nodexpay 0.1.0

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.
@@ -0,0 +1,157 @@
1
+ # HTTP とエラー
2
+
3
+ ## 既定 HTTP transport
4
+
5
+ 既定実装は Ruby 標準ライブラリの `Net::HTTP` を使用し、追加の runtime dependency を持ちません。
6
+
7
+ - HTTPS の証明書検証を有効にする
8
+ - request ごとに接続を作成し、可変な connection を Client 間で共有しない
9
+ - `POST` だけを使用する
10
+ - redirect を追従しない
11
+ - transport / 503 の自動再試行を行わない
12
+ - body は上位層で生成された form-urlencoded String をそのまま送る
13
+
14
+ ## Timeout
15
+
16
+ 既定値:
17
+
18
+ | 設定 | 既定 | 対象 |
19
+ | --- | --- | --- |
20
+ | `open_timeout` | 5秒 | TCP/TLS 接続確立 |
21
+ | `read_timeout` | 30秒 | response 読み取り |
22
+ | `write_timeout` | 30秒 | request body 書き込み |
23
+
24
+ client initializer で正の Numeric を指定できます。0、負数、NaN、Infinity は `ConfigurationError` とします。
25
+
26
+ 公式 FAQ の「API timeout はない」という記載は server 側上限の説明であり、client の無制限待機を意味するものとして扱いません。
27
+
28
+ ## Retry と結果不明状態
29
+
30
+ Payment Request は、server が作成を完了した後に response を受け取れなかった場合があります。この状態で同じ `order_code` を再送すると、2021 duplicate が返り、元の payment token を回収できない可能性があります。
31
+
32
+ そのため次を禁止します。
33
+
34
+ - timeout、接続切断、503 に対する自動 retry
35
+ - 新しい `order_code` を SDK が自動生成して retry
36
+ - duplicate error を成功として扱うこと
37
+
38
+ `TransportError` または `TimeoutError` を受けた利用側は、注文を「結果不明」として保持し、管理画面や別の業務手順で確認します。公式の照会 endpoint がないため、SDK は fallback を提供しません。
39
+
40
+ ## 例外階層
41
+
42
+ ```text
43
+ NodexPay::Error
44
+ ├── ConfigurationError
45
+ ├── ValidationError
46
+ ├── SignatureVerificationError
47
+ ├── TransportError
48
+ │ └── TimeoutError
49
+ ├── APIError
50
+ │ ├── BadRequestError
51
+ │ ├── ServiceUnavailableError
52
+ │ └── UnexpectedHTTPStatusError
53
+ └── InvalidResponseError
54
+ ```
55
+
56
+ ### ローカルエラー
57
+
58
+ | 例外 | 条件 |
59
+ | --- | --- |
60
+ | `ConfigurationError` | credential、environment、timeout、transport 設定が不正 |
61
+ | `ValidationError` | API 呼び出し引数が公開契約に違反 |
62
+ | `SignatureVerificationError` | Payment Result の署名が一致しない |
63
+ | `InvalidResponseError` | 成功 response または Payment Result の JSON/schema が不正 |
64
+
65
+ ### 通信エラー
66
+
67
+ | 例外 | 条件 |
68
+ | --- | --- |
69
+ | `TimeoutError` | open/read/write timeout |
70
+ | `TransportError` | DNS、TLS、connection reset、その他 response 未取得の通信障害 |
71
+
72
+ `TimeoutError` は `TransportError` の subclass です。元例外は `cause` で参照可能にしますが、message に request body や credential を含めません。
73
+
74
+ ### API エラー
75
+
76
+ `APIError` は次の読み取り専用属性を持ちます。
77
+
78
+ ```ruby
79
+ error.operation # :create_payment or :cancel_payment
80
+ error.status # Integer
81
+ error.error_codes # Array[Integer]
82
+ error.response_headers # Hash[String, String]
83
+ error.response_body # String
84
+ ```
85
+
86
+ `message` には operation、HTTP status、既知の error code 名だけを含めます。`identification_token`、`hash_token`、`verify_token`、request body は含めません。`response_body` は自動的に message や `inspect` へ展開せず、利用者にも機密情報として扱うよう文書化します。
87
+
88
+ ## HTTP status mapping
89
+
90
+ | Status | 例外 |
91
+ | --- | --- |
92
+ | 200 | endpoint ごとの成功処理 |
93
+ | 400 | `BadRequestError` |
94
+ | 503 | `ServiceUnavailableError` |
95
+ | その他 | `UnexpectedHTTPStatusError` |
96
+
97
+ 301/302/307/308 も `UnexpectedHTTPStatusError` です。別 host へ credential を転送しないため redirect は追従しません。
98
+
99
+ ## Error body の解析
100
+
101
+ 公式の400例は endpoint 間で形が異なり、一部は JSON として壊れています。SDK は成功扱いを広げる fallback ではなく、診断情報を失わないため次の限定的な抽出を行います。
102
+
103
+ 有効な JSON の場合、次の位置だけから error code を抽出します。
104
+
105
+ ```json
106
+ { "errors": [2021] }
107
+ ```
108
+
109
+ ```json
110
+ { "errors": 2024 }
111
+ ```
112
+
113
+ ```json
114
+ { "data": { "errors": [2021] } }
115
+ ```
116
+
117
+ - scalar または Array の整数と、10進整数だけの String を Integer 化する
118
+ - 上記以外の key を error code として走査しない
119
+ - JSON が壊れている、または code を抽出できない場合は空配列にする
120
+ - unknown code も数値のまま保持する
121
+ - status と元 body は常に `APIError` に保持する
122
+
123
+ 503 の maintenance body は valid JSON schema が規定されていないため、status のみで `ServiceUnavailableError` にします。
124
+
125
+ ## 既知 error code
126
+
127
+ 少なくとも公開 endpoint と直接関係する次の code 名を定義します。
128
+
129
+ | Code | Name |
130
+ | --- | --- |
131
+ | 2010 | `RECEIVED_IDENTIFICATION_TOKEN_EMPTY` |
132
+ | 2011 | `RECEIVED_IDENTIFICATION_TOKEN_INVALID` |
133
+ | 2020 | `RECEIVED_ORDER_CODE_EMPTY` |
134
+ | 2021 | `RECEIVED_ORDER_CODE_DUPLICATE` |
135
+ | 2022 | `RECEIVED_ORDER_CODE_ALREADY_CANCELLED` |
136
+ | 2023 | `PAYMENT_NOT_FOUND` |
137
+ | 2024 | `PAYMENT_NOT_CANCELABLE` |
138
+ | 2030 | `RECEIVED_AMOUNT_INVALID_FORMAT` |
139
+ | 2040 | `RECEIVED_INVALID_CALLBACK_URL` |
140
+ | 2050 | `RECEIVED_COLOR_THEME_INVALID_FORMAT` |
141
+ | 2060 | `RECEIVED_VERIFY_TOKEN_EMPTY` |
142
+ | 2061 | `RECEIVED_VERIFY_TOKEN_INVALID` |
143
+ | 2101 | `RECEIVED_EXT_RESERVED_INVALID` |
144
+ | 2102 | `RECEIVED_EXT_DESCRIPTION_INVALID` |
145
+ | 2370 | `RECEIVE_CONTRACT_UNPUBLISHED` |
146
+ | 2460 | `RECEIVED_INVALID_DEADLINE` |
147
+
148
+ 公式 Payment Request ページには内部 API 用に見える多数の code も列挙されています。SDK は未知 code を破棄しないため、すべてを定数化しなくても診断可能です。
149
+
150
+ ## 秘密情報の取り扱い
151
+
152
+ - Client の `inspect` は credential を表示しない
153
+ - request body と signing string をログ出力しない
154
+ - transport exception に URI path は含めても query/body は含めない
155
+ - test fixture、recording、CI artifact に実 credential を保存しない
156
+ - Hash Token は server-side でのみ使用し、browser/mobile bundle へ渡さない
157
+
@@ -0,0 +1,130 @@
1
+ # 実装計画
2
+
3
+ ## リリース方針
4
+
5
+ 最初の公開版は `0.1.0` とします。Nodex Pay の正式 SDK を名乗らず、README と gem metadata に非公式 SDK であることを記載します。
6
+
7
+ `1.0.0` は次を満たしてから公開します。
8
+
9
+ - Testnet で Payment Request → Payment Cancel の contract を確認済み
10
+ - 実運用相当の endpoint で Payment Result payload と署名を確認済み
11
+ - 公式文書の未確認事項が、解決済みまたは明示された制限として整理済み
12
+ - Ruby 3.4 / 4.0 の CI、RBS、package 検査が安定している
13
+
14
+ 公開ライセンスは MIT とします。公開 API の修正時に旧 keyword、alias、domain fallback を安易に追加しません。1.0 以降の破壊的変更は SemVer の major version で表現します。
15
+
16
+ ## Phase 1: Gem scaffold
17
+
18
+ ### 変更
19
+
20
+ - gem 名 `nodexpay`、require path `nodex_pay`、namespace `NodexPay` で scaffold を作る
21
+ - `required_ruby_version` を `>= 3.4` にする
22
+ - runtime dependency を追加しない
23
+ - Minitest、RBS、Sorbet RBI、CI、package build の最小構成を追加する
24
+ - Sorbet RBI は型エラーなしとmetrics上のsignature/strict-file coverage 100%をCIで必須にする
25
+ - MIT license、非公式 SDK の表示、version `0.1.0` を追加する
26
+
27
+ ### 完了条件
28
+
29
+ - `require "nodex_pay"` が成功する
30
+ - Ruby 3.4 / 4.0 で空の test suite と RBS 検査が成功する
31
+ - build した gem に意図した lib、sig、docs、license だけが含まれる
32
+
33
+ ## Phase 2: Value、validation、署名
34
+
35
+ ### 変更
36
+
37
+ - immutable な設定、`Payment`、`PaymentResult`、transport value を `Data` で定義する
38
+ - `order_code`、enum、description、deadline、timeout の validator を実装する
39
+ - String / Integer / BigDecimal を単一の decimal serializer へ集約する
40
+ - SHA-256 signer と constant-time verifier を pure object/function として実装する
41
+ - [testing.md](testing.md) の固定署名ベクトルを追加する
42
+
43
+ ### 完了条件
44
+
45
+ - 署名と form field が同じ normalized amount を使うことを unit test で証明する
46
+ - Float、指数表記、範囲外を送信前に拒否する
47
+ - 秘密情報が各 object の `inspect` に含まれない
48
+
49
+ ## Phase 3: HTTP transport と例外
50
+
51
+ ### 変更
52
+
53
+ - `Request` / `Response` contract と `Net::HTTP` transport を実装する
54
+ - TLS、open/read/write timeout、header、form body を設定する
55
+ - 例外階層、HTTP status mapping、限定的な error code decoder を実装する
56
+ - redirect と retry を実装しないことを test で固定する
57
+
58
+ ### 完了条件
59
+
60
+ - local server integration test で exact method/header/body を確認する
61
+ - timeout / DNS / TLS / connection failure が契約どおりの例外になる
62
+ - 400 の既知形状、503、unknown status、malformed body で診断情報を保持する
63
+
64
+ ## Phase 4: Payment Request と Cancel
65
+
66
+ ### 変更
67
+
68
+ - 環境から固定 base URL を選択する
69
+ - `create_payment` を実装し、form request と `Payment` decoder を接続する
70
+ - `cancel_payment` を amount なしの署名で実装する
71
+ - 成功 response と不正 response の endpoint-specific test を追加する
72
+
73
+ ### 完了条件
74
+
75
+ - [api-contract.md](api-contract.md) の全 keyword と wire field が一致する
76
+ - `ext_reserved` / `callback_url` を送信できる非公開経路が存在しない
77
+ - Cancel の200だけが `true` を返し、2022を含む400は例外になる
78
+
79
+ ## Phase 5: Payment Result
80
+
81
+ ### 変更
82
+
83
+ - raw JSON parser と field/type validator を実装する
84
+ - 受信した amount を変更せず署名検証する
85
+ - 検証成功後にのみ `PaymentResult` を構築する
86
+ - 署名対象外 field とリプレイ制約を利用ガイドへ記載する
87
+
88
+ ### 完了条件
89
+
90
+ - 正常/不正署名、欠落/型違い、`result: false` の全 test が成功する
91
+ - 未検証 payload がモデルや例外から外部へ漏れない
92
+ - Rails/Rack、DB、ACK response の責務が SDK 内へ混入していない
93
+
94
+ ## Phase 6: RBS/Sorbet RBI、文書、Testnet
95
+
96
+ ### 変更
97
+
98
+ - 公開 API、モデル、transport、例外を RBS へ反映する
99
+ - RBS と同じ公開契約を `rbi/nodex_pay.rbi` へ反映し、gem に同梱する
100
+ - root README にインストール、設定、3 API の最小例、非公式表示を追加する
101
+ - この設計文書と実装の名称・引数・戻り値を照合する
102
+ - 資格情報を必要とする手動 Testnet smoke job を追加する
103
+
104
+ ### 完了条件
105
+
106
+ - Ruby 3.4 / 4.0 の CI が成功する
107
+ - RBS/Sorbet RBI と Ruby の公開 API が一致する
108
+ - Testnet で create → cancel を実行し、Content-Type、Cancel 署名、成功 body を確認する
109
+ - 観測結果が設計と異なる場合は、実装の fallback ではなく設計判断を更新する
110
+
111
+ ## 0.1.0 Definition of Done
112
+
113
+ - `gem install nodexpay` と `require "nodex_pay"` が成立する
114
+ - 3つの公開メソッド以外に推測 API を公開していない
115
+ - outbound request は form-urlencoded、retry/redirect は無効である
116
+ - 全 credential と署名材料がログ、例外 message、test artifact から除外されている
117
+ - API/model/error/transport の RBS と Sorbet RBI が同梱されている
118
+ - docs、tests、実装が同じ契約を参照している
119
+ - MIT license と非公式 SDK 表示が配布物に含まれる
120
+
121
+ ## 1.0 前に Nodex 側へ確認する項目
122
+
123
+ 1. outbound request の正式な Content-Type
124
+ 2. amount の server-side lexical normalization
125
+ 3. Cancel の署名原文と success response
126
+ 4. `ext_reserved` の現行 request field としての可否
127
+ 5. Payment Result の必須 field、failure payload、ACK、retry
128
+ 6. rate limit、API timeout、secret rotation、deprecation policy
129
+
130
+ 回答が得られない項目は、Testnet/実環境で観測できた範囲と未保証範囲を明記します。観測できない機能を推測して実装しません。
@@ -0,0 +1,109 @@
1
+ # Payment Result
2
+
3
+ Payment Result API は、Nodex Pay が blockchain 上の支払い結果を確認した後、加盟店が管理画面で指定した URL へ送る JSON POST です。SDK は Web サーバーを提供せず、raw request body の解析と署名検証だけを担当します。
4
+
5
+ ## 公開 API
6
+
7
+ ```ruby
8
+ result = client.verify_payment_result(raw_request_body)
9
+ ```
10
+
11
+ 成功時は `NodexPay::PaymentResult`、JSON/field が不正な場合は `InvalidResponseError`、署名不一致の場合は `SignatureVerificationError` を返します。
12
+
13
+ 署名不一致を Boolean で返す低水準 API は公開しません。未検証データを業務処理へ渡さないためです。
14
+
15
+ ## 受信形式
16
+
17
+ Content-Type は公式仕様どおり `application/json` です。利用側は HTTP body を文字コード変換、parameter merge、symbolize する前に String として SDK へ渡します。
18
+
19
+ ```json
20
+ {
21
+ "order_code": "order-123",
22
+ "transaction_code": "A1B2-3CD4-5E6F",
23
+ "amount": "100",
24
+ "symbol": "USDT",
25
+ "result": true,
26
+ "verify_token": "lowercase-sha256-hex",
27
+ "chain_id": "1",
28
+ "transaction_hash": "0x..."
29
+ }
30
+ ```
31
+
32
+ 公式ページに完全な payload 例はないため、上記は field の組み合わせを示す SDK 文書上の例です。
33
+
34
+ ## field contract
35
+
36
+ | Field | 型 | SDK の検査 |
37
+ | --- | --- | --- |
38
+ | `order_code` | String | 空でないこと |
39
+ | `transaction_code` | String | `\A[A-Za-z0-9]{4}(?:-[A-Za-z0-9]{4}){2}\z` に一致する14 characters |
40
+ | `amount` | String | 空でないこと。署名用にそのまま保持 |
41
+ | `symbol` | String | 空でないこと。固定 enum にはしない |
42
+ | `result` | Boolean | `true` または `false` だけを許可 |
43
+ | `verify_token` | String | 64 characters の16進 SHA-256 |
44
+ | `chain_id` | String | 空でないこと。固定 enum にはしない |
45
+ | `transaction_hash` | String | 空でないこと |
46
+
47
+ すべての field を必須として扱います。公式 table に required marker はありませんが、各 field の省略時動作も定義されていないため、欠落を許容して不完全なモデルを生成することはしません。
48
+
49
+ `symbol` と `chain_id` は、対応 token/network の追加を妨げないよう SDK の enum validation を行いません。
50
+
51
+ ## 署名検証
52
+
53
+ 受信した文字列を次の順番で連結し、client の `hash_token` を使って SHA-256 を計算します。
54
+
55
+ ```text
56
+ <order_code>::<amount>::<hash_token>
57
+ ```
58
+
59
+ 重要な点:
60
+
61
+ - `amount` は数値化、丸め、末尾ゼロ削除を行わない
62
+ - 受信した `verify_token` は小文字へ変換せず、正規の64桁小文字 hex として検査する
63
+ - 長さを検査した後、`OpenSSL.fixed_length_secure_compare` 相当の constant-time comparison を行う
64
+ - `hash_token` や生成した raw signing string を例外、ログ、inspect へ含めない
65
+
66
+ ## 署名のセキュリティ境界
67
+
68
+ > [!WARNING]
69
+ > 公式署名が保護するのは `order_code` と `amount` だけです。`result`、`transaction_code`、`symbol`、`chain_id`、`transaction_hash` は署名対象ではありません。署名検証の成功だけで payload 全体の完全性や blockchain finality が保証されたと扱ってはいけません。
70
+
71
+ 利用アプリケーションは、検証後に少なくとも次を行います。
72
+
73
+ 1. `order_code` に対応する注文が存在することを確認する。
74
+ 2. 保存済みの注文金額・受取 token と `amount` / `symbol` を照合する。
75
+ 3. `transaction_code` を一意 key として、同じ通知を重複処理しない。
76
+ 4. 既に確定した注文を古い通知や矛盾する通知で上書きしない。
77
+ 5. 高い finality が必要な場合は、`transaction_hash` を explorer / JSON-RPC で別途確認する。
78
+
79
+ これらは加盟店のデータストアと業務規則を必要とするため SDK の責務には含めません。
80
+
81
+ ## HTTP endpoint 側の責務
82
+
83
+ SDK は Rack/Rails response を生成しません。利用側 endpoint は次を担当します。
84
+
85
+ - `Content-Type` と request size を確認する
86
+ - raw body を一度だけ読み取って `verify_payment_result` へ渡す
87
+ - 検証成功後、トランザクション内で重複排除と状態更新を行う
88
+ - 保存成功後にのみ 2xx を返す
89
+ - 例外時の response、監視、再処理方針を決める
90
+
91
+ 公式文書は ACK body、必要な status、timeout、再送回数、再送間隔、順序保証を規定していません。SDK は特定の ACK や retry protocol を仮定しません。
92
+
93
+ ## リプレイと重複
94
+
95
+ Payment Result には timestamp、nonce、delivery ID がありません。署名検証だけでは同一 payload の再送を検出できません。
96
+
97
+ `transaction_code` を一意制約付きで保存し、同じ値の再受信は同じ結果を返す業務処理を推奨します。ただし、この一意性管理は SDK がメモリ内で代替してはいけません。複数プロセス・再起動をまたぐ永続性が必要だからです。
98
+
99
+ ## Finality
100
+
101
+ 公式 FAQ には blockchain reorganization を完全には考慮しない旨の記載があります。`result: true` は Nodex Pay が成功として通知した事実を表しますが、不可逆な blockchain finality の保証ではありません。
102
+
103
+ ## Nodex 側への確認事項
104
+
105
+ 1. 必須 field と failure notification 時の nullability
106
+ 2. ACK に必要な HTTP status / body
107
+ 3. timeout、再送、重複、順序の保証
108
+ 4. source IP、追加認証、secret rotation の仕様
109
+ 5. `result` と transaction field を含む署名方式への拡張予定
data/docs/testing.md ADDED
@@ -0,0 +1,175 @@
1
+ # テスト戦略
2
+
3
+ テストは、外部 API を推測した柔軟な挙動ではなく、この設計文書で固定した公開契約を検証します。unit test は Minitest、型検査は RBS と Sorbet を使用します。
4
+
5
+ ## テスト階層
6
+
7
+ | 階層 | 目的 | 外部通信 |
8
+ | --- | --- | --- |
9
+ | Unit | validation、金額正規化、署名、decoder、モデル | なし |
10
+ | Client contract | Client から transport へ渡る request と戻り値/例外 | fake transport |
11
+ | Net::HTTP integration | form body、header、timeout、TLS/connection error mapping | local test server |
12
+ | Testnet smoke | Nodex Pay の実際の wire contract | 手動・資格情報必須 |
13
+
14
+ Production API は自動テストや CI から呼び出しません。
15
+
16
+ ## 固定署名ベクトル
17
+
18
+ 署名テストは最低限、次を固定 fixture として使用します。
19
+
20
+ ### 金額あり
21
+
22
+ ```text
23
+ raw:
24
+ ORDER-123::10::hash-secret
25
+
26
+ sha256:
27
+ b9087ce2f0af360db0a0172df536f755e25700b13601306831224ae69d22631a
28
+ ```
29
+
30
+ ### 金額なし
31
+
32
+ ```text
33
+ raw:
34
+ ORDER-123::::hash-secret
35
+
36
+ sha256:
37
+ 3816f1a2297c926880d0859439a7da193959936d4816dd28000e18428599c0a3
38
+ ```
39
+
40
+ `10` と `10.0` は異なる digest になるため、Client contract test では正規化後の文字列が署名入力と form field の両方で同一であることを直接検証します。
41
+
42
+ ## Configuration
43
+
44
+ - `:production` と `:testnet` を受け付ける
45
+ - environment 未指定、未知 Symbol/String を拒否する
46
+ - 空の `identification_token` / `hash_token` を拒否する
47
+ - timeout の既定値が 5/30/30 秒である
48
+ - 正の Integer/Float timeout を受け付け、0、負数、NaN、Infinity を拒否する
49
+ - custom transport が `call` contract を満たさない場合を明確な `ConfigurationError` にする
50
+ - Client の `inspect` に credential が含まれない
51
+
52
+ ## 金額と入力 validation
53
+
54
+ ### 正常系
55
+
56
+ - String、Integer、BigDecimal を受け付ける
57
+ - `"10.00"`、`10`、`BigDecimal("10.0")` を `"10"` にする
58
+ - `"10.50"` を `"10.5"` にする
59
+ - 最小値 `0.000001` と最大値 `100000000` を受け付ける
60
+ - amount nil 時に form から `amount` を完全に省略する
61
+ - Time deadline を Unix time 秒へ変換する
62
+ - enum の Symbol/String と大文字・小文字を規定どおり正規化する
63
+
64
+ ### 異常系
65
+
66
+ - Float、指数表記、先頭ゼロ、負数、ゼロ、範囲外、空文字列を拒否する
67
+ - NaN、Infinity を拒否する
68
+ - 非 ASCII、空、256 bytes 以上の `order_code` を拒否する
69
+ - 未知の `amount_type`、`color_theme`、`ui_mode` を拒否する
70
+ - 101 characters 以上または不正 UTF-8 の description 系 field を拒否する
71
+ - 負の deadline、Time/Integer 以外の deadline を拒否する
72
+
73
+ ## Payment Request
74
+
75
+ - environment ごとに正しい `/payment/receive` URI を組み立てる
76
+ - method、Accept、Content-Type、User-Agent が契約どおりである
77
+ - form body に credential、order code、digest、全 optional field が正しい wire name で入る
78
+ - `ui_mode` が `uimode` になる
79
+ - nil の optional field、`ext_reserved`、`callback_url`、`hash_token` が body に入らない
80
+ - 200 JSON を `Payment` へ変換する
81
+ - `ext_reserved` / `ext_description` の欠落と null を nil として扱う
82
+ - `url` / `token` 欠落、型違い、invalid JSON を `InvalidResponseError` にする
83
+
84
+ ## Payment Cancel
85
+
86
+ - environment ごとに正しい `/payment/cancel` URI を組み立てる
87
+ - 署名が必ず `order_code::::hash_token` である
88
+ - body に `amount` が入らない
89
+ - 200 の空 body、`{}`、その他の body で一律 `true` を返す
90
+ - 2022、2023、2024 を含む400を `BadRequestError` にする
91
+ - 2022 を成功へ読み替えない
92
+
93
+ ## HTTP と API error
94
+
95
+ - top-level `errors` の Array/scalar を抽出する
96
+ - `data.errors` の Array/scalar を抽出する
97
+ - 数値 String は code 化し、非数値値は無視する
98
+ - unknown code を保持する
99
+ - malformed JSON と plain text body でも status/body を失わない
100
+ - 400、503、未知 status、redirect status を正しい例外へ変換する
101
+ - open/read/write timeout を `TimeoutError` にする
102
+ - DNS、TLS、connection reset を `TransportError` にする
103
+ - どの通信エラーにも自動 retry しない
104
+ - redirect の Location へ2回目の request を送らない
105
+ - 例外の message / inspect に credential、verify token、request body が含まれない
106
+
107
+ ## Payment Result
108
+
109
+ ### 正常系
110
+
111
+ - 正しい JSON と署名を `PaymentResult` へ変換する
112
+ - `amount` の末尾ゼロを変更せず署名に使い、モデルにも保持する
113
+ - `result: true` と `result: false` を受け付ける
114
+ - `success?` が `result` と一致する
115
+ - 未知の `symbol` / `chain_id` を String として保持する
116
+
117
+ ### 異常系
118
+
119
+ - invalid JSON、配列 top-level、欠落 field、null、型違いを拒否する
120
+ - transaction code の形式違反を拒否する
121
+ - verify token の長さ、文字種、大文字 hex を拒否する
122
+ - order code、amount、hash token のいずれかが異なる署名を拒否する
123
+ - `result` や transaction field の変更だけでは公式署名が変化しないという制約を、期待されるセキュリティ境界として明示的にテストする
124
+ - 署名不一致時に未検証の payload/model を例外へ含めない
125
+
126
+ ## Transport contract
127
+
128
+ fake/custom transport 共通の contract test を用意します。
129
+
130
+ - `call(Request)` を1回だけ呼ぶ
131
+ - `Response` の status が Integer、headers が Hash、body が String であることを要求する
132
+ - response 型違反を `InvalidResponseError` にする
133
+ - transport が body を変更しないことを、local server が受け取った exact body で確認する
134
+
135
+ ## CI matrix
136
+
137
+ | 対象 | 内容 |
138
+ | --- | --- |
139
+ | Ruby | 3.4、4.0 の最新 patch |
140
+ | Tests | 全 Minitest |
141
+ | Types | RBS validation、同梱 RBI の `srb tc`、Sorbet signature/strict-file coverage 100%検査 |
142
+ | Package | `gem build` と gem contents 検査 |
143
+ | Docs | Markdown link、相対リンク、コードブロック、表の検査 |
144
+
145
+ CI は credential を要求せず、Testnet smoke test とは分離します。
146
+
147
+ ## Testnet smoke test
148
+
149
+ 資格情報を環境変数から受け取る手動ジョブとして実行します。
150
+
151
+ 1. timestamp と random suffix を含む一意な `order_code` を生成する。
152
+ 2. 最小限の固定額で Payment Request を1回実行する。
153
+ 3. `url` と `token` の型を確認する。値そのものはログへ出さない。
154
+ 4. 同じ `order_code` を Payment Cancel へ渡す。
155
+ 5. HTTP status、Content-Type、response schema の観測結果を秘密情報なしで記録する。
156
+
157
+ 通信結果が不明な場合に自動で再実行しません。失敗時の order code は、管理画面で確認できるよう秘密でない artifact として保持します。
158
+
159
+ Payment Result の実環境確認は、専用の加盟店 endpoint と testnet payment が必要なため別の手動受入試験とします。
160
+
161
+ ## 完了条件
162
+
163
+ - Ruby 3.4 / 4.0 の全テストが成功する
164
+ - 固定ベクトルと exact form body test が成功する
165
+ - すべての例外 path で秘密情報が露出しない
166
+ - Production への自動通信が存在しない
167
+ - このディレクトリの公開 API、引数、モデル、例外名が実装・RBS・Sorbet RBI・テストと一致する
168
+ - Sorbet RBI に型エラーがなく、metrics上のsignature coverageとstrict-file coverageがともに100%である
169
+
170
+ Sorbetのcoverageは、同梱RBIについて次のように定義します。
171
+
172
+ - signature coverage: `types.sig.count / types.input.foundmethods.total`
173
+ - strict-file coverage: `types.input.files.sigil.strict / rbi/**/*.rbi のファイル数`
174
+
175
+ `script/check_sorbet.rb` が両方を計算し、100%未満なら終了statusを非0にします。SorbetではRubyの `Data.define` が生成するinitializerとRBIのkeyword initializerを重ねると誤った再定義判定が起きるため、この検査は配布物であるRBIを単独で実行します。
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NodexPay
4
+ module AmountSerializer
5
+ MINIMUM = "0.000001"
6
+ MAXIMUM = "100000000"
7
+ DECIMAL_PATTERN = /\A(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/
8
+
9
+ module_function
10
+
11
+ def serialize(amount)
12
+ raw = case amount
13
+ when String
14
+ validate_string!(amount)
15
+ amount
16
+ when Integer
17
+ amount.to_s
18
+ else
19
+ bigdecimal_value(amount)
20
+ end
21
+
22
+ normalized = normalize(raw)
23
+ validate_string!(normalized)
24
+ unless compare_decimals(normalized, MINIMUM) >= 0 && compare_decimals(normalized, MAXIMUM) <= 0
25
+ raise ValidationError, "amount must be between 0.000001 and 100000000"
26
+ end
27
+
28
+ normalized
29
+ rescue ArgumentError
30
+ raise ValidationError, "amount must be a plain decimal value"
31
+ end
32
+
33
+ def validate_string!(amount)
34
+ return if DECIMAL_PATTERN.match?(amount)
35
+
36
+ raise ValidationError, "amount must use plain decimal notation without leading zeroes"
37
+ end
38
+ private_class_method :validate_string!
39
+
40
+ def normalize(value)
41
+ integer, fraction = value.split(".", 2)
42
+ return integer unless fraction
43
+
44
+ fraction = fraction.sub(/0+\z/, "")
45
+ fraction.empty? ? integer : "#{integer}.#{fraction}"
46
+ end
47
+ private_class_method :normalize
48
+
49
+ def bigdecimal_value(amount)
50
+ unless defined?(::BigDecimal) && amount.is_a?(::BigDecimal)
51
+ raise ValidationError, "amount must be a String, Integer, or BigDecimal"
52
+ end
53
+ unless amount.finite?
54
+ raise ValidationError, "amount must be between 0.000001 and 100000000"
55
+ end
56
+
57
+ amount.to_s("F")
58
+ end
59
+ private_class_method :bigdecimal_value
60
+
61
+ def compare_decimals(left, right)
62
+ left_integer, left_fraction = left.split(".", 2)
63
+ right_integer, right_fraction = right.split(".", 2)
64
+ left_fraction ||= ""
65
+ right_fraction ||= ""
66
+
67
+ integer_comparison = left_integer.length <=> right_integer.length
68
+ return integer_comparison unless integer_comparison.zero?
69
+
70
+ integer_comparison = left_integer <=> right_integer
71
+ return integer_comparison unless integer_comparison.zero?
72
+
73
+ width = [left_fraction.length, right_fraction.length].max
74
+ left_fraction.ljust(width, "0") <=> right_fraction.ljust(width, "0")
75
+ end
76
+ private_class_method :compare_decimals
77
+ end
78
+ end