lunixi-sdk 0.1.2 → 1.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dda2f615f12541201a6f330dbbdc4329f467a33ac7e5aea8aec2d4db0794fe88
4
- data.tar.gz: c5c9c52a55000090157e7afcdd1fbefc3e96dd79112b65adc581c7e6664e2a4b
3
+ metadata.gz: 40366427b51fa84757c9c338364d43e025105e683a04790919a4707d300418ec
4
+ data.tar.gz: 703ff10b22fdd261e095f129ac5c9c7e25b643843cf2cbeabc3b88075ac5b79f
5
5
  SHA512:
6
- metadata.gz: 25e95b8ed3a4b7f22e745cc83b5b1c21a73faa669dddc11bdfd1c92cd69d656ebff7f402c75e1462b0c00b13cca25c71a7f51fd57c397edb019f53e00f0df06b
7
- data.tar.gz: 7b888abc11c519dd5144dcc7b83f05fd37db37bb50e8907a7ff737db382f177a1cc26d4da311fa1eeaae59ab037604eb6b50e01700329291d9c46ad855bc1581
6
+ metadata.gz: bcfe1c791b4270392c9ce4f7c13d0592c9f2923ede5ad9be524ac7df8e487d3dd69d4468c17de7e33c11a7d8e243618da9a8e5c74dfb046b824140dda3991985
7
+ data.tar.gz: a10f6eaadfeae29b023b67fd5293d3d65979ed35019a20ef467c794a481dc6e5d850fd772b2a30788580367b78007a78c73d2c2be9f87212c9e92684706cb8ae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1 — 2026-11-01
4
+
5
+ - Align Rails sample API mocks and expectations with canonical minor-unit strings while preserving numeric-input rejection tests; runtime admission is unchanged.
6
+ - Preserve response parsing limits and error handling in a private helper; close RuboCop findings without suppressions.
7
+ - Update 1.0.1 installation and exact-tag publishing guidance.
8
+
9
+ ## 1.0.0 — 2026-11-01
10
+
11
+ - Reject explicit null capture/refund amounts; full remaining operations use omission or the dedicated API described in MIGRATION-1.0.md.
12
+
13
+ - Breaking: public money request/response fields require canonical minor-unit strings; JSON numbers are rejected.
14
+ - Exact signed 64-bit fiat arithmetic and currency-aware decimal text conversion preserve values above JavaScript’s safe-integer limit.
15
+ - See [MIGRATION-1.0.md](MIGRATION-1.0.md) before upgrading.
16
+
17
+
3
18
  ## 0.1.2 — 2026-10-31
4
19
 
5
20
  - Add strict backend payment-page URL and official embedded-form configuration helpers, without rebuilding hosted URLs from tokens.
data/MIGRATION-1.0.md ADDED
@@ -0,0 +1,19 @@
1
+ # 1.0.0 migration
2
+
3
+ All public monetary request and response fields now use canonical base-10 **strings in minor units**. For example, `amount: "1990"` represents TRY 19.90. This includes basket `price`, split and note amounts, capture/refund, balances and fixed fees on supported endpoints. Numeric JSON money is rejected; convert trusted native exact integers explicitly at your application boundary. Rates, basis points, quantities, counts and timestamps remain numeric.
4
+
5
+ Use the package’s decimal-text conversion helper with the actual currency. JPY has exponent 0, TRY/USD 2, KWD 3. Do not parse decimal text through float/double or assume a factor of 100. `"9007199254740993"` remains exact. Fiat amounts must fit signed 64-bit storage (maximum `"9223372036854775807"`); provider/platform limits may be lower. Crypto is a separate domain.
6
+
7
+ Canonical strings reject whitespace, signs on nonnegative values, leading zeros, decimals, exponents, `NaN`, `Infinity`, and negative zero. Payments are positive. Settlement totals may be zero; balances may be signed. Only omission retains remaining-amount semantics. Explicit JSON `null` and `"0"` are invalid. Split-refund amount remains required where the existing API requires it.
8
+
9
+ Update call sites, persisted outbound request schemas and gateway response fixtures together. Preserve an existing attempt’s exact body, idempotency key, environment and credential profile; reconcile historical attempts before creating new ones. The SDK does not silently rewrite historical payment bytes or accept both numeric and string wire money. Native internal exact integer storage may remain unchanged.
10
+
11
+ Raw webhook verification, signatures, authentication and replay/idempotency protections are unchanged. Upgrade the gateway and integrations as one coordinated breaking cut. Install version 1.0.0 after it is available in this package’s distribution channel.
12
+
13
+ Use `payments.create_intent({ amount: '1990', ... }, idempotency_key: key)` and `Lunixi::Money.decimal_to_minor('19.90', 'TRY')`. Capture/refund accept strings or omitted keywords; numeric money raises `ConfigurationError`.
14
+
15
+ ## Optional capture/refund amounts
16
+
17
+ Omit the `amount:` keyword for the remaining balance: `payments.capture(id, idempotency_key: key)` and `payments.refund(id, idempotency_key: key, reason: reason)`. Explicit `amount: nil` is rejected. The omission marker is private and never serialized.
18
+
19
+ The advanced/raw request boundary also rejects a present `amount: null` on capture/refund routes. Unrelated nullable response fields and merchant metadata keep their existing meaning.
data/README.md CHANGED
@@ -2,24 +2,25 @@
2
2
 
3
3
  # Lunixi Ruby SDK
4
4
 
5
+ **Version 1.0.1 uses canonical minor-unit strings for money.** This is a breaking change from 0.x; update request and response handling together. See [migration](MIGRATION-1.0.md).
6
+
5
7
  [English](README.md) · [Türkçe](README.tr.md) · [Documentation](https://docs.lunixi.com/sdks/ruby) · [Rails sample](samples/rails)
6
8
 
7
- Server-side Ruby client for Lunixi payments, fraud and signed gateway requests. It signs requests with OpenSSL Ed25519, caches tokens across threads, preserves integer minor-unit amounts, retries safe operations with the same idempotency key, and verifies raw-body v2 webhooks. Private keys and card details stay out of the browser and application logs.
9
+ Server-side Ruby client for Lunixi payments, fraud and signed gateway requests. It signs requests with OpenSSL Ed25519, caches tokens across threads, preserves canonical minor-unit strings, retries safe operations with the same idempotency key, and verifies raw-body v2 webhooks. Private keys and card details stay out of the browser and application logs.
8
10
 
9
11
  ## Requirements and installation
10
12
 
11
13
  Ruby **3.3+**, OpenSSL with **Ed25519** support, and Bundler. The runnable Rails sample uses **Rails 8.1** and SQLite. The core gem does not depend on Rails.
12
14
 
13
- Version **0.1.2** is distributed as source here. RubyGems publication is a separate release step; this guide does not assume a published registry package.
15
+ After version 1.0.1 is available on RubyGems, add the dependency and run `bundle install`:
14
16
 
15
- ```bash
16
- git clone https://github.com/Lunixi-Payment/lunixi-ruby.git
17
- cd lunixi-ruby
18
- bundle install
19
- gem build lunixi-sdk.gemspec
20
- gem install ./lunixi-sdk-0.1.2.gem
17
+ ```ruby
18
+ source 'https://rubygems.org'
19
+ gem 'lunixi-sdk', '1.0.1'
21
20
  ```
22
21
 
22
+ Keep `Gemfile.lock` to retain the reviewed dependency resolution.
23
+
23
24
  Inside this checkout, `bundle exec ruby …` loads the local gem. An application can use a reviewed checkout with `gem 'lunixi-sdk', path: '/absolute/path/to/lunixi-ruby'`. Pin an audited commit when using a Bundler Git source. Never use an unpinned moving branch for a production deployment.
24
25
 
25
26
  ## Create a client
@@ -55,11 +56,11 @@ Calculate the total on the server and persist a UUID idempotency key with the pa
55
56
  ```ruby
56
57
  result = lunixi.payments.create_intent(
57
58
  {
58
- amount: 10_050, # 100.50 TRY; integer kuruş, never 100.50
59
+ amount: "10050", # 100.50 TRY; integer kuruş, never 100.50
59
60
  currency: 'TRY',
60
61
  orderId: order.public_id,
61
62
  callbackUrl: 'https://merchant.example/payments/return',
62
- basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: 10_050 }]
63
+ basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: "10050" }]
63
64
  },
64
65
  idempotency_key: order.persisted_payment_attempt_key
65
66
  )
@@ -67,7 +68,6 @@ result = lunixi.payments.create_intent(
67
68
  intent_id = result.fetch('data').fetch('paymentId')
68
69
  checkout_token = result.fetch('data').fetch('token')
69
70
  # Persist the intent id and encrypt the checkout token at rest.
70
- # The Rails sample validates the token and builds a fixed-origin redirect:
71
71
  # Optional hosted checkout uses the validated backend paymentPageUrl, never a URL built from this token.
72
72
  ```
73
73
 
@@ -82,8 +82,8 @@ The shared Lunixi form owns enabled payment methods and supports the methods con
82
82
  | Create intent | `payments.create_intent(body, idempotency_key:)` |
83
83
  | Get/list payment | `payments.get(id)`, `payments.list(**filters)` |
84
84
  | Enabled methods | `payments.methods(payment_method: nil)` |
85
- | Capture/preauthorization | `payments.capture(id, idempotency_key:, amount: nil)` |
86
- | Full/partial refund | `payments.refund(id, idempotency_key:, amount: nil, reason: nil)` |
85
+ | Capture/preauthorization | `payments.capture(id, idempotency_key:)` |
86
+ | Full/partial refund | `payments.refund(id, idempotency_key:, reason: nil)` |
87
87
  | Void authorization | `payments.void(id, idempotency_key:)` |
88
88
  | Split payment (1–5 legs) | `payments.split(body, idempotency_key:)` |
89
89
  | Split status/refund | `payments.get_split(id)`, `payments.refund_split(id, amount:, idempotency_key:, reason: nil)` |
@@ -91,17 +91,17 @@ The shared Lunixi form owns enabled payment methods and supports the methods con
91
91
  | Fraud transaction event | `fraud.record_event(body, idempotency_key:)` |
92
92
  | Other documented endpoints | `request(method, path, body: nil, query: {}, idempotency_key: nil, signed: true, binary: false)` |
93
93
 
94
- `amount`, split `totalAmount` and each leg/item price must be positive integers within JavaScript's safe integer range. Root `price`/`paidPrice` are removed fields and rejected even when null. Basket and split totals must agree. Intent installments belong to the checkout form; `installment` is rejected on intent creation. Set `preauth: true` for authorization, then capture separately with a new durable operation key. A nil capture/refund amount asks the gateway to use the remaining amount; zero, false, fractional and string amounts are rejected.
94
+ `amount`, split `totalAmount` and each leg/item price must be positive canonical minor-unit strings within the signed 64-bit fiat range. Root `price`/`paidPrice` are removed fields and rejected even when null. Basket and split totals must agree. Intent installments belong to the checkout form; `installment` is rejected on intent creation. Set `preauth: true` for authorization, then capture separately with a new durable operation key. Omitting the capture/refund `amount:` keyword asks the gateway to use the remaining amount; explicit nil, numeric, zero, false and noncanonical string amounts are rejected.
95
95
 
96
96
  ```ruby
97
- lunixi.payments.refund('intent_123', amount: 2_500,
97
+ lunixi.payments.refund('intent_123', amount: "2500",
98
98
  reason: 'Customer request', idempotency_key: refund.persisted_operation_key)
99
99
 
100
100
  # Advanced transport: a documented existing endpoint, not a second HTTP stack.
101
101
  links = lunixi.request('GET', '/api/v1/payments/links', query: { limit: 20 })
102
102
  ```
103
103
 
104
- Payment, fraud and webhook helpers are explicit. Other products use the signed transport with the published gateway schema; version 0.1.2 does not claim full typed service-client parity with the PHP SDK. JSON is compact UTF-8 with JavaScript integer-index key ordering and compatible numeric formatting, so the signed bytes are exactly the transmitted bytes.
104
+ Payment, fraud and webhook helpers are explicit. Other products use the signed transport with the published gateway schema; version 1.0.1 does not claim full typed service-client parity with the PHP SDK. JSON is compact UTF-8 with JavaScript integer-index key ordering and compatible numeric formatting, so the signed bytes are exactly the transmitted bytes.
105
105
 
106
106
  ## Raw-body webhooks and durable deduplication
107
107
 
@@ -154,8 +154,10 @@ The Rails example defaults to `LUNIXI_CHECKOUT_MODE=embedded`. It renders the ex
154
154
 
155
155
  Set `LUNIXI_CHECKOUT_MODE=hosted` explicitly for optional redirect. `Lunixi::Checkout.payment_page_url(data.fetch('paymentPageUrl'))` and the Rails redirect wrapper validate the exact backend URL: HTTPS `pay.lunixi.com/payment/` followed by a 16–4096-character ASCII opaque value, with no userinfo, port, query, fragment or controls. It can differ from the embedded token. Do not construct a URL from that token. Both modes expose the same merchant-enabled form methods, multi-card, installments and insurance; these features do not require redirecting. The existing fixed-total single-intent settlement limitation still applies.
156
156
 
157
- Run `rails db:migrate` before deploying 0.1.2. New request/mode/callback and start time are persisted before network I/O; request/token/backend URL use Active Record Encryption. Retries reuse the saved body and key and stop financial dispatch after 23 hours. Existing rows are marked legacy hosted; unknown legacy requests or missing backend URLs remain reconciliation-only, without rewriting origin, resetting a key or inventing a URL. Global mode changes affect new reservations; existing requests retain their saved presentation. CSRF/demo authorization, raw webhooks, native receipt transactions and the bounded 3DS/split return adapter remain unchanged.
157
+ Run `rails db:migrate` before deploying 1.0.1. New request/mode/callback and start time are persisted before network I/O; request/token/backend URL use Active Record Encryption. Retries reuse the saved body and key and stop financial dispatch after 23 hours. Existing rows are marked legacy hosted; unknown legacy requests or missing backend URLs remain reconciliation-only, without rewriting origin, resetting a key or inventing a URL. Global mode changes affect new reservations; existing requests retain their saved presentation. CSRF/demo authorization, raw webhooks, native receipt transactions and the bounded 3DS/split return adapter remain unchanged.
158
158
 
159
- Focused 0.1.2 verification: 24 Rails form/return/URL/CSP security tests with 225 assertions, 2 SDK helper tests with 5 assertions, RuboCop and Brakeman (one exact documented strict-URL redirect false positive). Prior full 0.1.1 results remain the baseline; live/merchant acceptance is deferred.
159
+ Historical focused 0.1.2 verification: 24 Rails form/return/URL/CSP security tests with 225 assertions, 2 SDK helper tests with 5 assertions, RuboCop and Brakeman (one exact documented strict-URL redirect false positive). Prior full 0.1.1 results remain the baseline; live/merchant acceptance is deferred.
160
160
 
161
161
  The checkout page’s CSP permits form POSTs only to `'self'` and the configured gateway origin (default `https://api-gateway.lunixi.com`). This preserves wallet/provider and legacy POST handoffs supported by the browser SDK. For another reviewed provider, set server-only `LUNIXI_FORM_ACTION_ORIGINS` to a comma-separated list of exact HTTPS origins, without spaces, paths, trailing slashes, credentials, query, fragment or wildcards. Invalid configuration fails closed; gateway responses and browser fields never expand this list. Script nonces, frame/connect restrictions and informational return handling remain in force.
162
+
163
+ Before upgrading from 0.x, follow [the money-string migration](MIGRATION-1.0.md).
data/README.tr.md CHANGED
@@ -4,20 +4,27 @@
4
4
 
5
5
  [English](README.md) · [Türkçe](README.tr.md) · [Dokümantasyon](https://docs.lunixi.com/tr/sdks/ruby) · [Rails örneği](samples/rails)
6
6
 
7
- Lunixi ödemeleri, fraud işlemleri ve imzalı gateway çağrıları için sunucu tarafı Ruby SDK’sı. Ed25519 imzasını OpenSSL ile üretir; token’ı eşzamanlı istekler arasında önbelleğe alır, tutarları tam sayı alt birimde korur ve güvenli tekrar denemelerde aynı idempotency anahtarını kullanır. v2 webhook doğrulaması özgün istek baytları üzerinden yapılır.
7
+ Lunixi ödemeleri, fraud işlemleri ve imzalı gateway çağrıları için sunucu tarafı Ruby SDK’sı. Ed25519 imzasını OpenSSL ile üretir; token’ı eşzamanlı istekler arasında önbelleğe alır, tutarları kanonik alt birim string olarak korur ve güvenli tekrar denemelerde aynı idempotency anahtarını kullanır. v2 webhook doğrulaması özgün istek baytları üzerinden yapılır.
8
8
 
9
9
  ## Gereksinimler ve kurulum
10
10
 
11
11
  Ruby **3.3+**, **Ed25519** destekli OpenSSL ve Bundler gerekir. Çalıştırılabilir örnek **Rails 8.1** ve SQLite kullanır; ana gem Rails’e bağımlı değildir.
12
12
 
13
- **0.1.2** sürümü burada kaynak kod olarak dağıtılır. RubyGems yayını ayrı bir sürüm adımıdır; aşağıdaki kurulum kayıt deposunda yayımlanmış paket olduğunu varsaymaz.
13
+ 1.0.1 [RubyGems](https://rubygems.org/gems/lunixi-sdk) üzerinde kullanılabilir olduğunda `Gemfile` dosyanıza tam sürümü ekleyip `bundle install` çalıştırın. [Para sözleşmesi geçişini](MIGRATION-1.0.md) uygulayın:
14
+
15
+ ```ruby
16
+ source 'https://rubygems.org'
17
+ gem 'lunixi-sdk', '1.0.1'
18
+ ```
19
+
20
+ İncelenen bağımlılık çözümünü korumak için `Gemfile.lock` dosyasını saklayın. Kaynak koddan kurulum alternatifi:
14
21
 
15
22
  ```bash
16
- git clone https://github.com/Lunixi-Payment/lunixi-ruby.git
23
+ git clone --branch v1.0.1 --depth 1 https://github.com/Lunixi-Payment/lunixi-ruby.git
17
24
  cd lunixi-ruby
18
25
  bundle install
19
26
  gem build lunixi-sdk.gemspec
20
- gem install ./lunixi-sdk-0.1.2.gem
27
+ gem install ./lunixi-sdk-1.0.1.gem
21
28
  ```
22
29
 
23
30
  Bu dizinde `bundle exec ruby …` yerel SDK’yı yükler. Uygulamanızın Gemfile’ında incelenmiş bir kopyayı `gem 'lunixi-sdk', path: '/tam/yol/lunixi-ruby'` ile kullanabilirsiniz. Bundler Git kaynağı kullanırken denetlenmiş commit’i sabitleyin.
@@ -49,16 +56,16 @@ Toplamı sunucuda hesaplayın. Çağrıdan **önce** ödeme denemesine bir UUID
49
56
 
50
57
  ```ruby
51
58
  result = lunixi.payments.create_intent(
52
- { amount: 10_050, currency: 'TRY', orderId: order.public_id,
59
+ { amount: "10050", currency: 'TRY', orderId: order.public_id,
53
60
  callbackUrl: 'https://merchant.example/payments/return',
54
- basketItems: [{ id: 'urun-1', name: 'Product', itemType: 'PHYSICAL', price: 10_050 }] },
61
+ basketItems: [{ id: 'urun-1', name: 'Product', itemType: 'PHYSICAL', price: "10050" }] },
55
62
  idempotency_key: order.persisted_payment_attempt_key
56
63
  )
57
64
  intent_id = result.fetch('data').fetch('paymentId')
58
65
  checkout_token = result.fetch('data').fetch('token')
59
66
  ```
60
67
 
61
- `10_050`, 100,50 TRY’nin kuruş karşılığıdır. Tutarlar pozitif ve JavaScript güvenli tam sayı aralığında olmalıdır; float/string kabul edilmez. Eski kök `price`/`paidPrice` alanları null olsa bile reddedilir. Sepet toplamı ve split bacak toplamı ana tutarla aynı olmalıdır. Intent’te `installment` gönderilmez; taksiti ödeme formu seçtirir. `preauth: true` ile provizyon alınabilir; capture ayrı işlem ve anahtarla yapılır.
68
+ `"10050"`, 100,50 TRY’nin kuruş karşılığıdır. Tutarlar pozitif, kanonik alt birim string olmalı ve işaretli 64 bit fiat aralığında kalmalıdır; sayısal değerler kabul edilmez. Eski kök `price`/`paidPrice` alanları null olsa bile reddedilir. Sepet toplamı ve split bacak toplamı ana tutarla aynı olmalıdır. Intent’te `installment` gönderilmez; taksiti ödeme formu seçtirir. `preauth: true` ile provizyon alınabilir; capture ayrı işlem ve anahtarla yapılır.
62
69
 
63
70
  Varsayılan form mağaza içinde açılır; isteğe bağlı yönlendirme yalnız doğrulanmış backend `paymentPageUrl` adresini kullanır. Token ve adres şifrelenir; iki mod aynı Lunixi form ayarlarını paylaşır. PAN/CVV toplamayın.
64
71
 
@@ -71,15 +78,15 @@ HTTP 2xx, token, dönüş sayfası veya devam eden split durumu tahsilat kanıt
71
78
  | Intent oluşturma | `payments.create_intent(body, idempotency_key:)` |
72
79
  | Ödeme okuma/listeleme | `payments.get(id)`, `payments.list(**filters)` |
73
80
  | Açık yöntemler | `payments.methods(payment_method: nil)` |
74
- | Capture | `payments.capture(id, idempotency_key:, amount: nil)` |
75
- | Tam/kısmi iade | `payments.refund(id, idempotency_key:, amount: nil, reason: nil)` |
81
+ | Capture | `payments.capture(id, idempotency_key:)` |
82
+ | Tam/kısmi iade | `payments.refund(id, idempotency_key:, reason: nil)` |
76
83
  | Provizyon iptali | `payments.void(id, idempotency_key:)` |
77
84
  | Çoklu ödeme | `payments.split(body, idempotency_key:)` |
78
85
  | Split durum/iade | `payments.get_split(id)`, `payments.refund_split(id, amount:, idempotency_key:, reason: nil)` |
79
86
  | Fraud | `fraud.evaluate(body)`, `fraud.record_event(body, idempotency_key:)` |
80
87
  | Diğer dokümante uçlar | `request(method, path, body: nil, query: {}, idempotency_key: nil, signed: true, binary: false)` |
81
88
 
82
- Capture/iade için `amount: nil`, gateway’in kalan tutarı kullanmasını ister. `0`, `false`, float ve string tutar reddedilir. Her capture/iade/iptal için ayrı ve kalıcı işlem anahtarı gerekir. Yanıt zarfı korunur. Payment, fraud ve webhook yardımcıları açıktır; diğer servisler dokümante şema ile imzalı transport üzerinden çağrılır. Bu sürüm PHP SDK’sındaki tüm servisler için ayrı tipli istemci sunduğunu iddia etmez.
89
+ Capture/iade için `amount:` anahtarını göndermemek, gateway’in kalan tutarı kullanmasını ister; açık `amount: nil` reddedilir. `"0"`, `false`, sayısal değerler ve kanonik olmayan string tutar reddedilir. Her capture/iade/iptal için ayrı ve kalıcı işlem anahtarı gerekir. Yanıt zarfı korunur. Payment, fraud ve webhook yardımcıları açıktır; diğer servisler dokümante şema ile imzalı transport üzerinden çağrılır. Bu sürüm PHP SDK’sındaki tüm servisler için ayrı tipli istemci sunduğunu iddia etmez.
83
90
 
84
91
  ## Webhook güvenliği
85
92
 
@@ -127,8 +134,8 @@ Rails örneğinde varsayılan `LUNIXI_CHECKOUT_MODE=embedded` değeridir. Mevcut
127
134
 
128
135
  İsteğe bağlı yönlendirme için açıkça `LUNIXI_CHECKOUT_MODE=hosted` seçin. `Lunixi::Checkout.payment_page_url(data.fetch('paymentPageUrl'))` backend adresini birebir doğrular: HTTPS `pay.lunixi.com/payment/` ve 16–4096 karakter ASCII opak değer; userinfo, port, sorgu, fragment veya kontrol karakteri kabul edilmez. Bu değer gömülü token'dan farklı olabilir; token'dan URL üretilmez. İki mod aynı açık yöntemleri, çoklu kartı, taksiti ve sigortayı sunar; yönlendirme şart değildir. Sabit toplamlı tek-intent tahsilat sınırlaması korunur.
129
136
 
130
- 0.1.2'yi açmadan önce `rails db:migrate` çalıştırın. Yeni istek/mod/dönüş adresi/zaman ağdan önce kaydedilir; gövde, token ve backend adresi Active Record Encryption ile şifrelenir. Tekrar aynı gövde/anahtarla gider; 23 saatten eski finansal gönderim durur. Mevcut satırlar eski hosted akışı olarak işaretlenir; bilinmeyen eski istek veya eksik backend adresi origin/anahtar değiştirilmeden ve adres üretilmeden mutabakat bekler. Genel mod değişikliği yeni rezervasyonlara uygulanır; mevcut istek kayıtlı modunu korur. CSRF/demo yetkisi, ham webhook, atomik receipt ve sınırlı 3DS/çoklu kart dönüşü korunur.
137
+ Rails örneğini yükseltmeden önce `rails db:migrate` çalıştırın. Yeni istek/mod/dönüş adresi/zaman ağdan önce kaydedilir; gövde, token ve backend adresi Active Record Encryption ile şifrelenir. Tekrar aynı gövde/anahtarla gider; 23 saatten eski finansal gönderim durur. Mevcut satırlar eski hosted akışı olarak işaretlenir; bilinmeyen eski istek veya eksik backend adresi origin/anahtar değiştirilmeden ve adres üretilmeden mutabakat bekler. Genel mod değişikliği yeni rezervasyonlara uygulanır; mevcut istek kayıtlı modunu korur. CSRF/demo yetkisi, ham webhook, atomik receipt ve sınırlı 3DS/çoklu kart dönüşü korunur.
131
138
 
132
- 0.1.2 odaklı kontrolü: 24 Rails form/dönüş/URL/CSP güvenlik testi ve 225 assertion, 2 SDK yardımcı testi ve 5 assertion, RuboCop ve Brakeman (belgeli tek strict-URL yönlendirme false positive'i). Önceki tam 0.1.1 sonuçları temel kanıttır; canlı/merchant kabulü ertelenmiştir.
139
+ Geçmiş 0.1.2 odaklı kontrolü: 24 Rails form/dönüş/URL/CSP güvenlik testi ve 225 assertion, 2 SDK yardımcı testi ve 5 assertion, RuboCop ve Brakeman (belgeli tek strict-URL yönlendirme false positive'i). Önceki tam 0.1.1 sonuçları temel kanıttır; canlı/merchant kabulü ertelenmiştir.
133
140
 
134
141
  Checkout sayfasının CSP’si form POST’u için yalnız `'self'` ve yapılandırılan gateway origin’ine (varsayılan `https://api-gateway.lunixi.com`) izin verir. Böylece tarayıcı SDK’sının desteklediği cüzdan/sağlayıcı ve eski POST geçişleri korunur. İncelenmiş başka bir sağlayıcı gerekiyorsa yalnız sunucuda `LUNIXI_FORM_ACTION_ORIGINS` tanımlayın: virgülle ayrılmış tam HTTPS origin’leri; boşluk, yol, sondaki slash, kullanıcı bilgisi, sorgu, fragment ve wildcard kabul edilmez. Geçersiz ayar reddedilir; gateway yanıtı veya tarayıcı alanı listeyi genişletemez. Script nonce, frame/connect sınırları ve bilgilendirici dönüş güvenliği korunur.
data/docs/PUBLISHING.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # RubyGems publication
2
2
 
3
- `publish-rubygems.yml` is a manual, main-branch-only workflow for a tested version tag. It checks the exact tag's successful CI, gem identity/version and package contents before requesting a short-lived registry credential. No permanent API key is stored in the repository.
3
+ `publish-rubygems.yml` is a manual workflow dispatched from the exact release tag. For 1.0.1, select `v1.0.1` and enter `1.0.1`. It resolves the tag to the invocation SHA, requires successful public push CI for that same commit, then checks out the immutable source. Gem identity/version and SHA-256 are verified before publishing. No permanent API key is stored in the repository.
4
4
 
5
- For the first `lunixi-sdk` release, sign in with the intended confirmed RubyGems account and configure its pending trusted publisher. The account must satisfy the registry MFA requirement and becomes the gem's initial owner.
5
+ Use the existing account-owned trusted publisher for `lunixi-sdk`. Its authorized scope is listed below; registry publication does not require creating a second publisher.
6
6
 
7
7
  | Setting | Value |
8
8
  | --- | --- |
@@ -12,9 +12,23 @@ For the first `lunixi-sdk` release, sign in with the intended confirmed RubyGems
12
12
  | Environment | `release` |
13
13
  | Reusable workflow fields | Blank |
14
14
 
15
- The account owner must review and authorize this package-specific publishing delegation. The workflow alone does not create the publisher or publish a gem. After authorization, run it from `main` with version `0.1.1`; it refuses a version that already exists. Verify the resulting version and downloaded gem digest from RubyGems.
15
+ The approved initial account is asserted by immutable RubyGems **owner ID `255358`**. The exchange sends `owner` as the JSON string `"255358"`; RubyGems rejects a JSON number and resolves all-digit strings by immutable ID rather than by a reusable handle.
16
+
17
+ The released `configure-rubygems-credentials` **v2.1.0** does not expose an owner input: its [pinned action definition](https://github.com/rubygems/configure-rubygems-credentials/blob/dc5a8d8553e6ee01fc26761a49e99e733d17954a/action.yml) and [exchange implementation](https://github.com/rubygems/configure-rubygems-credentials/blob/dc5a8d8553e6ee01fc26761a49e99e733d17954a/src/oidc/trustedPublisher.ts) send only the JWT. Until an owner-capable release is reviewed, the workflow embeds a Python-standard-library exchange before its push step. It requests a GitHub ID token for audience `rubygems.org`, then POSTs only `{jwt, owner}` to the fixed HTTPS endpoint `https://rubygems.org/api/v1/oidc/trusted_publisher/exchange_token`.
18
+
19
+ The contract was checked against the [RubyGems controller at commit `5a641a1e1ac8bd37ba0b11d56b5d7ee379107f85`](https://github.com/rubygems/rubygems.org/blob/5a641a1e1ac8bd37ba0b11d56b5d7ee379107f85/app/controllers/api/v1/oidc/trusted_publisher_controller.rb) and its [owner-binding regression tests](https://github.com/rubygems/rubygems.org/blob/5a641a1e1ac8bd37ba0b11d56b5d7ee379107f85/test/integration/api/v1/oidc/trusted_publisher_controller_test.rb). The server verifies the signed OIDC claims, atomically binds the asserted owner, rejects replay of the same token, and issues a **push-only, 15-minute** credential with HTTP **201**. The workflow rejects redirects, other HTTP statuses, malformed/oversized responses, additional scopes and expiry beyond 16 minutes. It masks request/JWT/API credentials before use, exports only `GEM_HOST_API_KEY` through a mode-600 runner environment file, and never logs HTTP bodies or underlying exception details. It creates no permanent API key and does not alter the publisher's permissions.
20
+
21
+ The account owner must review and authorize this package-specific publishing delegation. The workflow alone does not create the publisher or publish a gem. For an authorized release, run it from the matching `v1.0.1` tag with version `1.0.1`; it refuses a version that already exists. Verify the resulting version and downloaded gem digest from RubyGems.
16
22
 
17
23
  The build does not repeat the functional suite. Ephemeral runner credentials are removed after the upload.
18
24
 
25
+ ## Verified 0.1.2 publication
26
+
27
+ The account owner authorized the exact publisher above. [Publish run 41249047391](https://github.com/Lunixi-Payment/lunixi-ruby/actions/runs/41249047391) completed successfully from workflow commit `7a4a0e7b6f647490f7f09b650847c38f0b7d3b19`, while the package checkout remained pinned to `579cdf8afeaad0cdba2764c312005bc60480227b`. The original `v0.1.2` tag is unchanged.
28
+
29
+ [RubyGems 0.1.2](https://rubygems.org/gems/lunixi-sdk/versions/0.1.2) is available with SHA-256 `c9c6386c03a3b853d56335c2f21e91f18d3450a83906d5ad15d9fc07ac1ce16f`. That historical release remains immutable. The current workflow also refuses an already published version.
30
+
31
+ The downloaded 39,424-byte archive matches the registry checksum. All 23 packaged files match both the prepared gem and the immutable release source; build metadata can differ between independently built archives.
32
+
19
33
  - [Trusted Publishing and pending publishers](https://guides.rubygems.org/trusted-publishing/)
20
34
  - [RubyGems publishing](https://guides.rubygems.org/publishing/)
data/docs/ruby.en.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Ruby SDK
3
- description: Lunixi Ruby SDK — source installation, Ed25519 signing, payments, fraud, durable idempotency and raw-body v2 webhooks.
3
+ description: Lunixi Ruby SDK — installation, Ed25519 signing, payments, fraud, durable idempotency and raw-body v2 webhooks.
4
4
  ---
5
5
 
6
6
 
@@ -8,20 +8,19 @@ description: Lunixi Ruby SDK — source installation, Ed25519 signing, payments,
8
8
 
9
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
10
 
11
- ## Requirements and source installation
11
+ ## Requirements and installation
12
12
 
13
13
  Ruby **3.3+**, Bundler and an OpenSSL build supporting **Ed25519** are required. The Rails example uses Rails 8.1.
14
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.
15
+ After 1.0.1 is available on RubyGems, add `gem 'lunixi-sdk', '1.0.1'` to your Gemfile and run `bundle install`. Follow the [money-string migration](../MIGRATION-1.0.md). The following source build is an optional development workflow:
17
16
 
18
17
 
19
18
  ```bash
20
- git clone https://github.com/Lunixi-Payment/lunixi-ruby.git
19
+ git clone --branch v1.0.1 --depth 1 https://github.com/Lunixi-Payment/lunixi-ruby.git
21
20
  cd lunixi-ruby
22
21
  bundle install
23
22
  gem build lunixi-sdk.gemspec
24
- gem install ./lunixi-sdk-0.1.1.gem
23
+ gem install ./lunixi-sdk-1.0.1.gem
25
24
  ```
26
25
 
27
26
  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.
@@ -59,11 +58,11 @@ Persist the payment attempt and its UUID idempotency key before the first reques
59
58
  ```ruby
60
59
  result = lunixi.payments.create_intent(
61
60
  {
62
- amount: 10_050,
61
+ amount: "10050",
63
62
  currency: 'TRY',
64
63
  orderId: order.public_id,
65
64
  callbackUrl: 'https://merchant.example/payments/return',
66
- basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: 10_050 }]
65
+ basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: "10050" }]
67
66
  },
68
67
  idempotency_key: order.persisted_payment_attempt_key
69
68
  )
@@ -78,7 +77,7 @@ checkout_token = result.fetch('data').fetch('token')
78
77
  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
78
 
80
79
 
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.
80
+ Amounts must be positive canonical minor-unit strings within the signed 64-bit fiat 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
81
 
83
82
  ## Available helpers
84
83
 
@@ -87,15 +86,15 @@ Amounts must be positive integers within JavaScript's safe integer range. Root `
87
86
  | Create an intent | `lunixi.payments.create_intent(body, idempotency_key:)` |
88
87
  | Read/list payments | `lunixi.payments.get(id)`, `lunixi.payments.list(**filters)` |
89
88
  | 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)` |
89
+ | Capture | `lunixi.payments.capture(id, idempotency_key:)` |
90
+ | Full/partial refund | `lunixi.payments.refund(id, idempotency_key:, reason: nil)` |
92
91
  | Void | `lunixi.payments.void(id, idempotency_key:)` |
93
92
  | Split payment | `lunixi.payments.split(body, idempotency_key:)` |
94
93
  | Split status/refund | `lunixi.payments.get_split(id)`, `lunixi.payments.refund_split(id, amount:, idempotency_key:, reason: nil)` |
95
94
  | Fraud | `lunixi.fraud.evaluate(body)`, `lunixi.fraud.record_event(body, idempotency_key:)` |
96
95
  | Advanced signed HTTP | `lunixi.request(method, path, body: nil, query: {}, idempotency_key: nil, signed: true, binary: false)` |
97
96
 
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.
97
+ Omit the capture/refund `amount:` keyword to use the remaining amount. Explicit nil, false, zero, numeric and noncanonical string 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
98
 
100
99
  The advanced transport supports other **documented** gateway routes without a second HTTP implementation:
101
100
 
data/docs/ruby.tr.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
3
+ description: Lunixi Ruby SDK — kurulum, Ed25519 imza, ödeme ve fraud, kalıcı idempotency ve özgün baytlarla v2 webhook doğrulama.
4
4
  ---
5
5
 
6
6
 
@@ -8,20 +8,19 @@ description: Lunixi Ruby SDK — kaynak kurulum, Ed25519 imza, ödeme ve fraud,
8
8
 
9
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
10
 
11
- ## Gereksinimler ve kaynak kurulum
11
+ ## Gereksinimler ve kurulum
12
12
 
13
13
  Ruby **3.3+**, Bundler ve **Ed25519** destekli OpenSSL gerekir. Rails örneği Rails 8.1 kullanır.
14
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.
15
+ 1.0.1 RubyGems üzerinde kullanılabilir olduğunda Gemfile’a `gem 'lunixi-sdk', '1.0.1'` ekleyip `bundle install` çalıştırın. [Para sözleşmesi geçişini](../MIGRATION-1.0.md) uygulayın. Aşağıdaki kaynak derlemesi geliştirme için isteğe bağlıdır:
17
16
 
18
17
 
19
18
  ```bash
20
- git clone https://github.com/Lunixi-Payment/lunixi-ruby.git
19
+ git clone --branch v1.0.1 --depth 1 https://github.com/Lunixi-Payment/lunixi-ruby.git
21
20
  cd lunixi-ruby
22
21
  bundle install
23
22
  gem build lunixi-sdk.gemspec
24
- gem install ./lunixi-sdk-0.1.1.gem
23
+ gem install ./lunixi-sdk-1.0.1.gem
25
24
  ```
26
25
 
27
26
  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.
@@ -59,11 +58,11 @@ Public anahtarı Lunixi paneline kaydedip verilen key id’yi kullanın. Özel a
59
58
  ```ruby
60
59
  result = lunixi.payments.create_intent(
61
60
  {
62
- amount: 10_050,
61
+ amount: "10050",
63
62
  currency: 'TRY',
64
63
  orderId: order.public_id,
65
64
  callbackUrl: 'https://merchant.example/payments/return',
66
- basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: 10_050 }]
65
+ basketItems: [{ id: 'item-1', name: 'Product', itemType: 'PHYSICAL', price: "10050" }]
67
66
  },
68
67
  idempotency_key: order.persisted_payment_attempt_key
69
68
  )
@@ -78,7 +77,7 @@ checkout_token = result.fetch('data').fetch('token')
78
77
  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
78
 
80
79
 
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.
80
+ Tutarlar pozitif, kanonik alt birim string olmalı ve işaretli 64 bit fiat aralığında kalmalı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
81
 
83
82
  ## Yardımcı API’ler
84
83
 
@@ -87,15 +86,15 @@ Tutarlar pozitif ve JavaScript güvenli tam sayı aralığında olmalıdır. Kö
87
86
  | Intent | `lunixi.payments.create_intent(body, idempotency_key:)` |
88
87
  | Ödeme okuma/liste | `lunixi.payments.get(id)`, `lunixi.payments.list(**filters)` |
89
88
  | 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)` |
89
+ | Capture | `lunixi.payments.capture(id, idempotency_key:)` |
90
+ | Tam/kısmi iade | `lunixi.payments.refund(id, idempotency_key:, reason: nil)` |
92
91
  | Provizyon iptali | `lunixi.payments.void(id, idempotency_key:)` |
93
92
  | Split | `lunixi.payments.split(body, idempotency_key:)` |
94
93
  | Split durum/iade | `lunixi.payments.get_split(id)`, `lunixi.payments.refund_split(id, amount:, idempotency_key:, reason: nil)` |
95
94
  | Fraud | `lunixi.fraud.evaluate(body)`, `lunixi.fraud.record_event(body, idempotency_key:)` |
96
95
  | İmzalı genel HTTP | `lunixi.request(method, path, body: nil, query: {}, idempotency_key: nil, signed: true, binary: false)` |
97
96
 
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.
97
+ Capture/iade `amount:` anahtarı gönderilmezse kalan tutar kullanılır; açık `amount: nil` reddedilir. False, sıfır, sayısal değerler ve kanonik olmayan string 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
98
 
100
99
  Diğer **dokümante** gateway uçları aynı imzalı transport üzerinden çağrılabilir:
101
100
 
data/lib/lunixi/client.rb CHANGED
@@ -39,6 +39,11 @@ module Lunixi
39
39
 
40
40
  path = safe_path(path, query)
41
41
  key = self.class.idempotency_key!(idempotency_key) if idempotency_key
42
+ Money.validate_operation_amount(path, body)
43
+ if Money.fiat_route?(path)
44
+ Money.validate_fields(body)
45
+ Money.validate_fields(query)
46
+ end
42
47
  bytes = body.nil? ? nil : JsonBody.dump(body)
43
48
  raise ConfigurationError, 'Request body exceeds the size limit' if bytes && bytes.bytesize > 1024 * 1024
44
49
 
@@ -77,7 +82,7 @@ module Lunixi
77
82
  retries += 1
78
83
  next
79
84
  end
80
- return decode_response(response, binary: binary, running_split: method == 'POST' && path == '/api/v1/payments/split')
85
+ return decode_response(response, binary: binary, fiat: Money.fiat_route?(path), running_split: method == 'POST' && path == '/api/v1/payments/split')
81
86
  rescue TransportError
82
87
  raise unless safe_to_retry && retries < @max_retries
83
88
 
@@ -187,15 +192,20 @@ module Lunixi
187
192
  @sleeper.call(seconds)
188
193
  end
189
194
 
190
- def decode_response(response, binary: false, running_split: false)
195
+ def response_payload(response)
196
+ payload = response.body.empty? ? {} : JSON.parse(response.body, max_nesting: 64)
197
+ raise ApiError.new(status: response.status, code: 'invalid_json_response') unless payload.is_a?(Hash)
198
+
199
+ payload
200
+ rescue JSON::ParserError
201
+ raise ApiError.new(status: response.status, code: 'invalid_json_response'), cause: nil
202
+ end
203
+
204
+ def decode_response(response, binary: false, running_split: false, fiat: false)
191
205
  return response.body.b.freeze if binary && response.status.between?(200, 299)
192
206
 
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)
207
+ payload = response_payload(response)
208
+ Money.validate_fields(payload, response: true) if fiat
199
209
  return payload if response.status.between?(200, 299)
200
210
 
201
211
  error_class = case response.status
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lunixi
4
+ # Canonical public fiat money. Arithmetic uses Ruby's exact Integer only after validation.
5
+ module Money
6
+ MAX = 9_223_372_036_854_775_807
7
+ EXPONENTS = %w[TRY USD EUR GBP RUB AED SAR QAR CHF CAD AUD CNY AZN].to_h { |c| [c, 2] }.merge('JPY' => 0, 'KWD' => 3).freeze
8
+ module_function
9
+
10
+ def fiat_route?(path)
11
+ path.match?(%r{\A/api/v1/(?:payments|wallet|insurance|bank-connect|fraud)(?:[/?]|\z)})
12
+ end
13
+
14
+ def validate_operation_amount(path, body)
15
+ return unless path.match?(%r{\A/api/v1/payments/(?:split/)?[^/]+/(?:capture|refund)(?:[?]|\z)}) && body.is_a?(Hash)
16
+
17
+ body.each { |key, value| minor(value) if key.to_s == 'amount' }
18
+ end
19
+
20
+ def minor(value, positive: true, signed: false)
21
+ pattern = if signed
22
+ /\A(?:0|-?[1-9][0-9]*)\z/
23
+ elsif positive
24
+ /\A[1-9][0-9]*\z/
25
+ else
26
+ /\A(?:0|[1-9][0-9]*)\z/
27
+ end
28
+ unless value.is_a?(String) && value.match?(pattern) && value.to_i.between?(-MAX - 1, MAX)
29
+ raise ConfigurationError, 'Canonical minor-unit string in the signed 64-bit fiat range required'
30
+ end
31
+
32
+ value
33
+ end
34
+
35
+ def decimal_to_minor(value, currency)
36
+ exponent = EXPONENTS.fetch(currency) { raise ConfigurationError, 'Unsupported currency' }
37
+ raise ConfigurationError, 'Exact decimal text required' unless value.is_a?(String) && value.match?(/\A(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/)
38
+
39
+ whole, fraction = value.split('.', 2)
40
+ fraction ||= ''
41
+ raise ConfigurationError, 'Fractional minor units' if fraction[exponent..]&.match?(/[1-9]/)
42
+
43
+ minor((whole + fraction[0, exponent].ljust(exponent, '0')).to_i.to_s, positive: false)
44
+ end
45
+
46
+ def validate_fields(value, response: false)
47
+ case value
48
+ when Array
49
+ value.each { |entry| validate_fields(entry, response: response) }
50
+ when Hash
51
+ value.each do |name, entry|
52
+ key = name.to_s
53
+ next if entry.nil? || %w[metadata fields inputFields insuranceData].include?(key)
54
+
55
+ if %w[
56
+ amount amountMinor authorizedAmount availableBalance balanceMinor buyerTotalCharge cancelledPremiumAmount
57
+ capturedAmount capturedAmountMinor chargeAmount collectedAmountMinor collectedPremiumAmount compensatedAmount
58
+ disputedAmount feeTotal fixedFee inForcePremiumAmount insuranceValueAmount legAmount legsAmount lineTotalMinor
59
+ maxAmount maxAmountMinor minAmount minAmountMinor nonRefundableAmount paidAmount paidPrice pendingBalance
60
+ premiumAmount price refundAmount refundOpenPremiumAmount refundableAmount refundedAmount refundedAmountMinor
61
+ refundedPremiumAmount requestedAmount reservedBalance sourceAmountMinor targetAmountMinor taxAmountMinor taxTotal
62
+ totalAmount unitPriceMinor
63
+ ].include?(key)
64
+ minor(entry, positive: !response && %w[amount price totalAmount].include?(key), signed: key.downcase.include?('balance'))
65
+ elsif %w[
66
+ data details items rows results payments operations balances wallets legs aggregate refund refunds totals
67
+ charge fees fee balance splits sellers basketItems promissoryNote schedule notes lines offers policies
68
+ reconciliation quotes payment transactions accounts settlements payouts limits
69
+ ].include?(key)
70
+ validate_fields(entry, response: response)
71
+ end
72
+ end
73
+ end
74
+ value
75
+ end
76
+ end
77
+ end
@@ -5,6 +5,8 @@ module Lunixi
5
5
  # documented endpoints without pretending to validate their entire DTO schema.
6
6
  class Payments
7
7
  BASE = '/api/v1/payments'
8
+ OMITTED_AMOUNT = Object.new.freeze
9
+ private_constant :OMITTED_AMOUNT
8
10
  attr_reader :client
9
11
 
10
12
  def initialize(client)
@@ -36,17 +38,17 @@ module Lunixi
36
38
  client.request('GET', "#{BASE}/methods", query: query)
37
39
  end
38
40
 
39
- # Capture an authorization; nil amount requests the gateway's remaining amount.
40
- def capture(payment_id, idempotency_key:, amount: nil)
41
+ # Omit amount to capture the remaining authorization; explicit nil is invalid.
42
+ def capture(payment_id, idempotency_key:, amount: OMITTED_AMOUNT)
41
43
  body = { 'paymentIntentId' => identifier!(payment_id) }
42
- body['amount'] = minor!(amount) unless amount.nil?
44
+ body['amount'] = minor!(amount) unless amount.equal?(OMITTED_AMOUNT)
43
45
  client.request('POST', "#{BASE}/#{payment_id}/capture", body: body, idempotency_key: Client.idempotency_key!(idempotency_key))
44
46
  end
45
47
 
46
48
  # Refund captured funds; persist a separate key for each refund operation.
47
- def refund(payment_id, idempotency_key:, amount: nil, reason: nil)
49
+ def refund(payment_id, idempotency_key:, amount: OMITTED_AMOUNT, reason: nil)
48
50
  body = { 'paymentIntentId' => identifier!(payment_id) }
49
- body['amount'] = minor!(amount) unless amount.nil?
51
+ body['amount'] = minor!(amount) unless amount.equal?(OMITTED_AMOUNT)
50
52
  body['reason'] = reason!(reason) unless reason.nil?
51
53
  client.request('POST', "#{BASE}/#{payment_id}/refund", body: body, idempotency_key: Client.idempotency_key!(idempotency_key))
52
54
  end
@@ -68,9 +70,9 @@ module Lunixi
68
70
  total = legs.sum do |leg|
69
71
  raise ConfigurationError, 'Split legs must be objects' unless leg.is_a?(Hash)
70
72
 
71
- minor!(leg['amount'])
73
+ minor!(leg['amount']).to_i
72
74
  end
73
- raise ConfigurationError, 'Split leg amounts must equal totalAmount' unless total == data['totalAmount']
75
+ raise ConfigurationError, 'Split leg amounts must equal totalAmount' unless total == data['totalAmount'].to_i
74
76
 
75
77
  client.request('POST', "#{BASE}/split", body: data, idempotency_key: Client.idempotency_key!(idempotency_key))
76
78
  end
@@ -97,11 +99,7 @@ module Lunixi
97
99
  end
98
100
 
99
101
  def minor!(value)
100
- unless value.is_a?(Integer) && value.positive? && value <= JsonBody::MAX_SAFE_INTEGER
101
- raise ConfigurationError, 'Amount must be a positive safe integer in minor units'
102
- end
103
-
104
- value
102
+ Money.minor(value)
105
103
  end
106
104
 
107
105
  def currency!(value)
@@ -155,9 +153,9 @@ module Lunixi
155
153
  total = items.sum do |item|
156
154
  raise ConfigurationError, 'Basket items must be objects' unless item.is_a?(Hash)
157
155
 
158
- minor!(item['price'])
156
+ minor!(item['price']).to_i
159
157
  end
160
- raise ConfigurationError, 'Basket total must equal amount' unless total == amount
158
+ raise ConfigurationError, 'Basket total must equal amount' unless total == amount.to_i
161
159
  end
162
160
  end
163
161
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lunixi
4
- VERSION = '0.1.2'
4
+ VERSION = '1.0.1'
5
5
  end
data/lib/lunixi.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 'lunixi/version'
4
4
  require_relative 'lunixi/errors'
5
5
  require_relative 'lunixi/json_body'
6
+ require_relative 'lunixi/money'
6
7
  require_relative 'lunixi/signer'
7
8
  require_relative 'lunixi/transport'
8
9
  require_relative 'lunixi/payments'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lunixi-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lunixi
@@ -101,6 +101,7 @@ files:
101
101
  - ".github/assets/lunixi-logo.svg"
102
102
  - CHANGELOG.md
103
103
  - LICENSE
104
+ - MIGRATION-1.0.md
104
105
  - README.md
105
106
  - README.tr.md
106
107
  - SECURITY.md
@@ -115,6 +116,7 @@ files:
115
116
  - lib/lunixi/errors.rb
116
117
  - lib/lunixi/fraud.rb
117
118
  - lib/lunixi/json_body.rb
119
+ - lib/lunixi/money.rb
118
120
  - lib/lunixi/payments.rb
119
121
  - lib/lunixi/signer.rb
120
122
  - lib/lunixi/transport.rb