mcpField
P
paypal-integration

paypal-integration

github.com/sickn33開発49.2k179 回インストール1 日前に更新
リポジトリ

paypal-integration について

paypal-integrationは、PayPalの決済、サブスクリプション、IPN通知、返金を実装する開発者向けスキルです。統合手順に加え、安全性や検証、信頼性の確認を扱います。参照用ファイルは別途入手が必要です。

主な機能

  1. 01Express CheckoutとOrders v2の手順
  2. 02IPNとWebhookの検証
  3. 03サブスクリプションと定期課金
  4. 04返金と紛争への対応
  5. 05決済の安全性と冪等性の確認

活用シーン

  1. 01アプリへのPayPal Checkout追加
  2. 02サブスクリプション更新の設定
  3. 03決済通知の処理と検証
  4. 04返金フローの設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/sickn33/agentic-awesome-skills --skill paypal-integration

インストール後、Claude Code で/paypal-integrationを入力して読み込まれたか確認します。

詳細

サポート資料の利用可否

以下は外部の前提条件であり、同梱ファイルではありません:<support-root>/references/express-checkout.md、<support-root>/references/ipn-handling.md、<support-root>/references/refund-workflows.md、<support-root>/references/billing-agreements.md、<support-root>/assets/paypal-client.py、<support-root>/assets/ipn-processor.py、<support-root>/assets/recurring-billing.py。元のソースまたは既存のインストール環境から入手し、使用前に内容を確認してください。必要なファイルが存在することを確認するまで、依存する手順を実行したり、完了したと主張したりしないでください。利用できない手順は報告してください。完全に文書化されたインラインのワークフローは、許可された範囲内で引き続き利用できます。

PayPal 連携

Express Checkout、IPN 処理、定期請求、返金ワークフローなど、PayPal 決済連携を包括的に扱います。

このスキルを使用しない場合

  • タスクが PayPal 連携に関係しない場合
  • このスコープ外の別のドメインやツールが必要な場合

手順

  • 目標、制約、必要な入力を明確にします。
  • 関連するベストプラクティスを適用し、結果を検証します。
  • 実行可能な手順と検証方法を提示します。

このスキルを使用する場合

  • 決済手段として PayPal を連携する場合
  • エクスプレスチェックアウトのフローを実装する場合
  • PayPal による定期請求を設定する場合
  • 返金や決済に関する異議申し立てを処理する場合
  • PayPal の Webhook(IPN)を処理する場合
  • 国際決済に対応する場合
  • PayPal サブスクリプションを実装する場合

主な概念

1. 決済プロダクト

PayPal Checkout

  • 1 回限りの決済
  • エクスプレスチェックアウトの利用体験
  • ゲスト決済と PayPal アカウント決済

PayPal Subscriptions

  • 定期請求
  • サブスクリプションプラン
  • 自動更新

PayPal Payouts

  • 複数の受取人への送金
  • マーケットプレイスおよびプラットフォームでの決済
2. 連携方法

クライアント側(JavaScript SDK)

  • Smart Payment Buttons
  • ホスト型決済フロー
  • 最小限のバックエンドコード

サーバー側(REST API)

  • 決済フローを完全に制御
  • カスタムのチェックアウト UI
  • 高度な機能
3. IPN(Instant Payment Notification)
  • Webhook に似た決済通知
  • 非同期の決済更新
  • 検証が必要

クイックスタート

認証済みバックエンド上の Orders v2 エンドポイントと、現行の JavaScript SDK を使用してください。ブラウザー側でのキャプチャとバックエンドでの二重キャプチャを組み合わせないでください。また、Orders v2 の ID をレガシーの Payments v1 SDK に渡さないでください。

// Browser fragment; include the SDK with the public client ID, never the client secret.
paypal.Buttons({
  async createOrder() {
    const response = await fetch('/api/paypal/orders', {method: 'POST'});
    if (!response.ok) throw new Error('Order creation failed');
    return (await response.json()).id;
  },
  async onApprove(data) {
    const response = await fetch('/api/paypal/capture', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({orderID: data.orderID})
    });
    if (!response.ok) throw new Error('Capture confirmation failed');
    // Backend owns reconciliation/fulfilment; the browser callback is not payment evidence.
  }
}).render('#paypal-button-container');

ルートハンドラーはアプリケーション用のアダプターであり、同梱されていません。ユーザーを認証し、CSRF/オリジン制御を適用し、注文を所有者のカートに紐付け、信頼できるデータから価格/通貨/受取人を決定し、操作ごとに安定した PayPal-Request-Id を永続化する必要があります。また、キャプチャのステータス/金額/通貨/受取人を検証してから、アトミックにフルフィルメントを実行してください。タイムアウトは結果不確定として扱い、別の請求を作成するのではなく、既存のリクエストの結果を照合してください。シークレットはサーバー環境またはシークレットマネージャーで管理してください。金額には Decimal または文字列を使い、通貨ごとの精度に従ってください。金額の処理にバイナリ浮動小数点数を使ってはいけません。以下のコード断片はサンドボックスの例であり、本番用決済システムではありません。

Express Checkout の実装

サーバー側での注文作成
import requests
import json

class PayPalClient:
    def __init__(self, client_id, client_secret, mode='sandbox'):
        if mode not in ('sandbox', 'live'):
            raise ValueError('Explicit sandbox or live mode required')
        self.client_id = client_id
        self.client_secret = client_secret
        self.base_url = 'https://api-m.sandbox.paypal.com' if mode == 'sandbox' else 'https://api-m.paypal.com'
        self.access_token = self.get_access_token()

    def get_access_token(self):
        """Get OAuth access token."""
        url = f"{self.base_url}/v1/oauth2/token"
        headers = {"Accept": "application/json", "Accept-Language": "en_US"}

        response = requests.post(
            url,
            headers=headers,
            data={"grant_type": "client_credentials"},
            auth=(self.client_id, self.client_secret), timeout=15, allow_redirects=False
        )

        response.raise_for_status()
        return response.json()['access_token']

    def create_order(self, amount, request_id, currency='USD'):
        """Create a PayPal order."""
        url = f"{self.base_url}/v2/checkout/orders"
        headers = {
            "Content-Type": "application/json",
            "PayPal-Request-Id": request_id, # Persisted per operation, unchanged on retry.
            "Authorization": f"Bearer {self.access_token}"
        }

        payload = {
            "intent": "CAPTURE",
            "purchase_units": [{
                "amount": {
                    "currency_code": currency,
                    "value": str(amount)
                }
            }]
        }

        response = requests.post(url, headers=headers, json=payload, timeout=15, allow_redirects=False)
        response.raise_for_status()
        return response.json()

    def capture_order(self, order_id, request_id):
        """Capture payment for an order."""
        url = f"{self.base_url}/v2/checkout/orders/{order_id}/capture"
        headers = {
            "Content-Type": "application/json",
            "PayPal-Request-Id": request_id, # Persisted per operation, unchanged on retry.
            "Authorization": f"Bearer {self.access_token}"
        }

        response = requests.post(url, headers=headers, timeout=15, allow_redirects=False)
        response.raise_for_status()
        return response.json()

    def get_order_details(self, order_id):
        """Get order details."""
        url = f"{self.base_url}/v2/checkout/orders/{order_id}"
        headers = {
            "Authorization": f"Bearer {self.access_token}"
        }

        response = requests.get(url, headers=headers, timeout=15, allow_redirects=False)
        response.raise_for_status()
        return response.json()

IPN(Instant Payment Notification)の処理

IPN はレガシーの通知プロトコルであり、REST Webhooks API ではありません。現行の REST 連携では、文書化されている Webhook ID と環境を使って Webhook 署名を検証してください。受信リクエストのサイズを制限してください。VERIFIED が確認するのは送信元のみです。受取人、通貨、金額、所有者の請求書/注文、取引ステータスを検証し、一意なイベント/取引レコードを使ってアトミックに重複を排除してください。確認してから実行するだけのヘルパーでは競合が発生します。取消や返金は元のレコードに対して処理し、永続的に記録した後にのみ応答してください。

IPN の検証と処理
from flask import Flask, request
import requests
from urllib.parse import parse_qs

app = Flask(__name__)

@app.route('/ipn', methods=['POST'])
def handle_ipn():
    """Handle PayPal IPN notifications."""
    # Get IPN message
    raw_ipn = request.get_data(cache=True)
    ipn_data = request.form.to_dict()

    # Verify IPN with PayPal
    if not verify_ipn(raw_ipn):
        return 'IPN verification failed', 400

    # Process IPN based on transaction type
    payment_status = ipn_data.get('payment_status')
    txn_type = ipn_data.get('txn_type')

    if payment_status == 'Completed':
        handle_payment_completed(ipn_data)
    elif payment_status == 'Refunded':
        handle_refund(ipn_data)
    elif payment_status == 'Reversed':
        handle_chargeback(ipn_data)

    return 'IPN processed', 200

def verify_ipn(raw_ipn):
    """Echo original encoded body in order; do not reconstruct it from a dict."""
    paypal_url = 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
    response = requests.post(paypal_url, data=b'cmd=_notify-validate&' + raw_ipn,
                             headers={'Content-Type': 'application/x-www-form-urlencoded'},
                             timeout=15, allow_redirects=False)
    response.raise_for_status()
    return response.text.strip() == 'VERIFIED'

def handle_payment_completed(ipn_data):
    """Process completed payment."""
    txn_id = ipn_data.get('txn_id')
    payer_email = ipn_data.get('payer_email')
    mc_gross = ipn_data.get('mc_gross')
    item_name = ipn_data.get('item_name')

    # Check if already processed (prevent duplicates)
    if is_transaction_processed(txn_id):
        return

    # Update database
    # Send confirmation email
    # Fulfill order
    print(f"Payment completed: {txn_id}, Amount: ${mc_gross}")

def handle_refund(ipn_data):
    """Handle refund."""
    parent_txn_id = ipn_data.get('parent_txn_id')
    mc_gross = ipn_data.get('mc_gross')

    # Process refund in your system
    print(f"Refund processed: {parent_txn_id}, Amount: ${mc_gross}")

def handle_chargeback(ipn_data):
    """Handle payment reversal/chargeback."""
    txn_id = ipn_data.get('txn_id')
    reason_code = ipn_data.get('reason_code')

    # Handle chargeback
    print(f"Chargeback: {txn_id}, Reason: {reason_code}")

ミューテーションの例では、対応している場合、永続化された操作固有の PayPal-Request-Id、不変のサーバー管理金額、不透明で検証済みのリソース ID が必要です。トークンの有効期限と更新、検証済みの加盟店 ID、永続的なイベント処理は、統合作業として必要です。どの断片も単独でフルフィルメントを実装するものではありません。返金と継続課金には、その操作に対する正確な認可が必要です。

サブスクリプション/継続課金

サブスクリプションプランの作成
def create_subscription_plan(name, amount, request_id, interval='MONTH'):
    """Create a subscription plan."""
    client = PayPalClient(CLIENT_ID, CLIENT_SECRET)

    url = f"{client.base_url}/v1/billing/plans"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {client.access_token}",
        "PayPal-Request-Id": request_id
    }

    payload = {
        "product_id": "PRODUCT_ID",  # Create product first
        "name": name,
        "billing_cycles": [{
            "frequency": {
                "interval_unit": interval,
                "interval_count": 1
            },
            "tenure_type": "REGULAR",
            "sequence": 1,
            "total_cycles": 0,  # Infinite
            "pricing_scheme": {
                "fixed_price": {
                    "value": str(amount),
                    "currency_code": "USD"
                }
            }
        }],
        "payment_preferences": {
            "auto_bill_outstanding": True,
            "setup_fee": {
                "value": "0",
                "currency_code": "USD"
            },
            "setup_fee_failure_action": "CONTINUE",
            "payment_failure_threshold": 3
        }
    }

    response = requests.post(url, headers=headers, json=payload, timeout=15, allow_redirects=False)
    response.raise_for_status()
    return response.json()

def create_subscription(plan_id, subscriber_email, request_id):
    """Create a subscription for a customer."""
    client = PayPalClient(CLIENT_ID, CLIENT_SECRET)

    url = f"{client.base_url}/v1/billing/subscriptions"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {client.access_token}",
        "PayPal-Request-Id": request_id
    }

    payload = {
        "plan_id": plan_id,
        "subscriber": {
            "email_address": subscriber_email
        },
        "application_context": {
            "return_url": "https://yourdomain.com/subscription/success",
            "cancel_url": "https://yourdomain.com/subscription/cancel"
        }
    }

    response = requests.post(url, headers=headers, json=payload, timeout=15, allow_redirects=False)
    response.raise_for_status()
    subscription = response.json()

    # Get approval URL
    for link in subscription.get('links', []):
        if link['rel'] == 'approve':
            return {
                'subscription_id': subscription['id'],
                'approval_url': link['href']
            }

返金ワークフロー

def create_refund(capture_id, request_id, amount=None, note=None):
    """Create a refund for a captured payment."""
    client = PayPalClient(CLIENT_ID, CLIENT_SECRET)

    url = f"{client.base_url}/v2/payments/captures/{capture_id}/refund"
    headers = {
        "Content-Type": "application/json",
        "PayPal-Request-Id": request_id,
        "Authorization": f"Bearer {client.access_token}"
    }

    payload = {}
    if amount is not None:
        payload["amount"] = {
            "value": str(amount),
            "currency_code": "USD"
        }

    # Validate positive Decimal refund <= remaining captured balance and capture currency first.
    if note:
        payload["note_to_payer"] = note

    response = requests.post(url, headers=headers, json=payload, timeout=15, allow_redirects=False)
    response.raise_for_status()
    return response.json()

def get_refund_details(refund_id):
    """Get refund details."""
    client = PayPalClient(CLIENT_ID, CLIENT_SECRET)

    url = f"{client.base_url}/v2/payments/refunds/{refund_id}"
    headers = {
        "Authorization": f"Bearer {client.access_token}"
    }

    response = requests.get(url, headers=headers, timeout=15, allow_redirects=False)
    response.raise_for_status()
    return response.json()

エラー処理

class PayPalError(Exception):
    """Custom PayPal error."""
    pass

def handle_paypal_api_call(api_function):
    """Wrapper for PayPal API calls with error handling."""
    try:
        result = api_function()
        return result
    except requests.exceptions.RequestException as e:
        # Network error
        raise PayPalError(f"Network error: {str(e)}")
    except Exception as e:
        # Other errors
        raise PayPalError(f"PayPal API error: {str(e)}")

# Usage
try:
    order = handle_paypal_api_call(lambda: client.create_order("25.00", persisted_request_id))
except PayPalError as e:
    # Handle error appropriately
    log_error(e)

テスト

# Use sandbox credentials
import os
SANDBOX_CLIENT_ID = os.environ["PAYPAL_SANDBOX_CLIENT_ID"]
SANDBOX_SECRET = os.environ["PAYPAL_SANDBOX_SECRET"]

# Test accounts
# Create test buyer and seller accounts at developer.paypal.com

def test_payment_flow():
    """Test complete payment flow."""
    client = PayPalClient(SANDBOX_CLIENT_ID, SANDBOX_SECRET, mode='sandbox')

    # Create order
    order = client.create_order("10.00", "sandbox-test-order-001")
    assert 'id' in order

    # Get approval URL
    approval_url = next((link['href'] for link in order['links'] if link['rel'] == 'approve'), None)
    assert approval_url is not None

    # After approval (manual step with test account)
    # Capture order
    # captured = client.capture_order(order['id'], 'sandbox-test-capture-001')
    # assert captured['status'] == 'COMPLETED'

リソース

  • references/express-checkout.md:エクスプレスチェックアウトの実装ガイド
  • references/ipn-handling.md:IPN の検証と処理
  • references/refund-workflows.md:返金処理のパターン
  • references/billing-agreements.md:継続課金の設定
  • assets/paypal-client.py:本番環境向け PayPal クライアント
  • assets/ipn-processor.py:IPN ウェブフックプロセッサー
  • assets/recurring-billing.py:サブスクリプション管理

ベストプラクティス

  1. IPN は必ず検証する:検証せずに IPN を信頼しないでください
  2. 冪等な処理:重複した IPN 通知を処理できるようにしてください
  3. エラー処理:堅牢なエラー処理を実装してください
  4. ログ記録:イベント ID とステータスのみを最小限に記録し、トークン、支払者識別子、生のプロバイダーエラーはマスキングしてください
  5. 十分にテストする:サンドボックスを十分に活用してください
  6. ウェブフックのバックアップ:クライアント側のコールバックだけに依存しないでください
  7. 通貨の処理:通貨は必ず明示的に指定してください

よくある落とし穴

  • IPN を検証しない:検証せずに IPN を受け入れる
  • 重複処理:重複したトランザクションを確認しない
  • 環境の誤り:サンドボックスと本番環境の URL/認証情報を混在させる
  • ウェブフックの不足:すべての支払い状態を処理しない
  • 値のハードコード:環境ごとに設定可能にしない

制限事項

  • 上記で説明した範囲にタスクが明確に一致する場合にのみ、このスキルを使用してください。
  • 出力を、環境固有の検証、テスト、または専門家によるレビューの代わりとして扱わないでください。
  • 必要な入力、権限、安全上の境界、または成功基準が不足している場合は、作業を中断して確認してください。

コメント