lunixi-sdk 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/docs/rails.tr.md ADDED
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: Rails
3
+ description: Lunixi Ruby SDK için çalıştırılabilir Rails örneği — hosted ödeme, şifreli token, CSRF, kalıcı webhook dedupe ve ödeme mutabakatı.
4
+ ---
5
+
6
+
7
+ # Rails
8
+
9
+ [Ruby SDK](/sdks/ruby) kaynak deposundaki `samples/rails`, çalıştırılabilir Rails 8.1 referans uygulamasıdır. Sunucuda hesaplanan tutarla hosted ödeme başlatır; token’ı şifreli saklar, alıcı rotalarını CSRF ve demo kimlik doğrulamayla korur, doğrulanmış webhook receipt’i ile sipariş değişikliğini atomik commit eder.
10
+
11
+ Örnek **tek üye işyeri ve sabit toplamlı TRY siparişleri** içindir. Ayrı yayımlanmış Rails gem’i veya tamamlanmış mağaza değildir. İmza, token önbelleği, retry ve webhook kriptografisi `lunixi-sdk` içinde kalır.
12
+
13
+ ## Kurulum
14
+
15
+ Ruby 3.3+, Bundler, Ed25519 destekli OpenSSL ve SQLite gerekir. SDK’nın bağımsız JSON uyum testi Node.js kullanır.
16
+
17
+ ```bash
18
+ git clone https://github.com/Lunixi-Payment/lunixi-ruby.git
19
+ cd lunixi-ruby
20
+ bundle install
21
+ ```
22
+
23
+ Gemfile, yerel SDK ile Rails, Puma ve SQLite bağımlılıklarını kurar. `lunixi-sdk` için RubyGems yayını varsayılmaz. [Örnek kaynak kod](https://github.com/Lunixi-Payment/lunixi-ruby/tree/main/samples/rails).
24
+
25
+ ## Secret ayarları
26
+
27
+ Başlamadan önce değişkenleri shell veya secret manager üzerinden dışa aktarın. Uygulama dotenv’i otomatik yüklemez; depodaki `samples/rails/.env.example` kontrol listesi olarak kullanılabilir.
28
+
29
+ | Değişken | Amaç |
30
+ | --- | --- |
31
+ | `LUNIXI_BASE_URL` | HTTPS gateway origin; normalde `https://api-gateway.lunixi.com` |
32
+ | `LUNIXI_KEY_ID` | Kayıtlı TEST/LIVE üye işyeri key id |
33
+ | `LUNIXI_MERCHANT_ID` | Doğrulanmış ödeme yanıtıyla birebir eşleşmesi gereken üye işyeri UUID değeri |
34
+ | `LUNIXI_PRIVATE_KEY_PATH` | Public web dizini dışındaki özel Ed25519 anahtarı |
35
+ | `LUNIXI_WEBHOOK_SECRET` | Kayıtlı webhook bağlantısına ait secret |
36
+ | `PUBLIC_ORIGIN` | Bu Rails uygulamasının public HTTPS origin’i; path içermez |
37
+ | `DEMO_USERNAME`, `DEMO_PASSWORD` | Demo HTTP Basic; parola en az 16 bayt |
38
+ | `SECRET_KEY_BASE` | Kalıcı Rails session secret |
39
+ | `ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY` | Kalıcı ana şifreleme anahtarı |
40
+ | `ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY` | Bağımsız deterministik şifreleme anahtarı |
41
+ | `ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT` | Bağımsız türetme salt’ı |
42
+
43
+ Session secret için yerelde `ruby -rsecurerandom -e 'puts SecureRandom.hex(64)'` çalıştırın. Şifreleme ayarları için üç bağımsız, rastgele 32 bayt değer daha üretin. Güvenle saklayın ve yeniden başlatmada koruyun. Rails anahtar rotasyonunu planlamadan anahtarları değiştirmeyin; eski anahtarı kaybetmek kayıtlı token’ları okunamaz yapar.
44
+
45
+ Yerel HTTPS tüneli `PUBLIC_ORIGIN` sağlayabilir. Bu origin dönüş URL’si ve izin verilen host için kullanılır; uygulamadan önce ayarlanmalıdır. Geliştirme sunucusunu kimlik doğrulama olmadan public açmayın.
46
+
47
+ ## Ödeme başlatma
48
+
49
+ ```bash
50
+ cd samples/rails
51
+ bundle exec rails db:migrate db:seed
52
+ bundle exec rails server -b 127.0.0.1
53
+ ```
54
+
55
+ `http://localhost:3000/orders/00000000-0000-4000-8000-000000000001` adresini açın, demo bilgileriyle giriş yapıp ödeme butonuna basın. Sentetik başlangıç siparişi **100,50 TRY**’dir. Türkçe arayüz için `?locale=tr` ekleyin.
56
+
57
+ Checkout POST’u CSRF token ister; tutar/para birimini veritabanından okur. Gateway çağrısından önce siparişe UUID `checkout_key` kaydedilir. Aynı anahtarla intent oluşturulur, `intent_id` saklanır ve `checkout_token` Active Record Encryption ile şifrelenir. Ardından sabit `pay.lunixi.com` host’una 303 yönlendirmesi yapılır. Yeni ve kayıtlı token’lar ASCII allowlist ile doğrulanır; tarayıcı veya gateway başka host/protokol veremez.
58
+
59
+ Ağ hatasında sipariş pending kalır, işlem anahtarı korunur. Checkout tekrarında aynı anahtar kullanılır. Kayıtlı token yeniden kullanılır; belirsiz deneme silinip sessizce yeni tahsilat açılmaz. Süresi dolmuş veya sonucu bilinmeyen deneme mutabakat gerektirir.
60
+
61
+ Lunixi formu, üye işyerine açık yöntemleri ve uygun alternatif/çoklu kart akışlarını sunar. Uygulama PAN/CVV işlemez.
62
+
63
+ ## Webhook kaydı
64
+
65
+ Lunixi panelinde aşağıdaki URL’yi kaydedip eşleşen webhook secret’ını ayarlayın:
66
+
67
+ ```text
68
+ https://<public-hostunuz>/webhooks/lunixi
69
+ ```
70
+
71
+ Dokümante ödeme olaylarına abone olun. Dış çağrı alan `WebhooksController < ActionController::API`, özgün `request.raw_post` baytlarını kullanır ve tarayıcı session’ına bağlı değildir. Ayrı middleware yalnız `POST /orders/{public_id}/return` bilgi dönüşünü de kabul eder: atılan ham gövdeyi 1 MiB ile sınırlar, bütün alanları yok sayar; sipariş/oturum sorgusu, gateway çağrısı veya mali yazım yapmadan mevcut kimlik doğrulamalı GET adresine boş ve sabit aynı-origin 303 verir. Bu bilgi adaptörü webhook sırrını kullanmaz. Checkout CSRF ve kimlik doğrulamayı korur. Normal GET deneme kimliğini ister; yayında gerçek müşteri/sipariş yetkisine bağlanmalıdır.
72
+
73
+ SDK v2 imza, zaman, olay kimliği/tipi ve rotasyonu doğrular. `RailsReplayStore`, benzersiz `(connection_id, event_id)` constraint’i olan receipt’i sipariş değişikliğiyle **aynı veritabanı transaction’ına** yazar. Aynı olay ikinci kez 200 döner ve sipariş yeniden işlenmez. Aynı kimliğin farklı baytları 400 alır. İşleyici veya sunucu ödeme sorgusu hatası receipt’i geri alır ve 503 döndürür; tekrar teslim kurtarabilir.
74
+
75
+ ## Tahsilat doğrulama
76
+
77
+ `WebhookProcessor`, `payment.captured` olayını yalnız kayıtlı sipariş/intent ile eşleştirir. Yetkili `payments.get(intent_id)` çağrısında şunları arar:
78
+
79
+ - nihai durum `CAPTURED`;
80
+ - aynı kalıcı sipariş referansı ve intent;
81
+ - aynı para birimi;
82
+ - siparişin tam sayı tutarıyla birebir eşleşme.
83
+
84
+ Bu koşullarla sipariş `paid` olur. Başarısız, yalnız provizyon alınmış, bilinmeyen veya tutarsız sonuç pending kalır. Diğer abone olunan olaylar tahsilat varsayılmadan kaydedilir. Tarayıcı dönüşü yalnız kayıtlı sipariş sayfasına gider; istemcinin `status` / `paid` parametreleri dikkate alınmaz.
85
+
86
+ >
87
+ SDK split, capture ve iade çağrılarını destekler. Bu örnek sabit toplamlı tek-intent TRY siparişi tamamlar. Split toplam tahsilat, kısmi teslim, müşteriye yansıtılan sigorta ve ek ücret açmadan önce açık muhasebe/mutabakat mantığı ekleyin. Eşleşmeyen tutar güvenli biçimde pending kalır.
88
+
89
+
90
+ ## Kendi uygulamanıza uyarlama
91
+
92
+ Demo HTTP Basic yerine müşteri/session/tenant ve sipariş sahipliği kontrollerinizi bağlayın. Sunucu toplamını, işlem başına kalıcı anahtarı ve atomik receipt/sipariş transaction’ını koruyun. Eksik webhook ve belirsiz sonuçlar için kalıcı mutabakat işi ekleyin; aynı korelasyon verisini kullanın.
93
+
94
+ Örnek SQLite ile test edilir. Başka üretim veritabanında ilgili Rails adapter’ını kurup transaction/eşzamanlılık kontrollerini yineleyin. Transaction içindeki uzun gateway sorgusu örneği sade tutar ama kapasiteyi sınırlar. Üretimde outbox/state machine ile kısa transaction kullanılabilir; olay kaydı ve iş değişikliğinin atomikliği korunmalıdır.
95
+
96
+ Yerel/test dışındaki ortamlarda HTTPS zorunludur. Rails yönlendirme adresine checkout token’ını yazabildiği için info istek logları kapalıdır. Ham ödeme/webhook gövdesi veya secret loglamayın. Kalıcı session/şifreleme anahtarları ve kalıcı veritabanı kullanın.
97
+
98
+ ## Test ve güvenlik
99
+
100
+ Depo kökünde:
101
+
102
+ ```bash
103
+ bundle exec rake test rails:test
104
+ bundle exec rubocop
105
+ bundle exec bundle-audit check --update
106
+ bundle exec brakeman --force --rails8 --path samples/rails --no-pager
107
+ gem build lunixi-sdk.gemspec
108
+ ```
109
+
110
+ Rails testleri sentetik gateway yanıtları ve gerçek SQLite kullanır; anahtar veya para hareketi gerektirmez. Kimlik doğrulama/CSRF, şifreli token, kalıcı anahtar, yönlendirme saldırısı, tarayıcı dönüşüne güvenmeme, imza, dedupe, yanlış tutar/sipariş ve rollback/yeniden teslim kapsanır. Brakeman’ın sabit host yönlendirmesine ait tek düşük güvenli false positive’i, kesin fingerprint ve saldırı testleriyle `samples/rails/config/brakeman.ignore` içinde gerekçelendirilmiştir. Genel bir güvenlik kategorisi kapatılmaz.
111
+
112
+ Canlıya geçmeden yetkili TEST üye işyeriyle uçtan uca ödeme, webhook ve iade kabul testi yapın. [Örnek README](https://github.com/Lunixi-Payment/lunixi-ruby/tree/main/samples/rails) ve [güvenlik politikası](https://github.com/Lunixi-Payment/lunixi-ruby/blob/main/SECURITY.md).
data/docs/ruby.en.md ADDED
@@ -0,0 +1,145 @@
1
+ ---
2
+ title: Ruby SDK
3
+ description: Lunixi Ruby SDK — source installation, Ed25519 signing, payments, fraud, durable idempotency and raw-body v2 webhooks.
4
+ ---
5
+
6
+
7
+ # Ruby SDK
8
+
9
+ `lunixi-sdk` is a server-side Ruby client for Lunixi. It handles OpenSSL Ed25519 request signing, synchronized token caching, compact JavaScript-compatible JSON, safe retries and raw-body webhook verification. It works with Rails, Sinatra, Rack and plain Ruby. The core gem has no framework dependency; the executable [Rails integration](/sdks/rails) is included in the source repository.
10
+
11
+ ## Requirements and source installation
12
+
13
+ Ruby **3.3+**, Bundler and an OpenSSL build supporting **Ed25519** are required. The Rails example uses Rails 8.1.
14
+
15
+ >
16
+ Version 0.1.1 is distributed from source. RubyGems publication is a separate release step; a registry install is not assumed here.
17
+
18
+
19
+ ```bash
20
+ git clone https://github.com/Lunixi-Payment/lunixi-ruby.git
21
+ cd lunixi-ruby
22
+ bundle install
23
+ gem build lunixi-sdk.gemspec
24
+ gem install ./lunixi-sdk-0.1.1.gem
25
+ ```
26
+
27
+ Source: [Lunixi-Payment/lunixi-ruby](https://github.com/Lunixi-Payment/lunixi-ruby). An application may also use a reviewed local checkout with `gem 'lunixi-sdk', path: '/absolute/path/to/lunixi-ruby'`. Pin an audited commit if you use a Bundler Git source.
28
+
29
+ ## Client and keys
30
+
31
+ ```ruby
32
+ require 'lunixi'
33
+
34
+ lunixi = Lunixi::Client.new(
35
+ base_url: ENV.fetch('LUNIXI_BASE_URL'),
36
+ key_id: ENV.fetch('LUNIXI_KEY_ID'),
37
+ private_key: File.read(ENV.fetch('LUNIXI_PRIVATE_KEY_PATH')),
38
+ timeout: 30,
39
+ max_retries: 2
40
+ )
41
+ ```
42
+
43
+ `base_url` must be an HTTPS origin such as `https://api-gateway.lunixi.com`, without credentials, a path, query or fragment. The key determines TEST/LIVE behavior. Create one client per merchant connection and reuse it across requests; the token cache is thread-safe.
44
+
45
+ Generate an Ed25519 key on your server:
46
+
47
+ ```bash
48
+ umask 077
49
+ openssl genpkey -algorithm ED25519 -out lunixi-private.pem
50
+ openssl pkey -in lunixi-private.pem -pubout -out lunixi-public.pem
51
+ ```
52
+
53
+ Register the public key in the Lunixi dashboard and use the resulting key id. Keep the private key outside source control and the public web root. Supported private-key encodings are PKCS#8 PEM, base64 PKCS#8 DER, or a base64 32-byte seed / 64-byte secret. See [Authentication](/getting-started/authentication).
54
+
55
+ ## Payment intent
56
+
57
+ Persist the payment attempt and its UUID idempotency key before the first request. Calculate prices on your server, not from a browser total.
58
+
59
+ ```ruby
60
+ result = lunixi.payments.create_intent(
61
+ {
62
+ amount: 10_050,
63
+ currency: 'TRY',
64
+ orderId: order.public_id,
65
+ callbackUrl: 'https://merchant.example/payments/return',
66
+ basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: 10_050 }]
67
+ },
68
+ idempotency_key: order.persisted_payment_attempt_key
69
+ )
70
+
71
+ intent_id = result.fetch('data').fetch('paymentId')
72
+ checkout_token = result.fetch('data').fetch('token')
73
+ ```
74
+
75
+ `10_050` means 100.50 TRY in kuruş. Persist the returned intent and encrypt the checkout token at rest. The Rails sample validates the token and builds the fixed-origin hosted checkout address `https://pay.lunixi.com/payment/<token>`. The hosted form shows methods enabled for that merchant, including eligible alternative methods and multi-card flows; availability depends on account configuration. Do not collect PAN/CVV with this SDK.
76
+
77
+ >
78
+ An intent/token, HTTP 2xx, return URL or intermediate split state does not prove capture. Correlate a verified webhook with the persisted order and authenticate a payment status read; confirm final status, amount and currency before fulfillment.
79
+
80
+
81
+ Amounts must be positive integers within JavaScript's safe integer range. Root `price` and `paidPrice` are rejected even when null. Basket prices and split leg amounts must equal the relevant total. Intent `installment` is rejected because installments are selected in the checkout form. `preauth: true` requests authorization; capture is a separate operation.
82
+
83
+ ## Available helpers
84
+
85
+ | Operation | API |
86
+ | --- | --- |
87
+ | Create an intent | `lunixi.payments.create_intent(body, idempotency_key:)` |
88
+ | Read/list payments | `lunixi.payments.get(id)`, `lunixi.payments.list(**filters)` |
89
+ | Merchant methods | `lunixi.payments.methods(payment_method: nil)` |
90
+ | Capture | `lunixi.payments.capture(id, idempotency_key:, amount: nil)` |
91
+ | Full/partial refund | `lunixi.payments.refund(id, idempotency_key:, amount: nil, reason: nil)` |
92
+ | Void | `lunixi.payments.void(id, idempotency_key:)` |
93
+ | Split payment | `lunixi.payments.split(body, idempotency_key:)` |
94
+ | Split status/refund | `lunixi.payments.get_split(id)`, `lunixi.payments.refund_split(id, amount:, idempotency_key:, reason: nil)` |
95
+ | Fraud | `lunixi.fraud.evaluate(body)`, `lunixi.fraud.record_event(body, idempotency_key:)` |
96
+ | Advanced signed HTTP | `lunixi.request(method, path, body: nil, query: {}, idempotency_key: nil, signed: true, binary: false)` |
97
+
98
+ Capture/refund `amount: nil` asks the gateway to use the remaining amount. False, zero, string and fractional amounts are rejected. Persist a separate operation key for each capture, refund or void. Running split HTTP 400 protocol responses are returned only for documented running states; they remain pending, not paid. Response envelopes are preserved.
99
+
100
+ The advanced transport supports other **documented** gateway routes without a second HTTP implementation:
101
+
102
+ ```ruby
103
+ links = lunixi.request('GET', '/api/v1/payments/links', query: { limit: 20 })
104
+ ```
105
+
106
+ Payment, fraud and webhooks have explicit helpers. Other services require their published REST schema; this version does not provide a separate typed client for every product.
107
+
108
+ ## Webhooks
109
+
110
+ Get the exact raw request bytes before parsing JSON. Pass the Lunixi signature headers as strings:
111
+
112
+ ```ruby
113
+ verifier = Lunixi::WebhookVerifier.new(
114
+ secret: ENV.fetch('LUNIXI_WEBHOOK_SECRET'),
115
+ previous_secrets: []
116
+ )
117
+
118
+ verifier.process(raw_request_bytes, signature_headers, store: durable_transactional_store) do |event|
119
+ # Reconcile event.data with the persisted intent/order and payment status.
120
+ # Commit the business change in the same transaction as the event receipt.
121
+ end
122
+ ```
123
+
124
+ The verifier requires v2 HMAC-SHA256 over `eventId.timestamp.rawBody`, uses constant-time comparison, checks signed id/type consistency, rejects duplicate JSON keys, and accepts timestamps within **300 seconds** by default, including future skew. Up to two previous secrets support rotation. Header names are case-insensitive; duplicate headers are rejected.
125
+
126
+ Verification alone does not record receipt. Your store implements `process_once(id:, digest:) { … }`: insert a unique event receipt and update the order in one database transaction, return `:processed` / `:duplicate`, and raise `Lunixi::ReplayConflict` for the same id with different bytes. Handler failure must roll back the receipt, allowing redelivery. Retain receipts across process restarts. The Rails example includes a real database store and unique `(connection_id, event_id)` index.
127
+
128
+ ## Errors and retries
129
+
130
+ `Lunixi::Error` is the base class. `ConfigurationError`, `SignatureError`, `TransportError`, `RedirectError`, `WebhookError` / `ReplayConflict`, and `ApiError` subclasses distinguish failure types. API errors expose `status`, safe `code` / `request_id`, and optional `retry_after`; remote payloads and error text are omitted.
131
+
132
+ GET/HEAD and idempotency-keyed mutations may retry network errors or HTTP 429/502/503/504, default two retries and maximum three. Retries preserve the body and key while refreshing the nonce/signature. A safe 401 refreshes the token once. Unkeyed mutations are not retried. An uncertain timeout requires reconciliation with the **original** key, never a new charge key.
133
+
134
+ TLS peer/hostname verification is mandatory; redirects and path/credential injection are rejected. The request limit is 1 MiB; response limit is 8 MiB. `transport:`, `clock:` and `sleeper:` can be injected for deterministic testing. A custom transport returns `Lunixi::Response.new(status:, headers:, body:)` and must preserve TLS, redirect and thread-safety guarantees. `ca_file:` accepts an explicit trust bundle without disabling verification. Ambient proxy credentials are not inherited.
135
+
136
+ ## Testing and Rails
137
+
138
+ ```bash
139
+ bundle exec rake test rails:test
140
+ bundle exec rubocop
141
+ bundle exec bundle-audit check --update
142
+ bundle exec brakeman --force --rails8 --path samples/rails --no-pager
143
+ ```
144
+
145
+ The suite covers cryptographic vectors, tampering, replay/rotation, exact signed bytes, money, concurrent token caching, retries, real local TLS and Rails database transactions. Node.js supplies an independent JSON.stringify comparison. Tests use synthetic fixtures and move no funds; a registered TEST merchant is needed for a live acceptance run. See the [Rails guide](/sdks/rails) and repository [security policy](https://github.com/Lunixi-Payment/lunixi-ruby/blob/main/SECURITY.md).
data/docs/ruby.tr.md ADDED
@@ -0,0 +1,145 @@
1
+ ---
2
+ title: Ruby SDK
3
+ description: Lunixi Ruby SDK — kaynak kurulum, Ed25519 imza, ödeme ve fraud, kalıcı idempotency ve özgün baytlarla v2 webhook doğrulama.
4
+ ---
5
+
6
+
7
+ # Ruby SDK
8
+
9
+ `lunixi-sdk`, Lunixi için sunucu tarafı Ruby istemcisidir. Ed25519 imzayı OpenSSL ile üretir; token’ı eşzamanlı istekler arasında saklar, kompakt JavaScript uyumlu JSON oluşturur, güvenli tekrar denemeleri ve özgün baytlarla webhook doğrulamayı sağlar. Rails, Sinatra, Rack ve düz Ruby ile çalışır. Ana gem çerçeveden bağımsızdır; kaynak depodaki çalıştırılabilir [Rails örneği](/sdks/rails) ayrı adaptördür.
10
+
11
+ ## Gereksinimler ve kaynak kurulum
12
+
13
+ Ruby **3.3+**, Bundler ve **Ed25519** destekli OpenSSL gerekir. Rails örneği Rails 8.1 kullanır.
14
+
15
+ >
16
+ 0.1.1 kaynak kod üzerinden dağıtılır. RubyGems yayını ayrı bir sürüm adımıdır; aşağıdaki kurulum yayımlanmış bir registry paketi varsaymaz.
17
+
18
+
19
+ ```bash
20
+ git clone https://github.com/Lunixi-Payment/lunixi-ruby.git
21
+ cd lunixi-ruby
22
+ bundle install
23
+ gem build lunixi-sdk.gemspec
24
+ gem install ./lunixi-sdk-0.1.1.gem
25
+ ```
26
+
27
+ Kaynak: [Lunixi-Payment/lunixi-ruby](https://github.com/Lunixi-Payment/lunixi-ruby). İncelenmiş yerel kopyayı `gem 'lunixi-sdk', path: '/tam/yol/lunixi-ruby'` ile kullanabilirsiniz. Bundler Git kaynağı kullanırken denetlenmiş commit’i sabitleyin.
28
+
29
+ ## İstemci ve anahtarlar
30
+
31
+ ```ruby
32
+ require 'lunixi'
33
+
34
+ lunixi = Lunixi::Client.new(
35
+ base_url: ENV.fetch('LUNIXI_BASE_URL'),
36
+ key_id: ENV.fetch('LUNIXI_KEY_ID'),
37
+ private_key: File.read(ENV.fetch('LUNIXI_PRIVATE_KEY_PATH')),
38
+ timeout: 30,
39
+ max_retries: 2
40
+ )
41
+ ```
42
+
43
+ `base_url`, `https://api-gateway.lunixi.com` gibi yalnız HTTPS origin olmalıdır; kullanıcı bilgisi, path, query veya fragment içeremez. TEST/LIVE ortamını anahtar belirler. Her üye işyeri bağlantısı için tek istemci oluşturup paylaşın; token önbelleği thread-safe çalışır.
44
+
45
+ Sunucuda anahtar üretimi:
46
+
47
+ ```bash
48
+ umask 077
49
+ openssl genpkey -algorithm ED25519 -out lunixi-private.pem
50
+ openssl pkey -in lunixi-private.pem -pubout -out lunixi-public.pem
51
+ ```
52
+
53
+ Public anahtarı Lunixi paneline kaydedip verilen key id’yi kullanın. Özel anahtar kaynak kontrolüne ve public web dizinine girmemelidir. PKCS#8 PEM, base64 PKCS#8 DER, base64 32 bayt seed ve 64 bayt secret desteklenir. Bkz. [Kimlik doğrulama](/getting-started/authentication).
54
+
55
+ ## Ödeme intent’i
56
+
57
+ İlk istekten önce ödeme denemesini ve UUID idempotency anahtarını kalıcı kaydedin. Toplamı tarayıcıdan almak yerine sunucuda hesaplayın.
58
+
59
+ ```ruby
60
+ result = lunixi.payments.create_intent(
61
+ {
62
+ amount: 10_050,
63
+ currency: 'TRY',
64
+ orderId: order.public_id,
65
+ callbackUrl: 'https://merchant.example/payments/return',
66
+ basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: 10_050 }]
67
+ },
68
+ idempotency_key: order.persisted_payment_attempt_key
69
+ )
70
+
71
+ intent_id = result.fetch('data').fetch('paymentId')
72
+ checkout_token = result.fetch('data').fetch('token')
73
+ ```
74
+
75
+ `10_050`, 100,50 TRY’nin kuruş karşılığıdır. Intent’i kaydedin, checkout token’ını veritabanında şifreleyin. Rails örneği token’ı doğrulayıp sabit `https://pay.lunixi.com/payment/<token>` adresini kurar. Lunixi formu, üye işyerine açık yöntemleri ve uygun alternatif/çoklu kart akışlarını gösterir; kullanılabilirlik hesap ayarlarına bağlıdır. SDK ile PAN/CVV toplamayın.
76
+
77
+ >
78
+ Intent/token, HTTP 2xx, dönüş sayfası veya ara split durumu tahsilat kanıtı değildir. İmzalı webhook’u kalıcı siparişle eşleştirin; sunucudan ödeme sorgusunda nihai durum, tutar ve para birimi uyuşmadan siparişi tamamlamayın.
79
+
80
+
81
+ Tutarlar pozitif ve JavaScript güvenli tam sayı aralığında olmalıdır. Kök `price`/`paidPrice` null olsa bile reddedilir. Sepet ve split bacak toplamları ana toplamla aynı olmalıdır. Intent’te `installment` gönderilmez; taksit formda seçilir. `preauth: true` provizyon ister; capture ayrı işlemdir.
82
+
83
+ ## Yardımcı API’ler
84
+
85
+ | İşlem | API |
86
+ | --- | --- |
87
+ | Intent | `lunixi.payments.create_intent(body, idempotency_key:)` |
88
+ | Ödeme okuma/liste | `lunixi.payments.get(id)`, `lunixi.payments.list(**filters)` |
89
+ | Açık yöntemler | `lunixi.payments.methods(payment_method: nil)` |
90
+ | Capture | `lunixi.payments.capture(id, idempotency_key:, amount: nil)` |
91
+ | Tam/kısmi iade | `lunixi.payments.refund(id, idempotency_key:, amount: nil, reason: nil)` |
92
+ | Provizyon iptali | `lunixi.payments.void(id, idempotency_key:)` |
93
+ | Split | `lunixi.payments.split(body, idempotency_key:)` |
94
+ | Split durum/iade | `lunixi.payments.get_split(id)`, `lunixi.payments.refund_split(id, amount:, idempotency_key:, reason: nil)` |
95
+ | Fraud | `lunixi.fraud.evaluate(body)`, `lunixi.fraud.record_event(body, idempotency_key:)` |
96
+ | İmzalı genel HTTP | `lunixi.request(method, path, body: nil, query: {}, idempotency_key: nil, signed: true, binary: false)` |
97
+
98
+ Capture/iade `amount: nil`, kalan tutarın gateway tarafından kullanılmasını ister. False, sıfır, string ve kesirli tutar reddedilir. Her capture/iade/iptal için ayrı, kalıcı anahtar saklayın. Belgelenmiş devam eden split durumlarındaki HTTP 400 yanıtları protokol sonucu olarak döndürülür; ödenmiş sayılmaz. Yanıt zarfı korunur.
99
+
100
+ Diğer **dokümante** gateway uçları aynı imzalı transport üzerinden çağrılabilir:
101
+
102
+ ```ruby
103
+ links = lunixi.request('GET', '/api/v1/payments/links', query: { limit: 20 })
104
+ ```
105
+
106
+ Payment, fraud ve webhook için açık yardımcılar vardır. Diğer servisler yayımlanmış REST şemasıyla kullanılır; bu sürüm her ürün için ayrı tipli istemci sunduğunu iddia etmez.
107
+
108
+ ## Webhook
109
+
110
+ JSON parse işleminden önce özgün baytları alın. Lunixi imza header’larını string değerlerle aktarın:
111
+
112
+ ```ruby
113
+ verifier = Lunixi::WebhookVerifier.new(
114
+ secret: ENV.fetch('LUNIXI_WEBHOOK_SECRET'),
115
+ previous_secrets: []
116
+ )
117
+
118
+ verifier.process(raw_request_bytes, signature_headers, store: durable_transactional_store) do |event|
119
+ # Intent/sipariş ve sunucu ödeme sorgusuyla mutabakat yapın.
120
+ # Sipariş değişikliği ile olay kaydını aynı transaction içinde commit edin.
121
+ end
122
+ ```
123
+
124
+ v2 HMAC-SHA256, `eventId.timestamp.rawBody` üzerinde constant-time karşılaştırılır. İmzalı kimlik/tip tutarlılığı kontrol edilir; yinelenmiş JSON anahtarları reddedilir. Varsayılan geçmiş/gelecek zaman toleransı **300 saniyedir**. Rotasyonda en fazla iki önceki secret tutulabilir. Header isimleri büyük/küçük harfe duyarsızdır; yinelenmiş header reddedilir.
125
+
126
+ Yalnız doğrulama olay kaydı oluşturmaz. Store `process_once(id:, digest:) { … }` uygular: benzersiz receipt ve sipariş değişikliği tek veritabanı transaction’ında commit edilir; `:processed` / `:duplicate` döner. Aynı kimliğin farklı baytları `Lunixi::ReplayConflict` üretir. İşleyici hatasında receipt geri alınmalıdır; yeniden teslim kurtarabilir. Kayıtları süreçler ve yeniden başlatmalar arasında koruyun. Rails örneğinde benzersiz `(connection_id, event_id)` indeksiyle gerçek veritabanı implementasyonu vardır.
127
+
128
+ ## Hatalar ve tekrar deneme
129
+
130
+ Taban sınıf `Lunixi::Error`’dır. `ConfigurationError`, `SignatureError`, `TransportError`, `RedirectError`, `WebhookError` / `ReplayConflict` ve `ApiError` alt sınıfları ayrılır. API hatalarında `status`, güvenli `code` / `request_id` ve isteğe bağlı `retry_after` vardır; karşı tarafın gövdesi/hata metni eklenmez.
131
+
132
+ GET/HEAD ve idempotency anahtarlı mutation, ağ hatası veya HTTP 429/502/503/504 için varsayılan iki, en çok üç kez tekrarlanabilir. Gövde/anahtar değişmez; nonce/imza yenilenir. Güvenli 401 çağrısında token bir kez yenilenir. Anahtarsız mutation tekrarlanmaz. Belirsiz zaman aşımında özgün işlemi sorgulayın; **yeni** anahtarla tahsilat başlatmayın.
133
+
134
+ TLS sertifika/hostname doğrulaması zorunludur; yönlendirmeler ve path/credential enjeksiyonu engellenir. İstek 1 MiB, yanıt 8 MiB ile sınırlıdır. `transport:`, `clock:`, `sleeper:` testlerde enjekte edilebilir. Transport `Lunixi::Response.new(status:, headers:, body:)` döndürmeli ve TLS/yönlendirme/thread-safety koşullarını korumalıdır. `ca_file:` doğrulamayı kapatmadan özel CA sağlar; ortam proxy kimlik bilgileri otomatik aktarılmaz.
135
+
136
+ ## Testler ve Rails
137
+
138
+ ```bash
139
+ bundle exec rake test rails:test
140
+ bundle exec rubocop
141
+ bundle exec bundle-audit check --update
142
+ bundle exec brakeman --force --rails8 --path samples/rails --no-pager
143
+ ```
144
+
145
+ Testler kriptografik vektörleri, bozulmuş imzayı, replay/rotasyonu, imzalanan baytları, tutarları, eşzamanlı token’ı, retry’ı, gerçek yerel TLS ve Rails transaction’larını kapsar. Node.js bağımsız JSON.stringify karşılaştırması sağlar. Sentetik testler para taşımaz; canlı kabul testi için kayıtlı TEST üye işyeri gerekir. [Rails rehberi](/sdks/rails) ve [güvenlik politikası](https://github.com/Lunixi-Payment/lunixi-ruby/blob/main/SECURITY.md).
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lunixi
4
+ # Browser-safe configuration and strict backend-supplied optional hosted URLs.
5
+ module Checkout
6
+ def self.payment_page_url(value)
7
+ unless value.is_a?(String) && value.match?(%r{\Ahttps://pay\.lunixi\.com/payment/[A-Za-z0-9_.~-]{16,4096}\z})
8
+ raise Error, 'A valid backend paymentPageUrl is required'
9
+ end
10
+
11
+ value.dup.freeze
12
+ end
13
+
14
+ def self.browser_config(token)
15
+ raise Error, 'Checkout token is invalid' unless token.is_a?(String) && token.match?(/\A[A-Za-z0-9_.~-]{16,4096}\z/)
16
+
17
+ { type: 'payment', target: 'lunixi-checkout-form', options: { token: token } }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'time'
5
+
6
+ module Lunixi
7
+ # Thread-safe server-side gateway client. Responses retain the gateway envelope.
8
+ class Client
9
+ AUTH_PATH = '/api/v1/auth/token'
10
+ METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
11
+ attr_reader :payments, :fraud
12
+
13
+ def initialize(base_url:, key_id:, private_key:, timeout: 30, max_retries: 2, transport: nil,
14
+ clock: -> { Time.now.utc }, sleeper: ->(seconds) { sleep(seconds) }, ca_file: nil)
15
+ @origin = validate_origin(base_url)
16
+ raise ConfigurationError, 'Invalid key id' unless key_id.is_a?(String) && key_id.match?(/\A[a-zA-Z0-9_.:-]{1,200}\z/)
17
+ raise ConfigurationError, 'timeout must be between 1 and 120 seconds' unless timeout.is_a?(Numeric) && timeout.between?(1, 120)
18
+ raise ConfigurationError, 'max_retries must be between 0 and 3' unless max_retries.is_a?(Integer) && max_retries.between?(0, 3)
19
+
20
+ @key_id = key_id.dup.freeze
21
+ @signer = Signer.new(private_key)
22
+ @transport = transport || NetHttpTransport.new(timeout: timeout, ca_file: ca_file)
23
+ @max_retries = max_retries
24
+ @clock = clock
25
+ @sleeper = sleeper
26
+ @token_mutex = Mutex.new
27
+ @token = nil
28
+ @token_expires_at = Time.at(0)
29
+ @payments = Payments.new(self)
30
+ @fraud = Fraud.new(self)
31
+ end
32
+
33
+ # Advanced, signed transport for other documented gateway surfaces. It does
34
+ # not invent field schemas, interpret financial results or retry an unsafe POST.
35
+ def request(method, path, body: nil, query: {}, idempotency_key: nil, signed: true, binary: false)
36
+ method = method.to_s.upcase
37
+ raise ConfigurationError, 'Unsupported HTTP method' unless METHODS.include?(method)
38
+ raise ConfigurationError, 'GET and HEAD cannot have a body' if %w[GET HEAD].include?(method) && !body.nil?
39
+
40
+ path = safe_path(path, query)
41
+ key = self.class.idempotency_key!(idempotency_key) if idempotency_key
42
+ bytes = body.nil? ? nil : JsonBody.dump(body)
43
+ raise ConfigurationError, 'Request body exceeds the size limit' if bytes && bytes.bytesize > 1024 * 1024
44
+
45
+ perform_request(method, path, bytes, key, signed, binary)
46
+ end
47
+
48
+ # Validates caller-owned durable keys; a generated key on every retry is unsafe.
49
+ def self.idempotency_key!(key)
50
+ unless key.is_a?(String) && key.match?(/\A[\x21-\x7e]{16,128}\z/)
51
+ raise ConfigurationError, 'Idempotency key must be 16–128 printable ASCII characters without spaces'
52
+ end
53
+
54
+ key.dup.freeze
55
+ end
56
+
57
+ def inspect
58
+ '#<Lunixi::Client credentials=[REDACTED]>'
59
+ end
60
+
61
+ private
62
+
63
+ def perform_request(method, path, bytes, key, signed, binary)
64
+ safe_to_retry = %w[GET HEAD].include?(method) || !key.nil?
65
+ retries = 0
66
+ reauthenticated = false
67
+ loop do
68
+ token = access_token
69
+ response = transmit(method, path, bytes, key, token, signed)
70
+ if response.status == 401 && !reauthenticated && safe_to_retry
71
+ invalidate_token(token)
72
+ reauthenticated = true
73
+ next
74
+ end
75
+ if retryable_response?(response, safe_to_retry, retries)
76
+ pause(retries, response.headers)
77
+ retries += 1
78
+ next
79
+ end
80
+ return decode_response(response, binary: binary, running_split: method == 'POST' && path == '/api/v1/payments/split')
81
+ rescue TransportError
82
+ raise unless safe_to_retry && retries < @max_retries
83
+
84
+ pause(retries, {})
85
+ retries += 1
86
+ end
87
+ end
88
+
89
+ def retryable_response?(response, safe_to_retry, retries)
90
+ [429, 502, 503, 504].include?(response.status) && safe_to_retry && retries < @max_retries
91
+ end
92
+
93
+ def validate_origin(value)
94
+ uri = URI.parse(value)
95
+ unless uri.is_a?(URI::HTTPS) && uri.host && !uri.userinfo && !uri.query && !uri.fragment && ['', '/'].include?(uri.path)
96
+ raise ConfigurationError, 'base_url must be an HTTPS origin without credentials, path, query or fragment'
97
+ end
98
+
99
+ value.delete_suffix('/').freeze
100
+ rescue URI::InvalidURIError, TypeError
101
+ raise ConfigurationError, 'Invalid HTTPS gateway origin', cause: nil
102
+ end
103
+
104
+ def safe_path(path, query)
105
+ unless path.is_a?(String) && path.start_with?('/api/v1/') && path.match?(%r{\A/[a-zA-Z0-9_~./%-]+\z})
106
+ raise ConfigurationError, 'Only relative /api/v1/ gateway paths are accepted'
107
+ end
108
+
109
+ path.split('/').each do |segment|
110
+ decoded = URI::RFC2396_PARSER.unescape(segment)
111
+ raise ConfigurationError, 'Unsafe gateway path segment' if ['.', '..'].include?(decoded) || decoded.match?(%r{[\\/\x00-\x20\x7f%]})
112
+ end
113
+ encoded = encoded_query(query)
114
+ encoded.empty? ? path : "#{path}?#{encoded}"
115
+ end
116
+
117
+ def encoded_query(query)
118
+ raise ConfigurationError, 'Query must be a hash' unless query.is_a?(Hash)
119
+
120
+ pairs = query.flat_map do |name, value|
121
+ raise ConfigurationError, 'Invalid query key' unless name.to_s.match?(/\A[a-zA-Z][a-zA-Z0-9_]{0,63}\z/)
122
+
123
+ Array(value).map do |entry|
124
+ unless entry.is_a?(String) || entry.is_a?(Integer) || entry == true || entry == false
125
+ raise ConfigurationError, 'Query values must be strings, integers or booleans'
126
+ end
127
+ raise ConfigurationError, 'Query value is too long' if entry.to_s.bytesize > 4096
128
+
129
+ [name.to_s, entry.to_s]
130
+ end
131
+ end
132
+ URI.encode_www_form(pairs)
133
+ end
134
+
135
+ def signed_headers(method, path, body)
136
+ date = @clock.call.getutc.iso8601
137
+ nonce = SecureRandom.uuid
138
+ digest = Signer.digest(body) unless body.nil?
139
+ headers = { 'X-Key-Id' => @key_id, 'X-Date' => date, 'X-Nonce' => nonce }
140
+ headers['Digest'] = digest if digest
141
+ headers['X-Signature'] = @signer.sign(Signer.canonical(method, path, date, nonce, digest))
142
+ headers
143
+ end
144
+
145
+ def transmit(method, path, body, key, token, signed)
146
+ headers = { 'Accept' => 'application/json', 'User-Agent' => "lunixi-sdk-ruby/#{VERSION}" }
147
+ headers['Authorization'] = "Bearer #{token}" if token
148
+ headers['Content-Type'] = 'application/json' unless body.nil?
149
+ headers['Idempotency-Key'] = key if key
150
+ headers.merge!(signed_headers(method, path, body)) if signed
151
+ response = @transport.call(method: method, uri: URI.parse(@origin + path), headers: headers.freeze, body: body)
152
+ unless response.is_a?(Response) && response.status.is_a?(Integer) && response.headers.is_a?(Hash) && response.body.is_a?(String)
153
+ raise TransportError, 'Transport returned an invalid response'
154
+ end
155
+ raise TransportError, 'Gateway response exceeds the size limit' if response.body.bytesize > NetHttpTransport::MAX_RESPONSE_BYTES
156
+ raise RedirectError, 'Gateway redirects are rejected; credentials were not forwarded' if response.status.between?(300, 399)
157
+
158
+ response
159
+ end
160
+
161
+ def access_token
162
+ @token_mutex.synchronize do
163
+ return @token if @token && @clock.call < @token_expires_at
164
+
165
+ response = transmit('POST', AUTH_PATH, nil, nil, nil, true)
166
+ payload = decode_response(response)
167
+ data = payload['data'].is_a?(Hash) ? payload['data'] : payload
168
+ token = data['access_token'] || data['accessToken']
169
+ ttl = data['expires_in'] || data['expiresIn']
170
+ unless token.is_a?(String) && token.match?(/\A[a-zA-Z0-9_.:-]{1,8192}\z/) && ttl.is_a?(Integer) && ttl.positive? && ttl <= 86_400
171
+ raise AuthenticationError.new(status: 502, code: 'invalid_token_response')
172
+ end
173
+
174
+ @token = token.freeze
175
+ @token_expires_at = @clock.call + [ttl - 30, 1].max
176
+ token
177
+ end
178
+ end
179
+
180
+ def invalidate_token(previous)
181
+ @token_mutex.synchronize { @token = nil if @token == previous }
182
+ end
183
+
184
+ def pause(attempt, headers)
185
+ retry_after = headers['retry-after'] || headers['Retry-After']
186
+ seconds = retry_after.to_s.match?(/\A\d{1,3}\z/) ? [retry_after.to_i, 5].min : [0.1 * (2**attempt), 2].min
187
+ @sleeper.call(seconds)
188
+ end
189
+
190
+ def decode_response(response, binary: false, running_split: false)
191
+ return response.body.b.freeze if binary && response.status.between?(200, 299)
192
+
193
+ begin
194
+ payload = response.body.empty? ? {} : JSON.parse(response.body, max_nesting: 64)
195
+ rescue JSON::ParserError
196
+ raise ApiError.new(status: response.status, code: 'invalid_json_response'), cause: nil
197
+ end
198
+ raise ApiError.new(status: response.status, code: 'invalid_json_response') unless payload.is_a?(Hash)
199
+ return payload if response.status.between?(200, 299)
200
+
201
+ error_class = case response.status
202
+ when 401, 403 then AuthenticationError
203
+ when 400, 422 then ValidationError
204
+ when 429 then RateLimitError
205
+ else ApiError
206
+ end
207
+ error = error_class.new(status: response.status, code: payload['code'],
208
+ request_id: response.headers['x-request-id'] || response.headers['X-Request-Id'],
209
+ retry_after: response.headers['retry-after'])
210
+ # Running split sessions are successful protocol responses, not completed payments.
211
+ running = %w[COMPOSING AUTHORIZING AWAITING_3DS AWAITING_ACTION AWAITING_CREDIT_APPROVAL AWAITING_REPLACEMENT_CARD AUTHORIZED CAPTURING]
212
+ return payload if running_split && response.status == 400 && running.include?(payload['code']) && payload.dig('data', 'splitPaymentId').is_a?(String)
213
+
214
+ raise error
215
+ end
216
+ end
217
+ end