diff --git a/data/app.db b/data/app.db index b51fdde..bb4e71b 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/docs/odeme/iyzico.md b/docs/odeme/iyzico.md new file mode 100644 index 0000000..82f38ee --- /dev/null +++ b/docs/odeme/iyzico.md @@ -0,0 +1,99 @@ +# iyzico ödeme entegrasyonu + +Ödeme akışı iyzico **Checkout Form (CF)** üzerinden yürür: kart bilgisi hiçbir +zaman bize ulaşmaz, kullanıcı iyzico'nun barındırdığı ödeme sayfasına gider. + +## Ortam değişkenleri + +| Değişken | Açıklama | +| --- | --- | +| `IYZICO_API_KEY` | Merchant Portal → Ayarlar → API anahtarları | +| `IYZICO_SECRET_KEY` | Aynı ekran. İmza doğrulamasında da kullanılır | +| `IYZICO_BASE_URL` | Sandbox: `https://sandbox-api.iyzipay.com` · Canlı: `https://api.iyzipay.com` | +| `NEXT_PUBLIC_APP_URL` | callback ve webhook URL'lerinin kökü; **https ve geçerli SSL şart** | + +`IYZICO_BASE_URL` verilmezse sandbox'a düşülür. Prod'da bu durum log'a uyarı +basar — canlıya çıkarken bu satır mutlaka ayarlanmalı. + +> Lokal `http://localhost:3000` ile uçtan uca test edilemez: iyzico callback +> adresinden geçerli SSL ister. Sandbox testinde tünel (cloudflared/ngrok) açıp +> `NEXT_PUBLIC_APP_URL`'i o https adrese ayarla. + +## Akış + +1. `baslatOdeme` (`src/features/odeme/odeme-actions.ts`) — `orders` satırını + `pending` olarak yazar, `checkoutFormInitialize` çağırır, dönen `token`'ı + siparişe iliştirir ve kullanıcıyı `paymentPageUrl`'e yönlendirir. + `conversationId` = `basketId` = sipariş id'miz. +2. Kullanıcı ödemeyi bitirince iyzico `/api/odeme/callback` adresine + **cross-site POST** atar; gövdede yalnızca `token` vardır. SameSite=Lax + nedeniyle session çerezi gelmez, bu yüzden kullanıcı token'dan çözülür. +3. `odemeyiSonuclandir` (`src/lib/odeme.ts`) iyzico'ya `checkoutForm.retrieve` + ile sorar ve krediyi **idempotent** tanımlar. Kullanıcı 303 ile + `/odeme/sonuc?siparis=…` sayfasına düşer. +4. `/odeme/sonuc` self-healing'dir: sipariş hâlâ `pending` ise aynı fonksiyonu + tekrar çağırır (callback kaybolduysa kurtarır). +5. `/api/odeme/webhook` iyzico bildirimini karşılar — sekmesini kapatan ya da + fraud incelemesinde bekleyen ödemeler için yedek yol. + +## Doğruluk kuralları (bunlara dokunurken dikkat) + +- **Kredi yalnızca `retrieve` yanıtına göre tanımlanır.** Ne callback gövdesine + ne webhook gövdesine güvenilir; ikisi de sadece "iyzico'ya tekrar sor" + tetikleyicisidir. +- **`paid` geçişi koşulludur** (`WHERE status != 'paid'`): eşzamanlı + callback + sayfa render'ı ikinci kez kredi yazamaz. `grantCredits`'teki + `UNIQUE(reason, ref_id)` ikinci katman güvencedir. +- **Ara durumlar `failed` damgalanmaz.** `INIT_THREEDS`, `CALLBACK_THREEDS`, + `PENDING_CREDIT`, `INIT_BANK_TRANSFER` … ödemenin sonuçlanmadığı anlamına + gelir; damgalarsak dakikalar sonra SUCCESS'e dönen ödemede kredi kaybolur. +- **`fraudStatus`**: `1` onaylı → kredi verilir. `0` incelemede → `pending` + bırakılır (çekim kesinleşmemiştir). `-1` reddedildi → `failed`. +- **Erken `failed` kurtarılabilir**: geçiş koşulu `status != 'paid'` olduğu için + yanlışlıkla `failed` damgalanmış bir sipariş, iyzico SUCCESS derse yine + `paid`'e döner. Para çekildiyse kredi mutlaka tanımlanır. +- **Tutar kontrolü**: `paidPrice` sipariş tutarıyla eşleşmiyorsa kredi otomatik + tanımlanmaz, log'a düşer. + +## İmza doğrulaması + +iyzico yanıtlarında HMAC-SHA256 `signature` döner; alanlar `:` ile birleşir ve +fiyatlarda sondaki sıfırlar atılır (`299.00` → `299`). Tümü +`src/lib/iyzico.ts` içinde: + +| Yer | Alan sırası | +| --- | --- | +| `initImzaDurumu` | `conversationId:token` | +| `retrieveImzaDurumu` | `paymentStatus:paymentId:currency:basketId:conversationId:paidPrice:price:token` | +| `webhookImzaDurumu` (V3, HPP) | `HMAC(secret, secret + iyziEventType + iyziPaymentId + token + paymentConversationId + status)` | + +Karar kuralı: imza **tutmuyorsa** işlem reddedilir; imza alanı **hiç yoksa** +(hesapta kapalıysa) akış sürer ve log'a uyarı düşer — retrieve zaten kimliği +doğrulanmış sunucu-sunucu çağrısıdır, bu yüzden imza yokluğu ödemeyi bloklamaz. + +## Webhook kurulumu + +Merchant Portal → Ayarlar → İşyeri Ayarları → İşyeri Bildirimleri → +`https:///api/odeme/webhook` (HTTPS zorunlu). + +`X-IYZ-SIGNATURE-V3` başlığının gönderilmesi ayrıca aktifleştirilmelidir +(entegrasyon@iyzico.com). Aktif değilse başlık gelmez; route yine güvenlidir +çünkü durumu gövdeden değil retrieve'den okur. + +iyzico 2xx alana kadar 15 dakika arayla 3 kez dener — bu yüzden işleyemediğimiz +durumlarda bile 200 döneriz, yalnızca **geçersiz imzada** 401. + +## Test + +Sandbox test kartları: . +Son kullanma tarihi gelecekte olmak kaydıyla SKT ve CVV serbesttir. + +Dev panelinden (`src/components/dev/dev-panel.tsx`) iyzico'ya hiç gitmeden +"ödenmiş sahte sipariş" üretilebilir — sonuç ekranını denemek için. + +## Kaynaklar + +- [CF-Initialize](https://docs.iyzico.com/en/payment-methods/checkoutform/cf-implementation/cf-initialize) +- [CF-Retrieve](https://docs.iyzico.com/en/payment-methods/checkoutform/cf-implementation/cf-retrieve) +- [Response Signature Validation](https://docs.iyzico.com/en/advanced/response-signature-validation) +- [Webhook](https://docs.iyzico.com/en/advanced/webhook) diff --git a/drizzle/0002_mysterious_shiva.sql b/drizzle/0002_mysterious_shiva.sql new file mode 100644 index 0000000..2700e4d --- /dev/null +++ b/drizzle/0002_mysterious_shiva.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX `orders_iyzico_token` ON `orders` (`iyzico_token`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..078fb79 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,914 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8b41054c-6e3f-434a-a62f-1eafa48f810d", + "prevId": "46e3e804-4222-4e4c-9151-dd71510cded5", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_message_id": { + "name": "client_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_client_message_id_unique": { + "name": "chat_messages_client_message_id_unique", + "columns": [ + "client_message_id" + ], + "isUnique": true + }, + "chat_user_created": { + "name": "chat_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credit_ledger": { + "name": "credit_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delta": { + "name": "delta", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ledger_reason_ref": { + "name": "ledger_reason_ref", + "columns": [ + "reason", + "ref_id" + ], + "isUnique": true + }, + "ledger_user": { + "name": "ledger_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_kurus": { + "name": "amount_kurus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "iyzico_token": { + "name": "iyzico_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iyzico_payment_id": { + "name": "iyzico_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "orders_user": { + "name": "orders_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "orders_iyzico_token": { + "name": "orders_iyzico_token", + "columns": [ + "iyzico_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "orders_user_id_user_id_fk": { + "name": "orders_user_id_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reports": { + "name": "reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "params": { + "name": "params", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_count": { + "name": "revision_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reports_user_id_unique": { + "name": "reports_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reports_user_id_user_id_fk": { + "name": "reports_user_id_user_id_fk", + "tableFrom": "reports", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_lists": { + "name": "saved_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items": { + "name": "items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profil": { + "name": "profil", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "saved_lists_user_id_unique": { + "name": "saved_lists_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "saved_lists_user_id_user_id_fk": { + "name": "saved_lists_user_id_user_id_fk", + "tableFrom": "saved_lists", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tadimlik_havuzu": { + "name": "tadimlik_havuzu", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kova_slug": { + "name": "kova_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tur": { + "name": "tur", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kategori_slug": { + "name": "kategori_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tip": { + "name": "tip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'genel'" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dilim": { + "name": "dilim", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gerekce": { + "name": "gerekce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "risk_notu": { + "name": "risk_notu", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trend_ozeti": { + "name": "trend_ozeti", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "program": { + "name": "program", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ornek_sira": { + "name": "ornek_sira", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uretim_at": { + "name": "uretim_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tadimlik_anahtar": { + "name": "tadimlik_anahtar", + "columns": [ + "kova_slug", + "tur", + "kategori_slug", + "tip" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_paket": { + "name": "has_paket", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "kredi_bitti_at": { + "name": "kredi_bitti_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kredi_hatirlatma_gonderildi_at": { + "name": "kredi_hatirlatma_gonderildi_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0702c64..0f82be0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1786223252523, "tag": "0001_steady_donald_blake", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1786396071293, + "tag": "0002_mysterious_shiva", + "breakpoints": true } ] } \ No newline at end of file diff --git a/public/kolay-tercih-logo.svg b/public/kolay-tercih-logo.svg new file mode 100644 index 0000000..d4444e2 --- /dev/null +++ b/public/kolay-tercih-logo.svg @@ -0,0 +1,14 @@ + + KolayTercih + + + + + + + + + + + + diff --git a/src/app/api/odeme/callback/route.ts b/src/app/api/odeme/callback/route.ts index c873f93..a4b2dca 100644 --- a/src/app/api/odeme/callback/route.ts +++ b/src/app/api/odeme/callback/route.ts @@ -3,15 +3,27 @@ import { NextResponse, type NextRequest } from "next/server"; import { appDb, schema } from "@/lib/appdb"; import { odemeyiSonuclandir } from "@/lib/odeme"; +// iyzico ödeme formunu bitiren kullanıcıyı buraya POST'lar; gövdede yalnızca +// `token` vardır (Checkout Form akışında imza başlığı gelmez — durum her zaman +// odemeyiSonuclandir içindeki sunucu-sunucu retrieve çağrısından okunur). +// // DİKKAT: Bu route iyzico'dan gelen cross-site form POST'udur. // SameSite=Lax nedeniyle session çerezi GELMEZ — kullanıcı token'dan çözülür, // verifySession ÇAĞRILMAZ. Kredi tanımlama odemeyiSonuclandir içinde idempotenttir. export async function POST(request: NextRequest) { - const form = await request.formData(); - const token = form.get("token"); const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; - if (typeof token !== "string" || !token) { + let token: string | undefined; + try { + const form = await request.formData(); + const t = form.get("token"); + token = typeof t === "string" ? t.trim() : undefined; + } catch { + token = undefined; + } + + if (!token) { + console.error("[odeme] callback: token yok"); return NextResponse.redirect(`${appUrl}/paket?hata=token`, 303); } @@ -19,12 +31,12 @@ export async function POST(request: NextRequest) { where: eq(schema.orders.iyzicoToken, token), }); if (!order) { + console.error("[odeme] callback: token'a ait sipariş yok"); return NextResponse.redirect(`${appUrl}/paket?hata=siparis`, 303); } await odemeyiSonuclandir(order.id); - return NextResponse.redirect( - `${appUrl}/odeme/sonuc?siparis=${order.id}`, - 303, - ); + // 303: iyzico'nun POST'u GET'e döner, kullanıcı sonuç sayfasında yenileme + // yaptığında form yeniden gönderilmez. + return NextResponse.redirect(`${appUrl}/odeme/sonuc?siparis=${order.id}`, 303); } diff --git a/src/app/api/odeme/webhook/route.ts b/src/app/api/odeme/webhook/route.ts new file mode 100644 index 0000000..784d18b --- /dev/null +++ b/src/app/api/odeme/webhook/route.ts @@ -0,0 +1,87 @@ +import { eq } from "drizzle-orm"; +import { NextResponse, type NextRequest } from "next/server"; +import { appDb, schema } from "@/lib/appdb"; +import { odemeyiSonuclandir } from "@/lib/odeme"; +import { webhookImzaDurumu } from "@/lib/iyzico"; + +// iyzico ödeme bildirimi (webhook). Callback'in yedeğidir: kullanıcı ödeme +// sonrası sekmeyi kapatır ya da 3DS/fraud incelemesi ödemeyi dakikalar sonra +// SUCCESS'e çevirirse kredi yine de tanımlansın diye. +// +// Kurulum: iyzico Merchant Portal → Ayarlar → İşyeri Ayarları → İşyeri +// Bildirimleri; HTTPS URL zorunlu. İmza başlığı (X-IYZ-SIGNATURE-V3) hesapta +// ayrıca aktifleştirilmelidir (entegrasyon@iyzico.com). +// +// GÜVENLİK NOTU: Gövdeye hiç güvenilmez — sadece "şu sipariş için iyzico'ya +// tekrar sor" tetikleyicisidir. Ödeme durumu her hâlükârda kimliği doğrulanmış +// sunucu-sunucu retrieve çağrısından okunur (odemeyiSonuclandir). Bu yüzden imza +// başlığı yoksa da işlem güvenle sürdürülebilir; varsa sahtesi reddedilir. +// +// iyzico 2xx alana kadar 15 dakika arayla 3 kez dener — bu yüzden işleyemediğimiz +// durumlarda bile 200 döneriz (yeniden deneme bize bir şey kazandırmaz). + +interface WebhookGovde { + iyziEventType?: string; + iyziPaymentId?: string | number; + iyziReferenceCode?: string; + token?: string; + paymentConversationId?: string; + paymentId?: string; + status?: string; +} + +export async function POST(request: NextRequest) { + let govde: WebhookGovde; + try { + govde = (await request.json()) as WebhookGovde; + } catch { + return NextResponse.json({ ok: false }, { status: 400 }); + } + + const imza = + request.headers.get("x-iyz-signature-v3") ?? + request.headers.get("X-IYZ-SIGNATURE-V3") ?? + undefined; + + let imzaDurumu; + try { + imzaDurumu = webhookImzaDurumu(govde, imza); + } catch { + // IYZICO_KEYS_MISSING — doğrulayamıyorsak işlemeyi de denemeyiz + console.error("[odeme] webhook: iyzico anahtarları eksik"); + return NextResponse.json({ ok: false }, { status: 200 }); + } + if (imzaDurumu === "gecersiz") { + console.error("[odeme] webhook imzası geçersiz", { + referans: govde.iyziReferenceCode, + }); + return NextResponse.json({ ok: false }, { status: 401 }); + } + + // Siparişi önce conversationId (= sipariş id'miz), yoksa token üzerinden bul + const order = govde.paymentConversationId + ? await appDb.query.orders.findFirst({ + where: eq(schema.orders.id, govde.paymentConversationId), + }) + : govde.token + ? await appDb.query.orders.findFirst({ + where: eq(schema.orders.iyzicoToken, govde.token), + }) + : undefined; + + if (!order) { + console.warn("[odeme] webhook: sipariş bulunamadı", { + referans: govde.iyziReferenceCode, + }); + return NextResponse.json({ ok: true }); + } + + const durum = await odemeyiSonuclandir(order.id); + console.info("[odeme] webhook işlendi", { + siparis: order.id, + olay: govde.iyziEventType, + iyzicoDurum: govde.status, + durum, + }); + return NextResponse.json({ ok: true }); +} diff --git a/src/features/odeme/odeme-actions.ts b/src/features/odeme/odeme-actions.ts index 1610684..c3d9772 100644 --- a/src/features/odeme/odeme-actions.ts +++ b/src/features/odeme/odeme-actions.ts @@ -6,10 +6,21 @@ import { redirect } from "next/navigation"; import { verifySession } from "@/lib/session"; import { appDb, schema } from "@/lib/appdb"; import { URUNLER } from "@/lib/credits"; -import { initializeCheckoutForm } from "@/lib/iyzico"; +import { initializeCheckoutForm, initImzaDurumu } from "@/lib/iyzico"; export type OdemeBaslatDurum = { error?: string } | undefined; +const GENEL_HATA = + "Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?"; + +/** "Bilal Gürsen" → ["Bilal", "Gürsen"]; iyzico ad ve soyadı ayrı ve dolu ister. */ +function adSoyadAyir(tamAd: string): [string, string] { + const parcalar = tamAd.trim().split(/\s+/).filter(Boolean); + if (parcalar.length === 0) return ["KolayTercih", "Kullanıcısı"]; + if (parcalar.length === 1) return [parcalar[0], parcalar[0]]; + return [parcalar.slice(0, -1).join(" "), parcalar[parcalar.length - 1]]; +} + export async function baslatOdeme( _prev: OdemeBaslatDurum, formData: FormData, @@ -37,6 +48,7 @@ export async function baslatOdeme( const buyerIp = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "85.34.78.112"; const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + const [ad, soyad] = adSoyadAyir(session.user.name ?? ""); let init; try { @@ -46,6 +58,9 @@ export async function baslatOdeme( urunAdi: `KolayTercih — ${urun.label}`, email: session.user.email, userId: session.user.id, + ad, + soyad, + // iyzico bu adrese hem başarıyı hem başarısızlığı POST'lar; geçerli SSL şart callbackUrl: `${appUrl}/api/odeme/callback`, buyerIp, }); @@ -53,18 +68,28 @@ export async function baslatOdeme( if (err instanceof Error && err.message === "IYZICO_KEYS_MISSING") { // Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj console.error("[odeme] iyzico anahtarları eksik — ödeme başlatılamadı"); - return { - error: - "Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?", - }; + return { error: GENEL_HATA }; } - throw err; + console.error("[odeme] iyzico initialize hatası", err); + return { error: GENEL_HATA }; } if (init.status !== "success" || !init.paymentPageUrl || !init.token) { - return { - error: init.errorMessage ?? "Ödeme başlatılamadı. Tekrar dener misin?", - }; + // initialize aşamasında karta hiç dokunulmaz: buradaki hatalar kart/kullanıcı + // kaynaklı değil, bizim yapılandırmamızdandır (anahtar, üye işyeri ayarı, + // sepet kuralı). iyzico'nun mesajı log'a gider, kullanıcıya genel mesaj. + console.error("[odeme] initialize başarısız", { + orderId, + errorCode: init.errorCode, + errorMessage: init.errorMessage, + }); + return { error: GENEL_HATA }; + } + + // Yanıtın bütünlüğü: imza tutmuyorsa kullanıcıyı o ödeme sayfasına yollama. + if (initImzaDurumu(init) === "gecersiz") { + console.error("[odeme] initialize imzası geçersiz", { orderId }); + return { error: GENEL_HATA }; } await appDb diff --git a/src/lib/appdb/schema.ts b/src/lib/appdb/schema.ts index 7614555..2f39d3e 100644 --- a/src/lib/appdb/schema.ts +++ b/src/lib/appdb/schema.ts @@ -94,7 +94,13 @@ export const orders = sqliteTable( createdAt: integer("created_at", { mode: "timestamp" }).notNull(), paidAt: integer("paid_at", { mode: "timestamp" }), }, - (t) => [index("orders_user").on(t.userId)], + (t) => [ + index("orders_user").on(t.userId), + // Callback ve webhook siparişi token'dan bulur; UNIQUE aynı zamanda bir + // token'ın iki siparişe bağlanmasını da imkânsız kılar (SQLite'ta çoklu + // NULL serbesttir, token'sız pending siparişler etkilenmez). + uniqueIndex("orders_iyzico_token").on(t.iyzicoToken), + ], ); export const creditLedger = sqliteTable( diff --git a/src/lib/iyzico.ts b/src/lib/iyzico.ts index 49f85de..848ed88 100644 --- a/src/lib/iyzico.ts +++ b/src/lib/iyzico.ts @@ -1,54 +1,182 @@ import "server-only"; +import { createHmac, timingSafeEqual } from "node:crypto"; import Iyzipay from "iyzipay"; // iyzipay CJS + callback tabanlı; burada promisify'lı ince bir katman var. // Anahtarlar boşsa (henüz sandbox hesabı yoksa) çağrı anlaşılır bir hatayla düşer. +// +// Doğrulama: iyzico her yanıtta HMAC-SHA256 bir `signature` döner ve webhook'ta +// X-IYZ-SIGNATURE-V3 başlığı gönderir. Alan listeleri ve sıraları iyzico +// dokümantasyonundan birebir alınmıştır (docs.iyzico.com → Response Signature +// Validation / Webhook); değiştirilirse imzalar tutmaz. const globalForIyzi = globalThis as unknown as { __iyzipay?: Iyzipay }; -function getClient(): Iyzipay { - if (!process.env.IYZICO_API_KEY || !process.env.IYZICO_SECRET_KEY) { - throw new Error("IYZICO_KEYS_MISSING"); +const SANDBOX_URI = "https://sandbox-api.iyzipay.com"; + +function anahtarlar() { + const apiKey = process.env.IYZICO_API_KEY; + const secretKey = process.env.IYZICO_SECRET_KEY; + if (!apiKey || !secretKey) throw new Error("IYZICO_KEYS_MISSING"); + const uri = process.env.IYZICO_BASE_URL ?? SANDBOX_URI; + // Sessizce sandbox'a düşmek prod'da "ödeme alındı ama para yok" demektir; + // env unutulursa en azından log'da bağırsın. + if (process.env.NODE_ENV === "production" && uri === SANDBOX_URI) { + console.warn( + "[odeme] UYARI: prod'da iyzico sandbox URI kullanılıyor — IYZICO_BASE_URL ayarlanmamış", + ); } + return { apiKey, secretKey, uri }; +} + +function getClient(): Iyzipay { + const { apiKey, secretKey, uri } = anahtarlar(); if (!globalForIyzi.__iyzipay) { - globalForIyzi.__iyzipay = new Iyzipay({ - apiKey: process.env.IYZICO_API_KEY, - secretKey: process.env.IYZICO_SECRET_KEY, - uri: process.env.IYZICO_BASE_URL ?? "https://sandbox-api.iyzipay.com", - }); + globalForIyzi.__iyzipay = new Iyzipay({ apiKey, secretKey, uri }); } return globalForIyzi.__iyzipay; } +// ---- imza doğrulama ---- + +/** + * iyzico imzası: alanlar ":" ile birleştirilir, secretKey ile HMAC-SHA256'lanır + * ve hex'e çevrilir. Boş/eksik alan boş string olarak katılır (iyzico da öyle + * hesaplar), sıra kritiktir. + */ +function imzaHesapla( + alanlar: readonly (string | number | null | undefined)[], + secretKey: string, +) { + const veri = alanlar.map((a) => (a == null ? "" : String(a))).join(":"); + return createHmac("sha256", secretKey).update(veri).digest("hex"); +} + +/** Sabit zamanlı karşılaştırma — uzunluk farkında timingSafeEqual patlar. */ +function esitMi(a: string, b: string) { + const ab = Buffer.from(a, "utf8"); + const bb = Buffer.from(b, "utf8"); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + +/** + * İmzada fiyatlar sondaki sıfırlar atılmış haliyle geçer: "10.50" → "10.5", + * "299.00" → "299". (iyzico'nun kendi örneği parseFloat(x).toString().) + */ +function fiyatSadelestir(p: string | number | undefined) { + if (p == null) return ""; + const n = typeof p === "number" ? p : Number.parseFloat(p); + return Number.isFinite(n) ? String(n) : String(p); +} + +export type ImzaDurumu = "gecerli" | "gecersiz" | "yok"; + +function imzaKarsilastir( + alanlar: readonly (string | number | null | undefined)[], + imza: string | undefined, +): ImzaDurumu { + if (!imza) return "yok"; + const { secretKey } = anahtarlar(); + return esitMi(imzaHesapla(alanlar, secretKey), imza) ? "gecerli" : "gecersiz"; +} + +// ---- checkout form ---- + export interface CheckoutFormInitSonuc { status: string; token?: string; paymentPageUrl?: string; + conversationId?: string; + signature?: string; + errorCode?: string; errorMessage?: string; } export interface CheckoutFormSonuc { status: string; - paymentStatus?: string; // "SUCCESS" beklenir + paymentStatus?: string; // SUCCESS | FAILURE | INIT_THREEDS | PENDING_CREDIT ... + fraudStatus?: number; // 1 onaylı, 0 incelemede, -1 reddedildi conversationId?: string; + basketId?: string; paymentId?: string; + currency?: string; + price?: string | number; paidPrice?: string | number; + token?: string; + signature?: string; + errorCode?: string; errorMessage?: string; } +/** initialize yanıtı imzası: conversationId:token */ +export function initImzaDurumu(r: CheckoutFormInitSonuc): ImzaDurumu { + return imzaKarsilastir([r.conversationId, r.token], r.signature); +} + +/** + * retrieve yanıtı imzası: + * paymentStatus:paymentId:currency:basketId:conversationId:paidPrice:price:token + */ +export function retrieveImzaDurumu(r: CheckoutFormSonuc): ImzaDurumu { + return imzaKarsilastir( + [ + r.paymentStatus, + r.paymentId, + r.currency, + r.basketId, + r.conversationId, + fiyatSadelestir(r.paidPrice), + fiyatSadelestir(r.price), + r.token, + ], + r.signature, + ); +} + +/** + * Webhook X-IYZ-SIGNATURE-V3 (HPP formatı — checkout form bu formatta gelir). + * Anahtarın kendisi de veriye katılır: + * HMAC(secretKey, secretKey + iyziEventType + iyziPaymentId + token + + * paymentConversationId + status) + */ +export function webhookImzaDurumu( + govde: { + iyziEventType?: string; + iyziPaymentId?: string | number; + token?: string; + paymentConversationId?: string; + status?: string; + }, + imza: string | undefined, +): ImzaDurumu { + if (!imza) return "yok"; + const { secretKey } = anahtarlar(); + const veri = + secretKey + + (govde.iyziEventType ?? "") + + (govde.iyziPaymentId ?? "") + + (govde.token ?? "") + + (govde.paymentConversationId ?? "") + + (govde.status ?? ""); + const beklenen = createHmac("sha256", secretKey).update(veri).digest("hex"); + return esitMi(beklenen, imza) ? "gecerli" : "gecersiz"; +} + export function initializeCheckoutForm(opts: { orderId: string; fiyatKurus: number; urunAdi: string; email: string; userId: string; + ad: string; + soyad: string; callbackUrl: string; buyerIp: string; }): Promise { const price = (opts.fiyatKurus / 100).toFixed(2); // iyzico buyer bloğu zorunlu alanlar ister; dijital üründe adres sembolik const adres = { - contactName: "KolayTercih Kullanıcısı", + contactName: `${opts.ad} ${opts.soyad}`.trim(), city: "Istanbul", country: "Turkey", address: "Dijital teslimat", @@ -65,8 +193,8 @@ export function initializeCheckoutForm(opts: { enabledInstallments: [1], buyer: { id: opts.userId, - name: "KolayTercih", - surname: "Kullanıcısı", + name: opts.ad, + surname: opts.soyad, gsmNumber: "+905000000000", email: opts.email, identityNumber: "11111111111", @@ -75,6 +203,8 @@ export function initializeCheckoutForm(opts: { city: adres.city, country: adres.country, }, + // Sepet tamamen VIRTUAL; iyzico shippingAddress'i bu durumda zorunlu + // tutmuyor ama göndermek de sorun değil, fraud skorunda tutarlılık sağlar. shippingAddress: adres, billingAddress: adres, basketItems: [ @@ -98,9 +228,12 @@ export function initializeCheckoutForm(opts: { }); } -export function retrieveCheckoutForm( - token: string, -): Promise { +/** + * conversationId BİLEREK gönderilmez: gönderilirse iyzico onu aynen yankılar ve + * "yanıttaki conversationId siparişimizle aynı mı" kontrolü anlamsızlaşır. + * Göndermeyince ödemenin kendi conversationId'si döner ve doğrulanabilir. + */ +export function retrieveCheckoutForm(token: string): Promise { return new Promise((resolve, reject) => { getClient().checkoutForm.retrieve( { locale: Iyzipay.LOCALE.TR, token } as never, @@ -110,4 +243,4 @@ export function retrieveCheckoutForm( }, ); }); -} \ No newline at end of file +} diff --git a/src/lib/odeme.ts b/src/lib/odeme.ts index 8acb67b..136b9e4 100644 --- a/src/lib/odeme.ts +++ b/src/lib/odeme.ts @@ -1,21 +1,59 @@ import "server-only"; import { after } from "next/server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { appDb, schema } from "./appdb"; import { grantCredits, URUNLER } from "./credits"; -import { retrieveCheckoutForm } from "./iyzico"; +import { + retrieveCheckoutForm, + retrieveImzaDurumu, + type CheckoutFormSonuc, +} from "./iyzico"; import { sunucuOlayi } from "./analitik-sunucu"; const { orders } = schema; +export type SiparisDurumu = "paid" | "pending" | "failed" | "not_found"; + +/** + * Ödeme henüz sonuçlanmamış ara durumlar (3DS ekranı, havale/kredi bekleyen + * akışlar, fraud incelemesi). Bunlarda sipariş "failed" DAMGALANMAZ — damgalarsak + * dakikalar sonra SUCCESS'e dönen ödeme "başarısız" kalır ve kredi tanımlanmaz. + */ +const ARA_DURUMLAR = new Set([ + "INIT_THREEDS", + "CALLBACK_THREEDS", + "BKM_POS_SELECTED", + "INIT_APM", + "INIT_CONTACTLESS", + "INIT_BANK_TRANSFER", + "INIT_CREDIT", + "PENDING_CREDIT", +]); + +/** iyzico yanıtı krediyi tanımlamak için yeterli mi? */ +function karar( + sonuc: CheckoutFormSonuc, +): "paid" | "pending" | "failed" { + if (sonuc.status !== "success") return "failed"; + if (sonuc.paymentStatus && ARA_DURUMLAR.has(sonuc.paymentStatus)) { + return "pending"; + } + if (sonuc.paymentStatus !== "SUCCESS") return "failed"; + // fraudStatus: 1 onaylı, 0 incelemede, -1 reddedildi. İncelemedeyken çekim + // kesinleşmemiştir; krediyi webhook/yenileme SUCCESS+1 getirince tanımlarız. + if (sonuc.fraudStatus === 0) return "pending"; + if (sonuc.fraudStatus === -1) return "failed"; + return "paid"; +} + /** * Token'la iyzico'dan sonucu çeker ve başarılıysa krediyi İDEMPOTENT tanımlar. - * Hem callback route'u hem /odeme/sonuc self-healing fallback'i bunu kullanır. + * Callback route'u, webhook ve /odeme/sonuc self-healing fallback'i bunu kullanır. * Dönen değer: siparişin son durumu. */ export async function odemeyiSonuclandir( orderId: string, -): Promise<"paid" | "pending" | "failed" | "not_found"> { +): Promise { const order = await appDb.query.orders.findFirst({ where: eq(orders.id, orderId), }); @@ -30,18 +68,55 @@ export async function odemeyiSonuclandir( return order.status; // iyzico'ya ulaşılamadı; durumu değiştirme } - if (sonuc.status !== "success" || sonuc.paymentStatus !== "SUCCESS") { + // İmza tutmuyorsa yanıt bütünlüğü bozulmuş demektir — hiçbir şey yazma. + // İmza alanı hiç yoksa (hesapta kapalıysa) yanıt zaten kimliği doğrulanmış + // sunucu-sunucu çağrısından geldiği için akış sürer, sadece iz bırakılır. + const imza = retrieveImzaDurumu(sonuc); + if (imza === "gecersiz") { + console.error("[odeme] iyzico imzası geçersiz", { orderId }); + return order.status; + } + // Hata yanıtlarında imza alanı zaten gelmez; yalnızca başarılı yanıtta eksikse + // anlamlı bir sinyal (hesapta imza kapalı ya da API değişmiş). + if (imza === "yok" && sonuc.status === "success") { + console.warn("[odeme] iyzico yanıtında imza alanı yok", { orderId }); + } + + // conversationId uyuşmazlığı: bu token başka bir siparişe ait, işleme + if (sonuc.conversationId && sonuc.conversationId !== order.id) { + console.error("[odeme] conversationId uyuşmuyor", { orderId }); + return order.status; + } + + const durum = karar(sonuc); + + if (durum === "pending") { + return "pending"; // damgalama: sipariş pending kalır, tekrar sorulur + } + + if (durum === "failed") { + // paid'i asla geri almayız; pending → failed serbest await appDb .update(orders) .set({ status: "failed" }) - .where(and(eq(orders.id, orderId), eq(orders.status, "pending"))); + .where(and(eq(orders.id, orderId), ne(orders.status, "paid"))); return "failed"; } - if (sonuc.conversationId && sonuc.conversationId !== order.id) { - return order.status; // conversationId uyuşmazlığı: işleme + + // Tahsil edilen tutar siparişle uyuşmalı — uyuşmuyorsa krediyi otomatik verme + const odenenKurus = Math.round(Number(sonuc.paidPrice) * 100); + if (!Number.isFinite(odenenKurus) || odenenKurus !== order.amountKurus) { + console.error("[odeme] tutar uyuşmuyor", { + orderId, + beklenen: order.amountKurus, + gelen: sonuc.paidPrice, + }); + return order.status; } - // pending -> 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 + // kurtarılabilir (para çekilmişse kredi mutlaka tanımlanır). const res = await appDb .update(orders) .set({ @@ -49,7 +124,7 @@ export async function odemeyiSonuclandir( iyzicoPaymentId: sonuc.paymentId ?? null, paidAt: new Date(), }) - .where(and(eq(orders.id, orderId), eq(orders.status, "pending"))); + .where(and(eq(orders.id, orderId), ne(orders.status, "paid"))); if (res.rowsAffected === 0) return "paid"; // UNIQUE(reason, refId) ikinci katman güvence @@ -79,4 +154,4 @@ export async function odemeyiSonuclandir( return "paid"; } -export { URUNLER }; \ No newline at end of file +export { URUNLER };