From 08419e6d1c34a5c87a26801b4c6041429207dfd2 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Tue, 11 Aug 2026 00:30:53 +0300 Subject: [PATCH] chore: update iyzico integration and enhance payment processing logic Refactored the iyzico payment integration to improve the handling of payment callbacks and order status updates. Added signature validation for responses to ensure data integrity. Enhanced the `initializeCheckoutForm` function to include buyer's name and surname, and updated the order schema to enforce unique constraints on iyzico tokens. Improved error handling and logging for payment processing, ensuring better tracking of payment states and issues. Updated the app database to reflect these changes. --- data/app.db | Bin 589824 -> 606208 bytes docs/odeme/iyzico.md | 99 +++ drizzle/0002_mysterious_shiva.sql | 1 + drizzle/meta/0002_snapshot.json | 914 ++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + public/kolay-tercih-logo.svg | 14 + src/app/api/odeme/callback/route.ts | 26 +- src/app/api/odeme/webhook/route.ts | 87 +++ src/features/odeme/odeme-actions.ts | 43 +- src/lib/appdb/schema.ts | 8 +- src/lib/iyzico.ts | 165 ++++- src/lib/odeme.ts | 97 ++- 12 files changed, 1417 insertions(+), 44 deletions(-) create mode 100644 docs/odeme/iyzico.md create mode 100644 drizzle/0002_mysterious_shiva.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 public/kolay-tercih-logo.svg create mode 100644 src/app/api/odeme/webhook/route.ts diff --git a/data/app.db b/data/app.db index b51fdded395a3f0d1863a35480770c60d221a437..bb4e71b0e74b7cbc139c75c1d604d049cf4c2b10 100644 GIT binary patch delta 11944 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 delta 3999 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 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 };