この記事は、PAY Advent Calendar 2025 の9日目として書いています。23日目にも書いているのですが、CLIの方もめどがついたので、書きました。
※今回実装したものは、テスト実装なので、くれぐれも本番で使おうとはしないようにお願いします!
PAY.JP について
PAY.JP(ペイドットジェーピー)とは、開発者や事業者向けに、Webサイトやアプリにクレジットカード決済機能を簡単に導入できるオンライン決済代行サービスです。シンプルなAPIと豊富なライブラリが特徴で、PCI DSS準拠の高いセキュリティのもと、業界最低水準の決済手数料(Visa/Mastercard: 2.59%〜)で提供されており、サブスクリプションやプラットフォーム決済、Apple Payなど多様な決済に対応しています。
主な特徴
- 簡単な導入: 開発者向けのAPIが用意されており、数行のコードでクレジットカード決済機能を実装できます
- 高いセキュリティ: 国際標準のPCI DSS v4.0に準拠し、カード情報をトークン化することで安全に決済を処理します
- リーズナブルな手数料: 業界最低水準の決済手数料で、コストを抑えて利用できます
- 豊富な機能: クレジットカード決済のほか、定期課金、Apple Pay、プラットフォーム決済、通知機能などを統合して利用できます
- 柔軟な料金プラン: ベンチャー企業や教育機関向けなど、業種や利用規模に応じたプランがあります
- 資金調達サービス: 売上実績に基づいて資金を調達できる「PAY.JP YELL BANK」も提供しています
PAY.JP の CLI
オフィシャルとして、実装されているようです。
オフィシャル CLI の仕様
オフィシャル CLI に仕様を Gemini に説明してもらいます。
なんか、感じとしてはデバッグ用ツール?
1. 認証とプロファイル設定 (login)
PAY.JP アカウントと CLI を連携させ、操作に必要な認証情報を設定します。
-
認証の実行:
loginコマンドを実行すると、ペアリングコードとブラウザ用の URL が表示されます。ブラウザで認証を完了させることで、CLI が安全に API キーを取得します。 - プロファイルの作成: 認証に成功すると、テストモード用のシークレットキーを含むプロファイルが作成され、ローカルに保存されます。
-
複数プロファイルの管理:
--profile(または-p) フラグを使用して、用途ごとに異なる設定(プロファイル)を使い分けることができます。
2. Webhook イベントのリアルタイム購読と転送 (listen)
PAY.JP で発生したイベント(支払いの作成など)をリアルタイムで受け取り、ローカルの開発環境に転送できます。
-
イベントの購読: 特定のイベント(例:
charge.created)を指定して購読するか、デフォルトですべてのイベントを購読します。 -
ローカルへの転送:
--forward-to(または-f) フラグを使用して、受け取ったイベントをローカルで起動しているサーバー(例:localhost:3000)や任意の URL に HTTP POST で転送します。 - ログ出力: 受信したイベントのタイプと、PAY.JP 管理画面へのリンクをターミナルに表示します。
- 接続の安定性: gRPC ストリーミングを使用してサーバーと通信し、切断時には自動再試行を行います。
3. ログアウト (logout)
CLI に保存された認証情報を削除します。
- 特定プロファイルの削除: 現在使用している、または指定したプロファイルの認証情報を削除します。
-
全プロファイルのクリア:
--allフラグを使用することで、すべてのプロファイルの認証情報を一括で削除し、ログアウト状態にできます。
4. その他の共通機能
-
設定のカスタマイズ: 設定ファイル(デフォルトは
$HOME/.payjp-cli)を使用して、ベース URL や gRPC サーバーのアドレスを変更できます。 -
バージョン情報の確認:
versionフラグなどで、CLI のバージョン、ビルド時のコミットハッシュ、ビルド日時を確認できます。
まとめ:主なユースケース
このコマンドを使用することで、**「PAY.JP からの Webhook を、サーバーを公開することなくローカルの開発環境で直接受け取り、バックエンドの処理をデバッグする」**といった作業が容易になります。
個人的に期待するもの
個人的には、APIでできることが全部できるようなものがいいとは思ってます。
アドベントカレンダー23日目用に作成してSDKを使えば良かったのですが、完全に抜けていて、ゼロベースで作成してます。
仕様書
作成した CLI から、Gemini に起こしてもらった仕様書です。
1. システム概要
本システムは、PAY.JP決済サービスのAPIを操作するためのコマンドラインインターフェース(CLI)ツールです。Rust言語で実装されており、支払い、顧客、カード、トークンの各リソースの操作および設定管理機能を提供します。
2. システム構成・アーキテクチャ
システムはモジュール化されており、以下の構成で設計されています。
| モジュール | 役割 | 該当ファイル |
|---|---|---|
| エントリーポイント | 引数のパース、コマンドの実行制御 | main.rs |
| CLIコマンド層 | 各サブコマンド(charge, customer等)のロジック | src/cli/*.rs |
| APIクライアント層 | HTTPリクエストの送信、認証、リトライ処理 | src/api/*.rs |
| モデル層 | APIリクエスト/レスポンスのデータ構造 | src/models/*.rs |
| 共通機能 | 設定管理、エラー定義、出力フォーマット |
config.rs, error.rs, output.rs
|
3. 主要コンポーネント設計
3.1. APIクライアント (src/api/)
PAY.JP APIとの通信を担当します。
-
PayjpClient: 秘密鍵(Secret Key)を使用し、支払い・顧客・カードの操作を行います。
-
reqwestを利用したブロッキング通信を実装しています。 -
レート制限(429 Too Many Requests)やネットワークエラーに対する指数バックオフ付きリトライ機能を備えています。
-
TokenClient: 公開鍵(Public Key)を使用し、トークンの作成・取得を行います。
3.2. 設定管理 (src/config.rs)
アプリケーションの設定を以下の優先順位で解決します。
- コマンドライン引数: 実行時に指定されたオプション。
-
環境変数:
PAYJP_SECRET_KEY,PAYJP_PUBLIC_KEY等。 -
設定ファイル:
config.tomlに保存されたプロファイル情報。
- マルチプロファイル対応: 「default」以外に任意の名前でプロファイルを切り替えて利用可能です。
3.3. 出力制御 (src/output.rs)
ユーザーへの表示形式を制御します。
-
Table形式:
tabledクレートを使用し、情報を整理された表形式で表示します。 - JSON形式: APIのレスポンスをそのままの構造で整形出力します。
- 日本語対応: エラーメッセージやヒント、日時のJST変換など、日本のユーザー向けにローカライズされています。
4. データモデル (src/models/)
APIで扱う主要なエンティティの定義です。
- Charge(支払い): 金額、通貨、支払い状態、カード情報、メタデータなどを保持します。
- Customer(顧客): メールアドレス、説明文、デフォルトカード、保有カードリストなどを保持します。
- Card(カード): ブランド、下4桁、有効期限、顧客ID、住所情報などを保持します。
-
Token(トークン): 一時的なカード情報を表し、
usedフラグで使用済みかを確認できます。
5. エラーハンドリング (src/error.rs)
thiserror を使用して独自の AppError を定義しています。
- Apiエラー: PAY.JP APIから返却されるエラー詳細(ステータスコード、エラータイプ、メッセージ)を保持します。
-
ヒント機能:
invalid_number(カード番号不正)やexpired_card(期限切れ)などのエラーに対し、具体的な対処法を日本語の「Hint」として提示します。
6. コマンド設計
主要なコマンドライン操作の構成は以下の通りです。
payjp
├── token # トークン作成・取得 (公開鍵)
├── charge # 支払い作成・取得・一覧・返金・確定 (秘密鍵)
├── customer # 顧客作成・取得・一覧・更新・削除 (秘密鍵)
├── card # カード作成・取得・一覧・更新・削除 (秘密鍵)
└── config # 設定の表示・保存・パス確認
7. 外部依存関係 (Cargo.toml)
- clap: コマンドライン引数の解析。
- reqwest: HTTP通信(blocking機能を使用)。
- serde / serde_json: JSONのシリアライズ・デシリアライズ。
- tabled: テーブル形式の出力表示。
- chrono: 日時操作とタイムゾーン変換。
- thiserror: エラー型の定義。
投入プロンプト
今回投入したプロンプトは Claude で作成しています。
なんだかんだで、プログラム関係のプロンプト作成は Claude が今のところいい感じがします。
プロンプトなので、必要はないのですが、絵文字とか入ってるあたりかわいいですよね。
# PAY.JP CLI - Rust 実装プロンプト
## 🎯 プロジェクト概要
PAY.JP 決済サービスの API を操作する CLI ツールを Rust でゼロベースから実装する。
既存ライブラリへの依存なしに、API 仕様から直接構築する。
## 📦 技術スタック
```toml
[package]
name = "payjp-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
tokio = { version = "1", features = ["full"] }
thiserror = "2"
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
tabled = "0.17" # テーブル表示
colored = "2" # カラー出力
dirs = "5" # 設定ファイルパス
toml = "0.8" # 設定ファイル
```
## 🏗️ アーキテクチャ
```
src/
├── main.rs # エントリーポイント
├── cli/
│ ├── mod.rs
│ ├── charge.rs # charge サブコマンド
│ ├── customer.rs # customer サブコマンド
│ └── card.rs # card サブコマンド
├── api/
│ ├── mod.rs
│ ├── client.rs # HTTP クライアント(認証・リトライ)
│ ├── charge.rs # Charge API
│ ├── customer.rs # Customer API
│ └── card.rs # Card API
├── models/
│ ├── mod.rs
│ ├── charge.rs # Charge 構造体
│ ├── customer.rs # Customer 構造体
│ ├── card.rs # Card 構造体
│ ├── error.rs # PAY.JP エラー構造体
│ └── common.rs # 共通型(List, Metadata等)
├── config.rs # 設定管理
├── error.rs # アプリケーションエラー
└── output.rs # 出力フォーマット(JSON/Table)
```
## 🔐 API 仕様
### 認証
- **方式**: Basic 認証
- **ヘッダー**: `Authorization: Basic {base64(secret_key:)}`
- **注意**: パスワード部分は空文字(コロン後ろ空)
```rust
fn create_auth_header(secret_key: &str) -> String {
let credentials = format!("{}:", secret_key);
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
format!("Basic {}", encoded)
}
```
### ベース URL
```
https://api.pay.jp/v1
```
### リクエスト形式
- **Content-Type**: `application/x-www-form-urlencoded`
- **レスポンス**: JSON
### レート制限
| ゾーン | テスト環境 | 本番環境 |
|--------|-----------|---------|
| pk | 2 req/s | 10 req/s |
| payment| 2 req/s | 14 req/s |
| sk | 2 req/s | 30 req/s |
**429 エラー時のリトライ戦略**:
- 指数バックオフ: 2s → 4s → 8s → 16s → 32s (max)
- ジッター: ±50% のランダム遅延
- 最大リトライ: 5回
## 📋 実装する API エンドポイント
### Charge API
| メソッド | エンドポイント | 説明 |
|---------|---------------|------|
| POST | `/v1/charges` | 支払い作成 |
| GET | `/v1/charges/{id}` | 支払い取得 |
| POST | `/v1/charges/{id}` | 支払い更新 |
| POST | `/v1/charges/{id}/refund` | 返金 |
| POST | `/v1/charges/{id}/capture` | 支払い確定 |
| POST | `/v1/charges/{id}/reauth` | 再オーソリ |
| POST | `/v1/charges/{id}/tds_finish` | 3Dセキュア完了 |
| GET | `/v1/charges` | 支払い一覧 |
**Charge 作成パラメータ**:
- `amount`: 50〜9,999,999 (必須)
- `currency`: "jpy" (必須)
- `card` または `customer`: トークンまたは顧客ID (必須)
- `capture`: bool (デフォルト true)
- `description`: 説明文
- `metadata[key]`: メタデータ (最大20キー)
- `three_d_secure`: bool (3Dセキュア有効化)
### Customer API
| メソッド | エンドポイント | 説明 |
|---------|---------------|------|
| POST | `/v1/customers` | 顧客作成 |
| GET | `/v1/customers/{id}` | 顧客取得 |
| POST | `/v1/customers/{id}` | 顧客更新 |
| DELETE | `/v1/customers/{id}` | 顧客削除 |
| GET | `/v1/customers` | 顧客一覧 |
### Card API
| メソッド | エンドポイント | 説明 |
|---------|---------------|------|
| POST | `/v1/customers/{customer_id}/cards` | カード作成 |
| GET | `/v1/customers/{customer_id}/cards/{id}` | カード取得 |
| POST | `/v1/customers/{customer_id}/cards/{id}` | カード更新 |
| DELETE | `/v1/customers/{customer_id}/cards/{id}` | カード削除 |
| GET | `/v1/customers/{customer_id}/cards` | カード一覧 |
## 📊 データモデル
### Charge
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct Charge {
pub id: String, // "ch_xxx"
pub object: String, // "charge"
pub livemode: bool,
pub created: i64, // Unix timestamp
pub amount: u64,
pub currency: String,
pub paid: bool,
pub captured: bool,
pub captured_at: Option<i64>,
pub refunded: bool,
pub amount_refunded: u64,
pub refund_reason: Option<String>,
pub card: Option<Card>,
pub customer: Option<String>,
pub description: Option<String>,
pub failure_code: Option<String>,
pub failure_message: Option<String>,
pub metadata: HashMap<String, String>,
pub three_d_secure_status: Option<String>, // "unverified" | "verified" | "attempted" | "failed"
}
```
### Customer
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct Customer {
pub id: String, // "cus_xxx"
pub object: String, // "customer"
pub livemode: bool,
pub created: i64,
pub default_card: Option<String>,
pub cards: CardList,
pub email: Option<String>,
pub description: Option<String>,
pub metadata: HashMap<String, String>,
}
```
### Card
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct Card {
pub id: String, // "car_xxx"
pub object: String, // "card"
pub created: i64,
pub name: Option<String>,
pub last4: String,
pub exp_month: u8,
pub exp_year: u16,
pub brand: String, // "Visa" | "MasterCard" | "JCB" | "American Express" | "Diners Club" | "Discover"
pub fingerprint: String,
pub country: Option<String>,
pub address_city: Option<String>,
pub address_line1: Option<String>,
pub address_line2: Option<String>,
pub address_state: Option<String>,
pub address_zip: Option<String>,
pub address_zip_check: Option<String>,
pub cvc_check: Option<String>,
pub metadata: HashMap<String, String>,
}
```
### Error
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct PayjpError {
pub error: PayjpErrorDetail,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PayjpErrorDetail {
pub status: u16,
#[serde(rename = "type")]
pub error_type: String, // "client_error" | "card_error" | "server_error"
pub code: Option<String>,
pub message: String,
pub param: Option<String>,
}
```
### List レスポンス
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct List<T> {
pub object: String, // "list"
pub count: u64,
pub has_more: bool,
pub url: String,
pub data: Vec<T>,
}
```
## 🖥️ CLI インターフェース
### グローバルオプション
```
payjp [OPTIONS] <COMMAND>
Options:
-k, --api-key <KEY> APIキー(環境変数 PAYJP_SECRET_KEY より優先)
-o, --output <FORMAT> 出力形式 [json|table] (default: table)
-v, --verbose 詳細出力
-h, --help ヘルプ表示
-V, --version バージョン表示
Commands:
charge 支払い操作
customer 顧客操作
card カード操作
config 設定管理
```
### Charge サブコマンド
```
payjp charge <COMMAND>
Commands:
create 支払い作成
get 支払い取得
update 支払い更新
refund 返金
capture 確定
list 一覧取得
Examples:
payjp charge create --amount 3500 --card tok_xxx
payjp charge create --amount 5000 --customer cus_xxx --capture=false
payjp charge get ch_xxx
payjp charge refund ch_xxx --amount 1000
payjp charge capture ch_xxx
payjp charge list --limit 20 --since 2024-01-01
```
### Customer サブコマンド
```
payjp customer <COMMAND>
Commands:
create 顧客作成
get 顧客取得
update 顧客更新
delete 顧客削除
list 一覧取得
Examples:
payjp customer create --email user@example.com --card tok_xxx
payjp customer get cus_xxx
payjp customer update cus_xxx --email new@example.com
payjp customer delete cus_xxx
payjp customer list --limit 10
```
### Card サブコマンド
```
payjp card <COMMAND>
Commands:
create カード作成
get カード取得
update カード更新
delete カード削除
list 一覧取得
Examples:
payjp card create cus_xxx --card tok_xxx
payjp card get cus_xxx car_xxx
payjp card update cus_xxx car_xxx --name "TARO YAMADA"
payjp card delete cus_xxx car_xxx
payjp card list cus_xxx
```
## ⚠️ エラーハンドリング
### 主要エラーコード
| コード | 説明 | 対処 |
|-------|------|------|
| `invalid_number` | カード番号不正 | 番号確認を促す |
| `invalid_cvc` | CVC不正 | CVC確認を促す |
| `invalid_expiry_month` | 有効期限月不正 | 1-12の範囲確認 |
| `invalid_expiry_year` | 有効期限年不正 | 将来の年確認 |
| `expired_card` | 有効期限切れ | 別カード使用を促す |
| `card_declined` | カード拒否 | カード会社確認を促す |
| `processing_error` | 処理エラー | リトライ |
| `invalid_api_key` | APIキー不正 | キー確認を促す |
| `over_capacity` | レート制限 | 自動リトライ |
| `three_d_secure_failed` | 3D認証失敗 | 再認証を促す |
### エラー出力例
```
Error: カード決済に失敗しました
Code: card_declined
Message: カード会社に拒否されました
Param: card
Hint: カード会社にお問い合わせいただくか、別のカードをお試しください。
```
## 🔧 設定ファイル
### パス
- Linux/macOS: `~/.config/payjp/config.toml`
- Windows: `%APPDATA%\payjp\config.toml`
### フォーマット
```toml
[default]
api_key = "sk_test_xxx"
output = "table"
[production]
api_key = "sk_live_xxx"
output = "json"
```
### 環境変数
- `PAYJP_SECRET_KEY`: APIキー
- `PAYJP_OUTPUT`: 出力形式
### 優先順位
1. コマンドラインオプション
2. 環境変数
3. 設定ファイル
## 🧪 テスト戦略
### テスト用カード番号
| 番号 | 結果 |
|------|------|
| `4242424242424242` | 成功 |
| `4000000000000002` | card_declined |
| `4000000000000069` | expired_card |
| `4000000000000119` | processing_error |
### ユニットテスト
- 各モデルの Serialize/Deserialize
- form-urlencoded エンコーディング
- エラーパース
- リトライロジック
### 統合テスト
- テスト環境での実API呼び出し
- E2Eシナリオ(顧客作成→カード登録→決済→返金)
## 📝 実装の優先順位
### Phase 1: 基盤
- [ ] プロジェクト構造セットアップ
- [ ] HTTP クライアント(認証・リトライ)
- [ ] エラーハンドリング
- [ ] 設定管理
### Phase 2: Charge API
- [ ] charge create
- [ ] charge get
- [ ] charge list
- [ ] charge refund
- [ ] charge capture
### Phase 3: Customer/Card API
- [ ] customer CRUD
- [ ] card CRUD
- [ ] ページネーション対応
### Phase 4: UX改善
- [ ] テーブル出力の整形
- [ ] カラー出力
- [ ] エラーメッセージの日本語化
- [ ] 補完スクリプト生成
### Phase 5: 3Dセキュア
- [ ] three_d_secure オプション
- [ ] tds_finish コマンド
## 💡 実装のヒント
### form-urlencoded エンコーディング
```rust
fn encode_params(params: &[(&str, &str)]) -> String {
params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&")
}
// Metadata のエンコード
// metadata[order_id]=12345 → metadata%5Border_id%5D=12345
```
### ページネーション
```rust
pub struct ListParams {
pub limit: Option<u8>, // 1-100, default 10
pub offset: Option<u64>, // default 0
pub since: Option<i64>, // Unix timestamp
pub until: Option<i64>, // Unix timestamp
}
```
### Unix タイムスタンプ表示
```rust
fn format_timestamp(ts: i64) -> String {
let dt = chrono::DateTime::from_timestamp(ts, 0)
.unwrap()
.with_timezone(&chrono::FixedOffset::east_opt(9 * 3600).unwrap()); // JST
dt.format("%Y-%m-%d %H:%M:%S JST").to_string()
}
```
## 🚀 期待する成果物
1. 完全に動作する CLI バイナリ
2. README.md(使用方法・インストール手順)
3. ユニットテスト・統合テスト
4. GitHub Actions CI 設定
5. Shell 補完スクリプト(bash/zsh/fish)
いざ投入
プログラム作成は、Claude Code on the Web を使用しています。
できたようですが、SDK作った時にも忘れていたことがあったので、追加しました。
トークン作成機能も実装します。
テスト
実装できたようなので、手元でテストなどやってみます。
penpen@PC release % ./payjp
PAY.JP CLI - Command line interface for PAY.JP payment service
Usage: payjp [OPTIONS] <COMMAND>
Commands:
charge Charge operations (create, get, list, refund, capture)
customer Customer operations (create, get, list, update, delete)
card Card operations (create, get, list, update, delete)
token Token operations (create, get) - uses public key
config Configuration management
help Print this message or the help of the given subcommand(s)
Options:
-k, --api-key <API_KEY> Secret API key (overrides PAYJP_SECRET_KEY environment variable)
--public-key <PUBLIC_KEY> Public API key (overrides PAYJP_PUBLIC_KEY environment variable)
-o, --output <OUTPUT> Output format [json|table]
-v, --verbose Enable verbose output
-p, --profile <PROFILE> Profile to use
-h, --help Print help
-V, --version Print version
penpen@PC release % ./payjp config set \
--api-key sk_test_000000000000000000000 \
--public-key pk_test_00000000000000000000000
✓ Secret key set to: sk_t...0000
✓ Public key set to: pk_t...0000
✓ Configuration saved to profile: default
penpen@PC release % ./payjp token create --number 4242424242424242 --exp-month 12 --exp-year 2025 --cvc 123
Token Details
──────────────────────────────────────────────────
ID: tok_46757cafa106fd72701f152fcbb0 (改竄済み)
Used: No
Live Mode: No (Test)
Created: 2025-12-26 18:48:45 JST
Card Information:
Brand: Visa
Number: ****4242
Expiry: 12/2025
CVC Check: passed
penpen@PC release % ./payjp charge create --amount 3500 --card tok_46757cafa106fd72701f152fcbb0
Charge Details
──────────────────────────────────────────────────
ID: ch_f012e0cc63cfaf86011bb05aa4711 (改竄済み)
Amount: ¥3500
Paid: Yes
Captured: Yes
Captured At: 2025-12-26 18:49:25 JST
Refunded: No
Card: Visa ****4242 (12/2025)
Live Mode: No (Test)
Created: 2025-12-26 18:49:25 JST
問題なく課金できたようですね。
ということで、一旦完成です。
まとめ
アドベントカレンダー的には未来になりますが、SDK を作成した時よりは勝手がわかった感もあり、そこそこ実装もうまくいったような気はします。金融系の場合、テストにテストを重ねてリリースを行うので、こういうバイブオンリーで作成したものを本番で使うかどうかと言われたら、なかなか難しいとこではあります。それでも、テストやレビューを重厚に行えるのであれば、バイブコーディングの可能性もあるのかな?とは思いました。何より、コーディング時間の短さには目を見張るものがあります。

