Skip to content

Commit 5f46ac2

Browse files
decofegrandizzygithub-actions[bot]ampcode-com
authored
feat: add Stripe payment method support (#145)
* feat: scaffold Stripe payment method support Adds the initial implementation for Stripe charge method (TOOLS-322): - src/protocol/methods/stripe/ — types (StripeChargeRequest, StripeCredentialPayload) and server-side ChargeMethod that verifies payments by creating a Stripe PaymentIntent with an SPT - src/client/stripe/ — StripeProvider implementing PaymentProvider with a user-provided createToken callback - src/server/ — StripeBuilder, StripeConfig, stripe() builder fn, Mpp::create_stripe(), Mpp::stripe_charge() - tests/integration_stripe.rs — e2e tests against a mock Stripe API (full 402 flow, challenge format, requires_action rejection) - Cargo.toml — stripe and integration-stripe feature flags Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> * chore: add changelog * feat: complete Stripe method implementation - Add spec-required methodDetails (networkId, paymentMethodTypes) to challenges - Add StripeChargeOptions (description, external_id, expires, metadata) - Add ChargeChallenger impl so MppCharge extractor works with Stripe - Parse Stripe error response bodies for better error messages - Add Stripe unit and integration tests Amp-Thread-ID: https://ampcode.com/threads/T-019d1969-811c-7725-aaa6-1e0116d5652c Co-authored-by: Amp <amp@ampcode.com> * Run stripe integration tests in CI * docs: add Stripe examples to README Amp-Thread-ID: https://ampcode.com/threads/T-019d1969-811c-7725-aaa6-1e0116d5652c Co-authored-by: Amp <amp@ampcode.com> * fix: align Stripe method with mppx TS SDK wire format - Replace StripeChargeRequest with StripeMethodDetails (correct wire shape: networkId/paymentMethodTypes/metadata nested under methodDetails) - stripe_charge() now emits methodDetails with networkId and paymentMethodTypes from config (fixes challenge schema bug) - Add stripe_charge_with_options() + StripeChargeOptions - Server verify() propagates metadata: analytics keys (mpp_version, mpp_is_mpp, mpp_intent, mpp_challenge_id, mpp_server_id, mpp_client_id) + user metadata from methodDetails - Callback returns CreateTokenResult { spt, external_id } instead of plain String (per-payment externalId support) - Provider extracts metadata from methodDetails.metadata and passes challenge JSON to callback - Remove CreateTokenFn trait alias; use plain generic F: Fn(...) - Provider reads networkId from methodDetails (not top-level) Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> * fix: clippy lints and update README examples Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> * refactor: deduplicate INTENT_CHARGE/INTENT_SESSION constants into protocol::intents Amp-Thread-ID: https://ampcode.com/threads/T-019d1969-811c-7725-aaa6-1e0116d5652c Co-authored-by: Amp <amp@ampcode.com> * refactor: use typed serde structs and ResultExt across Stripe code Amp-Thread-ID: https://ampcode.com/threads/T-019d1969-811c-7725-aaa6-1e0116d5652c Co-authored-by: Amp <amp@ampcode.com> * feat: add live Stripe integration tests + CI support - tests/integration_stripe_live.rs: tests against real Stripe test-mode API using test_helpers/shared_payment/granted_tokens endpoint. Skipped when STRIPE_SECRET_KEY is not set. - CI runs live tests only when STRIPE_SECRET_KEY secret exists - Tests: happy path (real SPT + PaymentIntent), invalid SPT rejection, expired challenge rejection Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> * fix: live Stripe test fallback for seller_details + suppress dead_code warning - create_test_spt now retries without seller_details when Stripe returns 'Received unknown parameter' (matches mppx fallback) - Suppress dead_code warning on ResultExt trait Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> * refactor: merge live Stripe tests into integration_stripe, runtime-gated Remove integration-stripe-live feature flag. Live tests now live in integration_stripe.rs and skip at runtime when STRIPE_SECRET_KEY is not set (matching mppx pattern). CI passes STRIPE_SECRET_KEY as env var to the test step. Revert #[allow(dead_code)] on ResultExt — pre-existing, not ours. Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> * refactor: extract server tempo/stripe config into submodules Amp-Thread-ID: https://ampcode.com/threads/T-019d1f49-ad9f-74a8-9268-37d860491bb8 Co-authored-by: Amp <amp@ampcode.com> * docs: add Stripe example (server + client) (#147) * docs: add Stripe example (server + client) Pay-per-fortune example demonstrating the full SPT flow: - Axum server with /api/create-spt proxy and /api/fortune gated endpoint - Headless CLI client using pm_card_visa test card - Mirrors the pympp examples/stripe/ structure Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d20a8-7850-76dc-a346-432e5d2f4f5f * chore: add changelog --------- Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix: remove emoji from stripe example to pass no-emojis lint Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> --------- Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: grandizzy <grandizzy.the.egg@gmail.com> Co-authored-by: Amp <amp@ampcode.com>
1 parent 04353f3 commit 5f46ac2

25 files changed

Lines changed: 2628 additions & 220 deletions

File tree

.changelog/kind-bees-pack.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
mpp: minor
3+
---
4+
5+
Added a Stripe Shared Payment Token (SPT) example demonstrating the full 402 → challenge → credential → retry flow using Stripe's payment method. Includes a server with SPT proxy endpoint and a headless client using a test card.

.changelog/unique-lakes-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
mpp: minor
3+
---
4+
5+
Added Stripe payment method support (`method="stripe"`, `intent="charge"`) with client-side `StripeProvider` for SPT creation, server-side `ChargeMethod` for PaymentIntent verification, and `Mpp::create_stripe()` builder integration. Added `stripe` and `integration-stripe` feature flags backed by `reqwest`.

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ jobs:
5151
- uses: Swatinem/rust-cache@v2
5252
- run: cargo update -p native-tls
5353
- uses: taiki-e/install-action@cargo-hack
54-
- run: cargo test --features tempo,server,client,axum,middleware,tower,utils
54+
- name: Tests
55+
run: cargo test --features tempo,stripe,server,client,axum,middleware,tower,utils,integration-stripe
56+
env:
57+
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
5558
- run: cargo hack check --each-feature --no-dev-deps --skip integration
5659
- name: Check examples
5760
run: find examples -name Cargo.toml -exec cargo check --manifest-path {} \;

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ server = ["tokio", "futures-core", "async-stream"]
2121
# Method implementations
2222
evm = ["alloy", "hex", "rand"]
2323
tempo = ["evm", "tempo-alloy", "tempo-primitives", "uuid"]
24+
stripe = ["dep:reqwest"]
2425

2526
# Utilities
2627
utils = ["hex", "rand"]
@@ -40,6 +41,7 @@ reqwest-rustls-tls = ["reqwest?/rustls-tls"]
4041

4142
# Integration tests (requires a running Tempo localnet)
4243
integration = ["tempo", "server", "client", "axum"]
44+
integration-stripe = ["stripe", "server", "client", "axum"]
4345

4446
[dependencies]
4547
# Core dependencies (always included)

README.md

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ cargo add mpp
3434

3535
## Quick Start
3636

37-
### Server
37+
### Server (Tempo)
3838

3939
```rust
4040
use mpp::server::{Mpp, tempo, TempoConfig};
@@ -47,7 +47,24 @@ let challenge = mpp.charge("1")?;
4747
let receipt = mpp.verify_credential(&credential).await?;
4848
```
4949

50-
### Client
50+
### Server (Stripe)
51+
52+
```rust
53+
use mpp::server::{Mpp, stripe, StripeConfig};
54+
55+
let mpp = Mpp::create_stripe(stripe(StripeConfig {
56+
secret_key: "sk_test_...",
57+
network_id: "internal",
58+
payment_method_types: &["card"],
59+
currency: "usd",
60+
decimals: 2,
61+
}))?;
62+
63+
let challenge = mpp.stripe_charge("1")?;
64+
let receipt = mpp.verify_credential(&credential).await?;
65+
```
66+
67+
### Client (Tempo)
5168

5269
```rust
5370
use mpp::client::{PaymentMiddleware, TempoProvider};
@@ -62,13 +79,37 @@ let client = ClientBuilder::new(reqwest::Client::new())
6279
let resp = client.get("https://mpp.dev/api/ping/paid").send().await?;
6380
```
6481

82+
### Client (Stripe)
83+
84+
```rust
85+
use mpp::client::{Fetch, StripeProvider};
86+
use mpp::protocol::methods::stripe::CreateTokenResult;
87+
88+
let provider = StripeProvider::new(|params| {
89+
Box::pin(async move {
90+
// Proxy SPT creation through your backend (requires Stripe secret key)
91+
let resp = reqwest::Client::new()
92+
.post("https://my-server.com/api/create-spt")
93+
.json(&params)
94+
.send().await?.json::<serde_json::Value>().await?;
95+
Ok(CreateTokenResult::from(resp["spt"].as_str().unwrap().to_string()))
96+
})
97+
});
98+
99+
let resp = reqwest::Client::new()
100+
.get("https://api.example.com/paid")
101+
.send_with_payment(&provider)
102+
.await?;
103+
```
104+
65105
## Feature Flags
66106

67107
| Feature | Description |
68108
|---------|-------------|
69109
| `client` | Client-side payment providers (`PaymentProvider` trait, `Fetch` extension) |
70110
| `server` | Server-side payment verification (`ChargeMethod` trait) |
71111
| `tempo` | [Tempo](https://tempo.xyz) blockchain support (includes `evm`) |
112+
| `stripe` | [Stripe](https://stripe.com) payment support via SPTs |
72113
| `evm` | Shared EVM utilities (Address, U256, parsing) |
73114
| `middleware` | reqwest-middleware support with `PaymentMiddleware` (implies `client`) |
74115
| `tower` | Tower middleware for server-side integration |
@@ -77,7 +118,7 @@ let resp = client.get("https://mpp.dev/api/ping/paid").send().await?;
77118

78119
## Payment Methods
79120

80-
MPP supports multiple [payment methods](https://mpp.dev/payment-methods/) through one protocol — [Tempo](https://mpp.dev/payment-methods/tempo/), [Stripe](https://mpp.dev/payment-methods/stripe/), [Lightning](https://mpp.dev/payment-methods/lightning/), [Card](https://mpp.dev/payment-methods/card/), and [custom methods](https://mpp.dev/payment-methods/custom). The server advertises which methods it accepts, and the client chooses which one to pay with. This SDK currently implements Tempo (charge and session intents).
121+
MPP supports multiple [payment methods](https://mpp.dev/payment-methods/) through one protocol — [Tempo](https://mpp.dev/payment-methods/tempo/), [Stripe](https://mpp.dev/payment-methods/stripe/), [Lightning](https://mpp.dev/payment-methods/lightning/), [Card](https://mpp.dev/payment-methods/card/), and [custom methods](https://mpp.dev/payment-methods/custom). The server advertises which methods it accepts, and the client chooses which one to pay with. This SDK implements Tempo (charge and session intents) and Stripe (charge intent via Shared Payment Tokens).
81122

82123
## Protocol
83124

examples/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow.
66

77
| Example | Description |
88
|---------|-------------|
9-
| [basic](./basic/) | Payment-gated Fortune Teller API |
9+
| [basic](./basic/) | Payment-gated Fortune Teller API (Tempo) |
10+
| [stripe](./stripe/) | Payment-gated Fortune Teller API (Stripe SPT) |
1011
| [axum-extractor](./axum-extractor/) | Axum extractors with per-route pricing (`MppCharge<C>`) |
1112
| [session/multi-fetch](./session/multi-fetch/) | Multiple paid requests over a single payment channel |
1213
| [session/sse](./session/sse/) | Pay-per-token LLM streaming with SSE |
@@ -16,11 +17,16 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow.
1617
Each example is a standalone Cargo crate with a server and client binary.
1718

1819
```bash
19-
# Basic example
20+
# Basic example (Tempo)
2021
cd examples/basic
2122
cargo run --bin basic-server # Terminal 1
2223
cargo run --bin basic-client # Terminal 2
2324

25+
# Stripe example
26+
cd examples/stripe
27+
STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server # Terminal 1
28+
cargo run --bin stripe-client # Terminal 2
29+
2430
# Axum extractor (per-route pricing)
2531
cd examples/axum-extractor
2632
cargo run --bin axum-server # Terminal 1

examples/stripe/Cargo.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "stripe-example"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[[bin]]
8+
name = "stripe-server"
9+
path = "src/server.rs"
10+
11+
[[bin]]
12+
name = "stripe-client"
13+
path = "src/client.rs"
14+
15+
[dependencies]
16+
mpp = { path = "../..", features = ["server", "client", "stripe"] }
17+
axum = "0.7"
18+
tokio = { version = "1", features = ["full"] }
19+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
20+
serde_json = "1"
21+
serde = { version = "1", features = ["derive"] }
22+
rand = "0.9"
23+
base64 = "0.22"

examples/stripe/README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Stripe Example
2+
3+
A pay-per-fortune API using Stripe's Shared Payment Token (SPT) flow.
4+
5+
## What This Demonstrates
6+
7+
- Server-side payment protection with `Mpp::create_stripe()` and the Stripe method
8+
- SPT proxy endpoint (secret key stays server-side)
9+
- Headless client using a test card (`pm_card_visa`)
10+
- Full 402 → challenge → credential → retry flow
11+
12+
## Prerequisites
13+
14+
- Rust 1.80+
15+
- A Stripe test-mode secret key (`sk_test_...`)
16+
17+
## Running
18+
19+
**Start the server:**
20+
21+
```bash
22+
export STRIPE_SECRET_KEY=sk_test_...
23+
cargo run --bin stripe-server
24+
```
25+
26+
The server starts at http://localhost:3000.
27+
28+
**Run the client** (in another terminal):
29+
30+
```bash
31+
cargo run --bin stripe-client
32+
# 🥠 A smooth long journey! Great expectations.
33+
# Payment receipt: pi_3Q...
34+
```
35+
36+
## Testing Manually
37+
38+
**Without payment** (returns 402):
39+
40+
```bash
41+
curl -i http://localhost:3000/api/fortune
42+
# HTTP/1.1 402 Payment Required
43+
# WWW-Authenticate: Payment ...
44+
```
45+
46+
## How It Works
47+
48+
```
49+
Client Server Stripe
50+
│ │ │
51+
│ GET /api/fortune │ │
52+
├──────────────────────────────> │ │
53+
│ │ │
54+
│ 402 + WWW-Authenticate │ │
55+
│<────────────────────────────── │ │
56+
│ │ │
57+
│ POST /api/create-spt │ │
58+
├──────────────────────────────> │ Create SPT (test helper) │
59+
│ ├─────────────────────────────> │
60+
│ spt_... │ │
61+
│<────────────────────────────── │<───────────────────────────── │
62+
│ │ │
63+
│ GET /api/fortune │ │
64+
│ Authorization: Payment <cred> │ │
65+
├──────────────────────────────> │ PaymentIntent (SPT + confirm)│
66+
│ ├─────────────────────────────> │
67+
│ │ pi_... succeeded │
68+
│ 200 + fortune + receipt │<───────────────────────────── │
69+
│<────────────────────────────── │ │
70+
```
71+
72+
1. Client requests the fortune → server returns 402 with a payment challenge
73+
2. mpp client calls `create_token` → POSTs to `/api/create-spt` → server creates SPT via Stripe
74+
3. Client retries with a credential containing the SPT
75+
4. Server creates a PaymentIntent with `shared_payment_granted_token` and `confirm=true`
76+
5. On success, returns the fortune with a receipt

examples/stripe/src/client.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! # Stripe Fortune Teller CLI Client
2+
//!
3+
//! A CLI client that fetches a fortune from the payment-gated Fortune Teller API
4+
//! using Stripe's Shared Payment Token (SPT) flow.
5+
//!
6+
//! Uses a test card (`pm_card_visa`) for headless operation — no browser needed.
7+
//!
8+
//! ## Running
9+
//!
10+
//! ```bash
11+
//! # First start the server:
12+
//! STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server
13+
//!
14+
//! # Then in another terminal:
15+
//! cargo run --bin stripe-client
16+
//!
17+
//! # Or target a different server:
18+
//! cargo run --bin stripe-client -- --server http://localhost:8000
19+
//! ```
20+
21+
use mpp::client::{Fetch, StripeProvider};
22+
use mpp::protocol::methods::stripe::CreateTokenResult;
23+
use mpp::{parse_receipt, MppError};
24+
use reqwest::Client;
25+
26+
#[tokio::main]
27+
async fn main() {
28+
let server_url = std::env::args()
29+
.skip_while(|a| a != "--server")
30+
.nth(1)
31+
.unwrap_or_else(|| "http://localhost:3000".to_string());
32+
33+
let server_base = server_url.trim_end_matches('/').to_string();
34+
let spt_url = format!("{server_base}/api/create-spt");
35+
36+
let provider = StripeProvider::new(move |params| {
37+
let spt_url = spt_url.clone();
38+
Box::pin(async move {
39+
let resp = Client::new()
40+
.post(&spt_url)
41+
.json(&serde_json::json!({
42+
"paymentMethod": "pm_card_visa",
43+
"amount": params.amount,
44+
"currency": params.currency,
45+
"expiresAt": params.expires_at,
46+
}))
47+
.send()
48+
.await
49+
.map_err(|e| MppError::Http(e.to_string()))?;
50+
51+
if !resp.status().is_success() {
52+
let body = resp.text().await.unwrap_or_default();
53+
return Err(MppError::Http(format!("SPT creation failed: {body}")));
54+
}
55+
56+
let json: serde_json::Value = resp
57+
.json()
58+
.await
59+
.map_err(|e| MppError::Http(e.to_string()))?;
60+
61+
let spt = json["spt"]
62+
.as_str()
63+
.ok_or_else(|| MppError::Http("missing spt in response".to_string()))?
64+
.to_string();
65+
66+
Ok(CreateTokenResult::from(spt))
67+
})
68+
});
69+
70+
let fortune_url = format!("{server_base}/api/fortune");
71+
println!("Fetching {fortune_url} ...");
72+
73+
let resp = Client::new()
74+
.get(&fortune_url)
75+
.send_with_payment(&provider)
76+
.await
77+
.expect("request failed");
78+
79+
println!("Status: {}", resp.status());
80+
81+
if let Some(receipt_hdr) = resp.headers().get("payment-receipt") {
82+
if let Ok(receipt_str) = receipt_hdr.to_str() {
83+
if let Ok(receipt) = parse_receipt(receipt_str) {
84+
println!("Payment receipt: {}", receipt.reference);
85+
}
86+
}
87+
}
88+
89+
let body = resp.text().await.expect("failed to read response body");
90+
91+
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
92+
if let Some(fortune) = json.get("fortune").and_then(|v| v.as_str()) {
93+
println!("\nFortune: {fortune}");
94+
} else {
95+
println!("\nResponse: {json}");
96+
}
97+
} else {
98+
println!("\nResponse: {body}");
99+
}
100+
}

0 commit comments

Comments
 (0)