chore: update iyzico integration and enhance payment processing logic
All checks were successful
Deploy / deploy (push) Successful in 7m0s
All checks were successful
Deploy / deploy (push) Successful in 7m0s
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.
This commit is contained in:
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
99
docs/odeme/iyzico.md
Normal file
99
docs/odeme/iyzico.md
Normal file
@@ -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://<alan-adı>/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ı: <https://docs.iyzico.com/en/add-ons/test-cards>.
|
||||
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)
|
||||
1
drizzle/0002_mysterious_shiva.sql
Normal file
1
drizzle/0002_mysterious_shiva.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX `orders_iyzico_token` ON `orders` (`iyzico_token`);
|
||||
914
drizzle/meta/0002_snapshot.json
Normal file
914
drizzle/meta/0002_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
14
public/kolay-tercih-logo.svg
Normal file
14
public/kolay-tercih-logo.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 199 44" role="img" aria-labelledby="kt-title">
|
||||
<title id="kt-title">KolayTercih</title>
|
||||
<!-- Mark: public/kolay-tercih-mark.svg (viewBox 180 -> 44px) -->
|
||||
<g transform="scale(0.244444)">
|
||||
<rect x="27" y="56" width="57" height="57" rx="13" fill="#3b82f6"/>
|
||||
<rect x="91" y="29" width="56" height="56" rx="13" fill="#fdc7a7"/>
|
||||
<rect x="91" y="95" width="56" height="56" rx="13" fill="#ff5a00"/>
|
||||
</g>
|
||||
<!-- "Kolay" — Bricolage Grotesque 300, "Tercih" — 600, tracking -0.05em -->
|
||||
<g fill="#0a0a0a">
|
||||
<path d="M48.92 31.90L46.70 31.90L46.70 12.10L48.92 12.10L48.92 21.76Q50.66 21.19 52.30 20.23Q53.93 19.27 55.36 17.99Q56.78 16.72 57.84 15.22Q58.91 13.72 59.48 12.10L61.91 12.10Q61.25 13.93 60.23 15.47Q59.21 17.02 57.95 18.27Q56.69 19.51 55.34 20.44Q53.99 21.37 52.67 21.91L52.67 22.27Q54.17 22.30 55.38 22.70Q56.60 23.11 57.60 23.90Q58.61 24.70 59.42 25.91Q60.23 27.13 60.89 28.78L62.06 31.90L59.54 31.90L58.82 29.59Q58.07 27.55 57.08 26.21Q56.09 24.88 54.59 24.20Q53.09 23.53 50.72 23.53L48.92 23.53M70.91 32.26Q68.72 32.26 67.05 31.29Q65.39 30.31 64.47 28.49Q63.56 26.68 63.56 24.10Q63.56 21.40 64.53 19.63Q65.51 17.86 67.16 16.98Q68.81 16.09 70.82 16.09Q72.89 16.09 74.55 17.03Q76.22 17.98 77.19 19.79Q78.17 21.61 78.17 24.25Q78.17 26.89 77.21 28.67Q76.25 30.46 74.60 31.36Q72.95 32.26 70.91 32.26M71 30.34Q72.53 30.34 73.64 29.59Q74.75 28.84 75.35 27.47Q75.95 26.11 75.95 24.28Q75.95 22.39 75.32 20.99Q74.69 19.60 73.53 18.84Q72.38 18.07 70.76 18.07Q69.26 18.07 68.15 18.77Q67.04 19.48 66.41 20.81Q65.78 22.15 65.78 24.04Q65.78 26.95 67.19 28.64Q68.60 30.34 71 30.34M83.06 31.90L80.84 31.90L80.84 11.05L83.06 11.05M90.08 32.26Q88.82 32.26 87.80 31.79Q86.78 31.33 86.18 30.34Q85.58 29.35 85.58 27.88Q85.58 26.83 85.95 25.99Q86.33 25.15 87.14 24.54Q87.95 23.92 89.30 23.50Q90.65 23.08 92.60 22.84Q93.80 22.69 94.52 22.52Q95.24 22.36 95.58 22.02Q95.93 21.67 95.93 20.95Q95.93 19.72 95.09 18.86Q94.25 18.01 92.45 18.01Q91.58 18.01 90.62 18.31Q89.66 18.61 88.94 19.36Q88.22 20.11 88.04 21.49L85.97 20.74Q86.18 19.69 86.69 18.85Q87.20 18.01 88.04 17.38Q88.88 16.75 90 16.42Q91.13 16.09 92.51 16.09Q94.40 16.09 95.64 16.72Q96.89 17.35 97.50 18.66Q98.12 19.96 98.12 22L98.12 25.69Q98.12 26.59 98.16 27.68Q98.21 28.78 98.30 29.88Q98.39 30.97 98.48 31.90L96.41 31.90Q96.32 30.88 96.26 29.92Q96.20 28.96 96.17 28L95.81 28Q95.39 29.11 94.64 30.08Q93.89 31.06 92.77 31.66Q91.64 32.26 90.08 32.26M90.56 30.37Q91.22 30.37 91.94 30.14Q92.66 29.92 93.38 29.42Q94.10 28.93 94.75 28.10Q95.39 27.28 95.96 26.11L95.96 23.71Q95.75 23.80 95.51 23.86Q94.67 24.13 93.65 24.28Q92.63 24.43 91.59 24.61Q90.56 24.79 89.70 25.13Q88.85 25.48 88.33 26.09Q87.80 26.71 87.80 27.79Q87.80 29.08 88.56 29.72Q89.33 30.37 90.56 30.37M106.94 37.03Q105.71 37.03 104.54 36.73Q103.37 36.43 102.42 35.83Q101.48 35.23 100.88 34.27L102.29 32.86Q102.92 33.88 104.12 34.43Q105.32 34.99 106.82 34.99Q108.56 34.99 109.70 34.21Q110.84 33.43 111.42 31.84Q112.01 30.25 112.01 27.82L112.37 24.58L111.86 24.58Q111.50 26.83 110.78 28.15Q110.06 29.47 109.04 30.04Q108.02 30.61 106.67 30.61Q104.99 30.61 103.83 29.77Q102.68 28.93 102.09 27.27Q101.51 25.60 101.51 23.26L101.51 16.45L103.67 16.45L103.67 22.84Q103.67 25.75 104.58 27.16Q105.50 28.57 107.24 28.57Q108.20 28.57 108.98 28.12Q109.76 27.67 110.33 26.72Q110.90 25.78 111.24 24.37Q111.59 22.96 111.68 21.01L111.68 16.45L113.90 16.45L113.90 27.97Q113.90 29.77 113.64 31.22Q113.39 32.68 112.82 33.76Q112.25 34.84 111.42 35.57Q110.60 36.31 109.47 36.67Q108.35 37.03 106.94 37.03"/>
|
||||
<path d="M125.18 31.90L121.37 31.90L121.37 15.31L115.49 15.31L115.49 12.10L131.09 12.10L131.09 15.31L125.18 15.31M139.43 32.29Q137.54 32.29 136.06 31.75Q134.57 31.21 133.55 30.17Q132.53 29.14 131.99 27.67Q131.45 26.20 131.45 24.34Q131.45 22.48 131.97 20.92Q132.50 19.36 133.49 18.22Q134.48 17.08 135.91 16.45Q137.33 15.82 139.16 15.82Q140.87 15.82 142.22 16.39Q143.57 16.96 144.50 18.09Q145.43 19.21 145.88 20.88Q146.33 22.54 146.18 24.73L135.11 24.82Q135.23 27.01 136.28 28.18Q137.42 29.47 139.43 29.47Q140.33 29.47 140.94 29.24Q141.56 29.02 141.98 28.64Q142.40 28.27 142.66 27.79Q142.91 27.31 143.06 26.77L146.36 27.52Q146.12 28.63 145.58 29.50Q145.04 30.37 144.19 31Q143.33 31.63 142.16 31.96Q140.99 32.29 139.43 32.29M135.23 22.63L142.73 22.54Q142.67 21.64 142.40 20.95Q141.95 19.87 141.11 19.34Q140.27 18.82 139.19 18.82Q137.96 18.82 137.03 19.46Q136.10 20.11 135.62 21.31Q135.38 21.91 135.23 22.63M151.91 31.90L148.13 31.90L148.13 16.21L151.31 16.21L151.25 21.61L151.79 21.61Q152.06 19.69 152.63 18.41Q153.20 17.14 154.22 16.49Q155.24 15.85 156.74 15.85Q157.04 15.85 157.38 15.88Q157.73 15.91 158.15 16.03L158 20.02Q157.52 19.84 157.03 19.77Q156.53 19.69 156.11 19.69Q154.94 19.69 154.09 20.29Q153.23 20.89 152.71 21.98Q152.18 23.08 151.91 24.58M166.34 32.29Q164.30 32.29 162.81 31.69Q161.33 31.09 160.35 29.99Q159.38 28.90 158.90 27.43Q158.42 25.96 158.42 24.25Q158.42 22.42 158.91 20.89Q159.41 19.36 160.40 18.22Q161.39 17.08 162.84 16.45Q164.30 15.82 166.19 15.82Q168.08 15.82 169.47 16.45Q170.87 17.08 171.74 18.20Q172.61 19.33 172.91 20.83L169.37 21.94Q169.31 21.04 168.89 20.34Q168.47 19.63 167.75 19.24Q167.03 18.85 166.04 18.85Q165.08 18.85 164.38 19.23Q163.67 19.60 163.20 20.29Q162.74 20.98 162.48 21.95Q162.23 22.93 162.23 24.13Q162.23 25.78 162.68 26.95Q163.13 28.12 164.06 28.77Q164.99 29.41 166.37 29.41Q167.60 29.41 168.37 28.95Q169.13 28.48 169.53 27.73Q169.94 26.98 170.03 26.08L173.39 26.83Q173.21 28.06 172.70 29.05Q172.19 30.04 171.31 30.77Q170.42 31.51 169.19 31.90Q167.96 32.29 166.34 32.29M178.37 31.90L174.59 31.90L174.59 16.21L178.37 16.21L178.37 31.90M176.48 13.75Q175.34 13.75 174.72 13.25Q174.11 12.76 174.11 11.83Q174.11 10.90 174.72 10.41Q175.34 9.91 176.48 9.91Q177.68 9.91 178.28 10.39Q178.88 10.87 178.88 11.83Q178.88 12.76 178.27 13.25Q177.65 13.75 176.48 13.75M184.91 31.90L181.13 31.90L181.13 10.60L184.94 10.60L184.94 15.49Q184.94 16.12 184.90 16.78Q184.85 17.44 184.76 18.13Q184.67 18.82 184.56 19.51Q184.46 20.20 184.34 20.89L184.97 20.89Q185.39 19.27 186.05 18.14Q186.71 17.02 187.75 16.42Q188.78 15.82 190.28 15.82Q193.01 15.82 194.36 17.73Q195.71 19.63 195.71 23.50L195.71 31.90L191.90 31.90L191.90 24.04Q191.90 21.46 191.13 20.23Q190.37 19 188.84 19Q187.58 19 186.74 19.77Q185.90 20.53 185.45 21.79Q185 23.05 184.91 24.64"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
@@ -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);
|
||||
}
|
||||
|
||||
87
src/app/api/odeme/webhook/route.ts
Normal file
87
src/app/api/odeme/webhook/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<CheckoutFormInitSonuc> {
|
||||
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<CheckoutFormSonuc> {
|
||||
/**
|
||||
* 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<CheckoutFormSonuc> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient().checkoutForm.retrieve(
|
||||
{ locale: Iyzipay.LOCALE.TR, token } as never,
|
||||
|
||||
@@ -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<SiparisDurumu> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user