diff --git a/offchain/Cargo.lock b/offchain/Cargo.lock index 94e5889..7d22f66 100644 --- a/offchain/Cargo.lock +++ b/offchain/Cargo.lock @@ -2561,6 +2561,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "payments-stripe" +version = "1.0.0" +dependencies = [ + "anyhow", + "dotenvy", + "env_logger", + "log", + "mockito", + "nexus-sdk", + "nexus-toolkit", + "reqwest 0.12.25", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "toml", + "zeroize", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" diff --git a/offchain/tools/.just b/offchain/tools/.just index 1dfa105..be4c4d7 100644 --- a/offchain/tools/.just +++ b/offchain/tools/.just @@ -14,16 +14,19 @@ _default: build: _check-cargo cargo +stable build --package math --release cargo +stable build --package llm-openai-chat-completion --release + cargo +stable build --package payments-stripe --release # Check all native Nexus Tools check: _check-cargo cargo +stable check --package math cargo +stable check --package llm-openai-chat-completion + cargo +stable check --package payments-stripe # Run all tests in all native Nexus Tools test: _check-cargo cargo +stable test --package math cargo +stable test --package llm-openai-chat-completion + cargo +stable test --package payments-stripe # Run rustfmt on all native Nexus Tools fmt-check: _check-cargo @@ -33,11 +36,13 @@ fmt-check: _check-cargo cargo +"$nightly" fmt --package math --check cargo +"$nightly" fmt --package llm-openai-chat-completion --check + cargo +"$nightly" fmt --package payments-stripe --check # Run clippy on all native Nexus Tools clippy: _check-cargo cargo +stable clippy --package math cargo +stable clippy --package llm-openai-chat-completion + cargo +stable clippy --package payments-stripe # Runs the `which` native Nexus Tool. Note that some Tools might need some # environment variables to be set. diff --git a/offchain/tools/payments-stripe/.env.example b/offchain/tools/payments-stripe/.env.example new file mode 100644 index 0000000..67748a1 --- /dev/null +++ b/offchain/tools/payments-stripe/.env.example @@ -0,0 +1,14 @@ +# payments-stripe — required environment variables. +# +# Copy to `.env` for local dev (this file MUST stay in source control; +# `.env` MUST be gitignored). In production these come from Cloud Run +# `secretKeyRef` bindings to GCP Secret Manager, configured by the +# operator out-of-band (the deploy pipeline does NOT provision the key). +# +# The tool exits 1 at startup if STRIPE_API_KEY is unset, empty, or does +# not start with sk_test_ / sk_live_ / rk_test_ / rk_live_. + +STRIPE_API_KEY=sk_test_replace_with_real_key + +# Optional. Defaults to "info". +RUST_LOG=info diff --git a/offchain/tools/payments-stripe/Cargo.toml b/offchain/tools/payments-stripe/Cargo.toml new file mode 100644 index 0000000..7b6c8e2 --- /dev/null +++ b/offchain/tools/payments-stripe/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "payments-stripe" +description = "Stripe payments tools for Nexus" + +edition.workspace = true +version.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +readme.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[[bin]] +name = "payments-stripe" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +dotenvy.workspace = true +env_logger.workspace = true +log.workspace = true +reqwest = { workspace = true, features = ["json"] } +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +zeroize.workspace = true + +# === Nexus deps === +nexus-toolkit.workspace = true +nexus-sdk.workspace = true + +[dev-dependencies] +mockito.workspace = true + +[build-dependencies] +serde_json.workspace = true +toml = "0.8" diff --git a/offchain/tools/payments-stripe/README.md b/offchain/tools/payments-stripe/README.md new file mode 100644 index 0000000..ca50090 --- /dev/null +++ b/offchain/tools/payments-stripe/README.md @@ -0,0 +1,185 @@ +# Stripe tools for Nexus + +A set of stateless Nexus Tools that wrap the [Stripe REST +API](https://stripe.com/docs/api). Each tool is a single endpoint. + +## Credential handling + +- **The tool reads `STRIPE_API_KEY` once from the process environment at + startup.** Sources: + - Production (Cloud Run): mounted via `secretKeyRef` from GCP Secret + Manager, configured by the operator out-of-band (the deploy pipeline + does NOT provision the secret). + - Local dev: copy `.env.example` to `.env` and set + `STRIPE_API_KEY=sk_test_…`. +- **The credential never appears on any `Input` struct.** Tool inputs flow + through the Nexus DAG on Sui as plaintext — anything on `Input` is + effectively published on-chain. The skill's auditor will refuse to mark + the tool ready if any `Input` field is credential-shaped. +- The credential is wrapped in `zeroize::Zeroizing` (heap-zeroed + on drop). The struct hand-implements `Debug` to print ``. +- The tool exits 1 at startup if `STRIPE_API_KEY` is unset, empty, or does + not start with one of `sk_test_`, `sk_live_`, `rk_test_`, `rk_live_`. +- **Tests use `sk_test_…` keys only.** Real `sk_live_…` material must + never enter source, fixtures, or logs. + +## Environment variables + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `STRIPE_API_KEY` | **yes** | — | Stripe secret (`sk_test_…` for staging/test, `sk_live_…` for prod). Validated at startup; the tool refuses to boot without it. | +| `RUST_LOG` | no | `info` | env_logger filter. | +| `BIND_ADDR` | no | `127.0.0.1:8080` | The toolkit's HTTP bind address. | + +## Idempotency + +Every write endpoint accepts an optional `idempotency_key`. Generate a +UUID per logical retry-bucket in your DAG. Stripe guarantees identical +responses for identical idempotency keys; reusing the same key on retry +prevents double-charges. `idempotency_key` is per-call dedup data — NOT a +credential — so it stays on `Input`. + +## FQN versioning + +All six FQNs are threaded through `env!("TOOL_FQN_VERSION")` in +`build.rs`; CI sets `TOOL_FQN_VERSION` from the tool's subtree git hash, +so any source change auto-bumps the version on the next deploy. Local +builds default to `@1`. + +--- + +# `xyz.taluslabs.payments.stripe.create-payment-intent@` + +Creates a [PaymentIntent](https://stripe.com/docs/api/payment_intents/create). + +## Input + +- **`idempotency_key`: [`String`] (optional)** — `Idempotency-Key` header value. Generate a UUID per retry-bucket. +- **`amount`: [`i64`]** — Amount in the smallest currency unit (e.g. cents for USD). +- **`currency`: [`String`]** — ISO-4217 currency code (lowercase, e.g. `usd`). +- **`customer`: [`String`] (optional)** — Existing Stripe customer id (`cus_…`). +- **`description`: [`String`] (optional)** — Free-form description shown on the Stripe Dashboard. + +## Output Variants & Ports + +**`ok`** — PaymentIntent created. + +- **`ok.id`: [`String`]** — PaymentIntent id (`pi_…`). +- **`ok.client_secret`: [`String`]** — Used by clients to confirm the intent. +- **`ok.status`: [`String`]** — Stripe lifecycle status (`requires_payment_method`, `requires_confirmation`, etc.). +- **`ok.amount`: [`i64`]** — Echo of the requested amount. +- **`ok.currency`: [`String`]** — Echo of the requested currency. + +**`err`** — Stripe rejected the request or the network failed. + +- **`err.reason`: [`String`]** — Human-readable error description. +- **`err.kind`: [`String`]** — One of `invalid_request`, `card_error`, `validation_error`, `idempotency_error`, `rate_limit_exceeded`, `unauthorized`, etc. +- **`err.status_code`: [`u16`] (optional)** — HTTP status from Stripe, if any. + +--- + +# `xyz.taluslabs.payments.stripe.get-payment-intent@` + +Retrieves a [PaymentIntent](https://stripe.com/docs/api/payment_intents/retrieve) by id. + +## Input + +- **`payment_intent_id`: [`String`]** — `pi_…` to look up. + +## Output Variants & Ports + +**`ok`** + +- **`ok.id`: [`String`]** +- **`ok.status`: [`String`]** +- **`ok.amount`: [`i64`]** +- **`ok.currency`: [`String`]** +- **`ok.client_secret`: [`String`] (optional)** — present unless the intent has been confirmed. + +**`err`** — See `create-payment-intent` for the variant shape. + +--- + +# `xyz.taluslabs.payments.stripe.confirm-payment-intent@` + +[Confirms](https://stripe.com/docs/api/payment_intents/confirm) a PaymentIntent. + +## Input + +- **`idempotency_key`: [`String`] (optional)** +- **`payment_intent_id`: [`String`]** — `pi_…` to confirm. +- **`payment_method`: [`String`] (optional)** — `pm_…` to attach (e.g. `pm_card_visa` in test mode). +- **`return_url`: [`String`] (optional)** — Required for redirect-based payment methods. + +## Output Variants & Ports + +**`ok`** + +- **`ok.id`: [`String`]** +- **`ok.status`: [`String`]** +- **`ok.next_action_type`: [`String`] (optional)** — Set when the PaymentIntent requires further user action. + +**`err`** — See above. + +--- + +# `xyz.taluslabs.payments.stripe.create-customer@` + +Creates a [Customer](https://stripe.com/docs/api/customers/create). + +## Input + +- **`idempotency_key`: [`String`] (optional)** +- **`email`: [`String`] (optional)** +- **`name`: [`String`] (optional)** +- **`description`: [`String`] (optional)** + +## Output Variants & Ports + +**`ok`** + +- **`ok.id`: [`String`]** — `cus_…`. +- **`ok.email`: [`String`] (optional)** +- **`ok.created`: [`i64`]** — Unix timestamp. + +**`err`** — See above. + +--- + +# `xyz.taluslabs.payments.stripe.get-balance@` + +Reads the platform [Balance](https://stripe.com/docs/api/balance/balance_retrieve). + +## Input + +No input ports — credentials come from env. + +## Output Variants & Ports + +**`ok`** + +- **`ok.available`: [`Vec<{ amount: i64, currency: String }>`]** — Funds available for payout. +- **`ok.pending`: [`Vec<{ amount: i64, currency: String }>`]** — Funds still settling. + +**`err`** — See above. + +--- + +# `xyz.taluslabs.payments.stripe.list-charges@` + +Lists [Charges](https://stripe.com/docs/api/charges/list). + +## Input + +- **`limit`: [`i64`] (optional)** — Page size (1–100, Stripe default 10). +- **`customer`: [`String`] (optional)** — Filter to a specific customer id. +- **`starting_after`: [`String`] (optional)** — Cursor for pagination (charge id from the previous page). + +## Output Variants & Ports + +**`ok`** + +- **`ok.charges`: [`Vec<{ id, amount, currency, status, customer? }>`]** +- **`ok.has_more`: [`bool`]** — More results available with `starting_after = charges.last().id`. + +**`err`** — See above. diff --git a/offchain/tools/payments-stripe/build.rs b/offchain/tools/payments-stripe/build.rs new file mode 100644 index 0000000..07c24d8 --- /dev/null +++ b/offchain/tools/payments-stripe/build.rs @@ -0,0 +1,50 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let tools_json_path = manifest_dir.join("tools.json"); + let cargo_toml_path = manifest_dir.join("Cargo.toml"); + + println!("cargo::rerun-if-changed={}", tools_json_path.display()); + println!("cargo::rerun-if-changed={}", cargo_toml_path.display()); + + let tools_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(&tools_json_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", tools_json_path.display())), + ) + .expect("tools.json must be valid JSON"); + + let command = tools_json["command"] + .as_str() + .expect("tools.json must have command"); + + let cargo_toml: toml::Value = toml::from_str( + &fs::read_to_string(&cargo_toml_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", cargo_toml_path.display())), + ) + .expect("Cargo.toml must be valid TOML"); + + let bin_name = cargo_toml + .get("bin") + .and_then(|b| b.as_array()) + .and_then(|bins| bins.first()) + .and_then(|b| b.get("name")) + .and_then(|n| n.as_str()); + + match bin_name { + Some(name) if name == command => {} + Some(name) => panic!( + "Binary name mismatch: Cargo.toml [[bin]] name = \"{name}\" \ + but tools.json command = \"{command}\". They must match." + ), + None => panic!( + "Cargo.toml has no [[bin]] section but tools.json specifies \ + command = \"{command}\". Add: [[bin]]\nname = \"{command}\"" + ), + } + + // FQN version is set by CI via Docker build-arg; defaults to "1" locally. + let version = env::var("TOOL_FQN_VERSION").unwrap_or_else(|_| "1".to_string()); + println!("cargo:rustc-env=TOOL_FQN_VERSION={version}"); + println!("cargo:rerun-if-env-changed=TOOL_FQN_VERSION"); +} diff --git a/offchain/tools/payments-stripe/src/error.rs b/offchain/tools/payments-stripe/src/error.rs new file mode 100644 index 0000000..a83b1a2 --- /dev/null +++ b/offchain/tools/payments-stripe/src/error.rs @@ -0,0 +1,155 @@ +//! Stripe-specific error envelope + kind mapping. +//! +//! Stripe returns errors as `{"error":{"type","code","message","param"}}`. +//! We map `type` to a typed kind enum so DAG authors can branch on the +//! failure class without parsing `reason`. + +use { + schemars::JsonSchema, + serde::{Deserialize, Serialize}, + thiserror::Error, +}; + +/// Machine-readable error kinds for Stripe operations. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum StripeErrorKind { + /// Stripe `invalid_request_error` — malformed request. + InvalidRequest, + /// Stripe `card_error` — card was declined. + CardError, + /// Stripe `validation_error` — request parameters invalid. + ValidationError, + /// Stripe `idempotency_error` — idempotency key reused with different params. + IdempotencyError, + /// Stripe `authentication_error` — missing or wrong API key. + Unauthorized, + /// HTTP 402 payment required. + PaymentRequired, + /// HTTP 403. + Forbidden, + /// Resource not found. + NotFound, + /// HTTP 408 / network timeout. + TimedOut, + /// HTTP 409 — conflict. + Conflict, + /// Stripe `rate_limit_error` / HTTP 429. + RateLimitExceeded, + /// Stripe `api_error` / HTTP 5xx. + InternalServerError, + /// HTTP 502 bad gateway. + BadGateway, + /// HTTP 503 service unavailable. + ServiceUnavailable, + /// Connection-level failure. + NetworkConnectionFailed, + /// Failed to parse the upstream response. + Parse, + /// Anything we don't have a typed mapping for. + Unknown, +} + +/// Public error surface returned to the DAG via `Output::Err`. +#[derive(Debug, Serialize, Deserialize)] +pub struct StripeErrorResponse { + pub reason: String, + pub kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_code: Option, +} + +#[derive(Error, Debug)] +#[allow(dead_code)] +pub enum StripeError { + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + #[error("Response parsing error: {0}")] + ParseError(#[from] serde_json::Error), + #[error("Stripe API error: {0}")] + ApiError(String), +} + +impl StripeErrorKind { + pub fn from_status_code(status_code: u16) -> Self { + match status_code { + 400 => Self::InvalidRequest, + 401 => Self::Unauthorized, + 402 => Self::PaymentRequired, + 403 => Self::Forbidden, + 404 => Self::NotFound, + 408 => Self::TimedOut, + 409 => Self::Conflict, + 429 => Self::RateLimitExceeded, + 500 => Self::InternalServerError, + 502 => Self::BadGateway, + 503 => Self::ServiceUnavailable, + 504 => Self::TimedOut, + _ => Self::Unknown, + } + } + + pub fn from_network_error(error: &reqwest::Error) -> Self { + if error.is_timeout() { + Self::TimedOut + } else { + // is_connect / is_request / anything else all map here. + Self::NetworkConnectionFailed + } + } + + /// Map Stripe's `error.type` to our kind. Cover every documented + /// Stripe error class (audit check H10). + pub fn from_api_error_type(error_type: &str) -> Self { + match error_type { + "invalid_request_error" => Self::InvalidRequest, + "card_error" => Self::CardError, + "validation_error" => Self::ValidationError, + "idempotency_error" => Self::IdempotencyError, + "rate_limit_error" => Self::RateLimitExceeded, + "authentication_error" => Self::Unauthorized, + "api_error" | "api_connection_error" => Self::InternalServerError, + _ => Self::Unknown, + } + } +} + +#[derive(Debug, Deserialize)] +struct StripeEnvelope { + error: StripeErrorBody, +} + +#[derive(Debug, Deserialize)] +struct StripeErrorBody { + #[serde(rename = "type")] + error_type: Option, + code: Option, + message: Option, + param: Option, +} + +/// Best-effort parse of a non-2xx response body into a typed error. +/// Returns `None` if the body doesn't match Stripe's envelope; the +/// caller falls back to status-code-only mapping. +pub fn try_parse_api_error(body: &str, status_code: u16) -> Option { + let env: StripeEnvelope = serde_json::from_str(body).ok()?; + let kind = env + .error + .error_type + .as_deref() + .map(StripeErrorKind::from_api_error_type) + .unwrap_or_else(|| StripeErrorKind::from_status_code(status_code)); + + let reason = match (env.error.message, env.error.code, env.error.param) { + (Some(m), _, Some(p)) => format!("{} (param: {})", m, p), + (Some(m), _, None) => m, + (None, Some(c), _) => format!("Stripe error code: {}", c), + _ => format!("Stripe API error ({})", status_code), + }; + + Some(StripeErrorResponse { + reason, + kind, + status_code: Some(status_code), + }) +} diff --git a/offchain/tools/payments-stripe/src/main.rs b/offchain/tools/payments-stripe/src/main.rs new file mode 100644 index 0000000..708896d --- /dev/null +++ b/offchain/tools/payments-stripe/src/main.rs @@ -0,0 +1,49 @@ +#![doc = include_str!("../README.md")] +#![allow(clippy::large_enum_variant)] + +use nexus_toolkit::bootstrap; + +mod error; +mod stripe_client; +mod tools; + +fn main() { + // Install env_logger before anything else so dotenv/credential paths + // emit through `log::*`; `bootstrap!`'s own `try_init()` becomes a no-op. + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + + // `--meta` is an introspection mode that emits the tool's FQN/URL/ + // schema list without standing up the runtime. CI's prepare step + // invokes it from a fresh image with no env, so it must not require + // runtime credentials. `nexus_toolkit::bootstrap!` handles --meta + // and exits before any HTTP call is made. + let meta_only = std::env::args().any(|a| a == "--meta"); + + if !meta_only { + // dotenv + credential validation run single-threaded — `set_var` + // is unsound from a multi-threaded process. `main` is the only + // exit site. + stripe_client::load_dotenv_if_present(); + if let Err(reason) = stripe_client::validate_credentials_at_startup() { + log::error!("{} {reason}", stripe_client::ENV_API_KEY); + std::process::exit(1); + } + } + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime") + .block_on(async { + bootstrap!([ + tools::create_payment_intent::CreatePaymentIntent, + tools::get_payment_intent::GetPaymentIntent, + tools::confirm_payment_intent::ConfirmPaymentIntent, + tools::create_customer::CreateCustomer, + tools::get_balance::GetBalance, + tools::list_charges::ListCharges, + ]) + }); +} diff --git a/offchain/tools/payments-stripe/src/stripe_client.rs b/offchain/tools/payments-stripe/src/stripe_client.rs new file mode 100644 index 0000000..e490f8f --- /dev/null +++ b/offchain/tools/payments-stripe/src/stripe_client.rs @@ -0,0 +1,326 @@ +//! Stateless Stripe HTTP client. +//! +//! Credential model (audit checks C1, C7, C8, C9, C10): +//! +//! - `STRIPE_API_KEY` is read once at startup via [`from_env`]. The +//! bearer is wrapped in [`zeroize::Zeroizing`] so the heap +//! buffer is wiped on drop. The struct hand-implements `Debug` to +//! print `` — never `#[derive(Debug)]`. +//! - The credential never appears on the `Input` struct of any tool — +//! tool inputs flow through the Nexus DAG on Sui as plaintext. +//! - The struct's only mutable per-request field is the optional +//! `Idempotency-Key` (Stripe-style writes); cheap to clone, no +//! shared state. +//! - In Cloud Run the env var is mounted by Secret Manager via a +//! `secretKeyRef` binding configured by the operator (out-of-band). +//! The deploy pipeline does NOT provision the upstream API key. +//! +//! Canonical reference: `offchain/tools/memory-memwal/src/client.rs`. + +use { + crate::{ + error::{try_parse_api_error, StripeErrorKind, StripeErrorResponse}, + tools::STRIPE_API_BASE, + }, + reqwest::{Client, RequestBuilder}, + serde::{de::DeserializeOwned, Serialize}, + std::sync::{Arc, Once, OnceLock}, + zeroize::Zeroizing, +}; + +/// Env var holding the Stripe secret key (`sk_test_…` or `sk_live_…`). +/// Mounted in Cloud Run via `secretKeyRef`; configured by the operator +/// out-of-band, not by the deploy pipeline. +pub(crate) const ENV_API_KEY: &str = "STRIPE_API_KEY"; + +/// One-shot guard so the "missing key" warning fires once even though +/// `from_env` runs once per registered tool (six tools = six `new()` calls). +static WARN_MISSING_KEY: Once = Once::new(); + +/// Process-wide HTTP client shared across every `StripeClient` so the +/// connection pool, TLS session cache, and HTTP/2 multiplexing survive +/// across `invoke` calls. `reqwest::Client` clone is a cheap Arc bump. +static SHARED_HTTP: OnceLock = OnceLock::new(); + +fn shared_http() -> Client { + SHARED_HTTP + .get_or_init(|| { + Client::builder() + .user_agent("nexus-sdk-payments-stripe/1.0") + .build() + .expect("Failed to create HTTP client") + }) + .clone() +} + +/// Load `/.env` if present. Cwd-only (no parent walk) so a planted +/// `.env` above the binary's cwd cannot influence the process. Existing +/// exports always win. MUST be called before the tokio runtime is built — +/// `set_var` is unsound from a multi-threaded process. +pub(crate) fn load_dotenv_if_present() { + let candidate = match std::env::current_dir() { + Ok(d) => d.join(".env"), + Err(e) => { + log::warn!("could not read cwd while looking for .env: {e}"); + return; + } + }; + if !candidate.is_file() { + return; + } + match dotenvy::from_path(&candidate) { + Ok(()) => log::info!("loaded env vars from {}", candidate.display()), + Err(e) => log::warn!("failed to load {}: {e}", candidate.display()), + } +} + +/// Classification of the Stripe key env var. Hand-written `Debug` redacts +/// the secret — never `derive` Debug on this type. +enum KeyValidation { + Ok(Zeroizing), + Missing, + Invalid(String), +} + +impl std::fmt::Debug for KeyValidation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ok(_) => write!(f, "Ok()"), + Self::Missing => write!(f, "Missing"), + Self::Invalid(reason) => f.debug_tuple("Invalid").field(reason).finish(), + } + } +} + +/// Pure classifier — no I/O, no exits, deterministic. +fn classify_key(value: Option<&str>) -> KeyValidation { + let raw = match value { + Some(s) if !s.is_empty() => s, + _ => return KeyValidation::Missing, + }; + // Stripe secret keys are prefixed `sk_test_` (test mode) or + // `sk_live_` (production). Restricted keys use `rk_test_` / `rk_live_`. + // Reject anything else as a typo / wrong env var. + let valid_prefix = ["sk_test_", "sk_live_", "rk_test_", "rk_live_"] + .iter() + .any(|p| raw.starts_with(p)); + if !valid_prefix { + return KeyValidation::Invalid( + "is set but does not start with a recognized Stripe prefix \ + (sk_test_, sk_live_, rk_test_, rk_live_)." + .to_string(), + ); + } + if raw.len() < 32 { + return KeyValidation::Invalid(format!( + "is set but is shorter than expected ({} chars). Real Stripe \ + keys are ≥32 chars after the prefix.", + raw.len() + )); + } + KeyValidation::Ok(Zeroizing::new(raw.to_string())) +} + +/// Eager startup-time key validation. Without this, a malformed key would +/// only surface on the first `/invoke` (well after `server-start` reports +/// "Ready"). `main` is the only `process::exit` site. +pub(crate) fn validate_credentials_at_startup() -> Result<(), String> { + read_validated_api_key().map(|_| ()) +} + +/// `Ok(Some(key))` valid; `Ok(None)` unset (warn-and-continue); `Err(reason)` +/// malformed. `main` exits on `Err`; `from_env` downgrades it to a log so a +/// mid-process key rotation doesn't kill in-flight requests. +fn read_validated_api_key() -> Result>, String> { + // `env::var` returns a fresh `String` — wrap it immediately so the + // intermediate copy is also zeroed on drop. + let raw = std::env::var(ENV_API_KEY).ok().map(Zeroizing::new); + match classify_key(raw.as_deref().map(|z| z.as_str())) { + KeyValidation::Ok(k) => Ok(Some(k)), + KeyValidation::Missing => { + WARN_MISSING_KEY.call_once(|| { + log::warn!( + "{ENV_API_KEY} is not set — every invoke will fail \ + upstream with 401 until this env var is exported in \ + the process environment." + ); + }); + Ok(None) + } + KeyValidation::Invalid(reason) => Err(reason), + } +} + +#[derive(Clone)] +pub struct StripeClient { + client: Arc, + base_url: String, + /// Stripe secret key read once at startup from `ENV_API_KEY`. Wrapped + /// in `Zeroizing` so heap buffers are wiped on drop. `None` if the + /// env var was missing or malformed — `invoke()` calls will fail + /// upstream with 401, which is the right signal to the operator. + bearer: Option>>, + /// Per-request idempotency key (Stripe-style writes). Set via + /// `.with_idempotency(...)` on the cheap-cloned client. + idempotency_key: Option, +} + +// Hand-written Debug — NEVER derive on a type holding a credential. +impl std::fmt::Debug for StripeClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StripeClient") + .field("base_url", &self.base_url) + .field( + "bearer", + &if self.bearer.is_some() { + "" + } else { + "" + }, + ) + .field("idempotency_key", &self.idempotency_key) + .finish() + } +} + +impl StripeClient { + /// Production constructor: reads `STRIPE_API_KEY` from the process + /// env and wraps it in `Zeroizing`. A missing/malformed key logs but + /// does not error here — `main`'s startup validation is the + /// fail-fast site. + pub fn from_env() -> Result { + let bearer = match read_validated_api_key() { + Ok(Some(k)) => Some(Arc::new(k)), + Ok(None) => None, + Err(reason) => { + log::error!("{ENV_API_KEY} {reason}"); + None + } + }; + Ok(Self { + client: Arc::new(shared_http()), + base_url: STRIPE_API_BASE.to_string(), + bearer, + idempotency_key: None, + }) + } + + /// Test-only constructor. Accepts a base URL (HTTP loopback for + /// mockito) and a bearer string directly, bypassing env validation. + #[cfg(test)] + pub(crate) fn for_testing(base_url: &str, bearer: &str) -> Self { + Self { + client: Arc::new( + Client::builder() + .user_agent("nexus-sdk-payments-stripe/1.0") + .build() + .expect("failed to build test HTTP client"), + ), + base_url: base_url.to_string(), + bearer: Some(Arc::new(Zeroizing::new(bearer.to_string()))), + idempotency_key: None, + } + } + + /// Attach an `Idempotency-Key` header for the next request. Required + /// by Stripe writes; harmless on reads. + #[must_use] + pub fn with_idempotency(mut self, key: &str) -> Self { + self.idempotency_key = Some(key.to_string()); + self + } + + pub async fn get(&self, endpoint: &str) -> Result + where + T: DeserializeOwned, + { + let req = self.apply_headers(self.client.get(self.url(endpoint))); + self.send(req).await + } + + /// Stripe uses `application/x-www-form-urlencoded` for write bodies, + /// not JSON. Pass a `serde_urlencoded`-compatible value. + pub async fn post_form(&self, endpoint: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let req = self.apply_headers(self.client.post(self.url(endpoint)).form(body)); + self.send(req).await + } + + fn url(&self, endpoint: &str) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + endpoint.trim_start_matches('/') + ) + } + + fn apply_headers(&self, mut req: RequestBuilder) -> RequestBuilder { + if let Some(ref bearer) = self.bearer { + req = req.bearer_auth(bearer.as_str()); + } + if let Some(ref key) = self.idempotency_key { + req = req.header("Idempotency-Key", key); + } + req + } + + async fn send(&self, req: RequestBuilder) -> Result + where + T: DeserializeOwned, + { + let response = match req.send().await { + Ok(r) => r, + Err(e) => { + return Err(StripeErrorResponse { + reason: format!("Network error: {}", e), + kind: StripeErrorKind::from_network_error(&e), + status_code: Some(0), + }); + } + }; + + let status = response.status(); + let text = match response.text().await { + Ok(t) => t, + Err(e) => { + return Err(StripeErrorResponse { + reason: format!("Failed to read response: {}", e), + kind: StripeErrorKind::Parse, + status_code: None, + }); + } + }; + + if !status.is_success() { + if let Some(parsed) = try_parse_api_error(&text, status.as_u16()) { + return Err(parsed); + } + return Err(StripeErrorResponse { + reason: format!("Stripe API error ({}): {}", status, truncate(&text, 512)), + kind: StripeErrorKind::from_status_code(status.as_u16()), + status_code: Some(status.as_u16()), + }); + } + + serde_json::from_str::(&text).map_err(|e| StripeErrorResponse { + reason: format!("Failed to parse JSON: {}", e), + kind: StripeErrorKind::Parse, + status_code: None, + }) + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} diff --git a/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs new file mode 100644 index 0000000..dc8f7c4 --- /dev/null +++ b/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs @@ -0,0 +1,218 @@ +//! # `xyz.taluslabs.payments.stripe.confirm-payment-intent@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub idempotency_key: Option, + pub payment_intent_id: String, + pub payment_method: Option, + pub return_url: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + id: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + next_action_type: Option, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct ConfirmPaymentIntent { + client: StripeClient, +} + +#[derive(Deserialize)] +struct ConfirmResponse { + id: String, + status: String, + next_action: Option, +} + +#[derive(Deserialize)] +struct NextAction { + #[serde(rename = "type")] + action_type: String, +} + +impl NexusTool for ConfirmPaymentIntent { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), + } + } + + fn fqn() -> ToolFqn { + fqn!(concat!( + "xyz.taluslabs.payments.stripe.confirm-payment-intent@", + env!("TOOL_FQN_VERSION") + )) + } + + fn path() -> &'static str { + "/confirm-payment-intent" + } + + fn description() -> &'static str { + "Confirms a Stripe PaymentIntent." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + if input.payment_intent_id.trim().is_empty() { + return Output::Err { + reason: "payment_intent_id must not be empty".to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + + let mut form: Vec<(&str, String)> = Vec::new(); + if let Some(pm) = &input.payment_method { + form.push(("payment_method", pm.clone())); + } + if let Some(ru) = &input.return_url { + form.push(("return_url", ru.clone())); + } + + let endpoint = format!("v1/payment_intents/{}/confirm", input.payment_intent_id); + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; + + match client + .post_form::(&endpoint, &form) + .await + { + Ok(r) => Output::Ok { + id: r.id, + status: r.status, + next_action_type: r.next_action.map(|na| na.action_type), + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, ConfirmPaymentIntent) { + let server = Server::new_async().await; + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); + (server, ConfirmPaymentIntent { client }) + } + + fn test_input() -> Input { + Input { + idempotency_key: None, + payment_intent_id: "pi_test_123".to_string(), + payment_method: Some("pm_card_visa".to_string()), + return_url: None, + } + } + + #[tokio::test] + async fn test_confirm_success_no_next_action() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("POST", "/v1/payment_intents/pi_test_123/confirm") + .with_status(200) + .with_body( + json!({ + "id": "pi_test_123", + "status": "succeeded", + "next_action": null + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + match result { + Output::Ok { + status, + next_action_type, + .. + } => { + assert_eq!(status, "succeeded"); + assert_eq!(next_action_type, None); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } + + #[tokio::test] + async fn test_confirm_requires_action() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("POST", "/v1/payment_intents/pi_test_123/confirm") + .with_status(200) + .with_body( + json!({ + "id": "pi_test_123", + "status": "requires_action", + "next_action": { "type": "redirect_to_url" } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + match result { + Output::Ok { + next_action_type, .. + } => { + assert_eq!(next_action_type.as_deref(), Some("redirect_to_url")); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } + + #[tokio::test] + async fn test_confirm_empty_id() { + let (_, tool) = create_server_and_tool().await; + let mut input = test_input(); + input.payment_intent_id = "".to_string(); + let result = tool.invoke(input).await; + assert!(matches!(result, Output::Err { .. })); + } +} diff --git a/offchain/tools/payments-stripe/src/tools/create_customer.rs b/offchain/tools/payments-stripe/src/tools/create_customer.rs new file mode 100644 index 0000000..251fce1 --- /dev/null +++ b/offchain/tools/payments-stripe/src/tools/create_customer.rs @@ -0,0 +1,199 @@ +//! # `xyz.taluslabs.payments.stripe.create-customer@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub idempotency_key: Option, + pub email: Option, + pub name: Option, + pub description: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, + created: i64, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct CreateCustomer { + client: StripeClient, +} + +#[derive(Deserialize)] +struct CustomerResponse { + id: String, + email: Option, + created: i64, +} + +impl NexusTool for CreateCustomer { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), + } + } + + fn fqn() -> ToolFqn { + fqn!(concat!( + "xyz.taluslabs.payments.stripe.create-customer@", + env!("TOOL_FQN_VERSION") + )) + } + + fn path() -> &'static str { + "/create-customer" + } + + fn description() -> &'static str { + "Creates a Stripe Customer." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + let mut form: Vec<(&str, String)> = Vec::new(); + if let Some(e) = &input.email { + form.push(("email", e.clone())); + } + if let Some(n) = &input.name { + form.push(("name", n.clone())); + } + if let Some(d) = &input.description { + form.push(("description", d.clone())); + } + + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; + + match client + .post_form::("v1/customers", &form) + .await + { + Ok(c) => Output::Ok { + id: c.id, + email: c.email, + created: c.created, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, CreateCustomer) { + let server = Server::new_async().await; + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); + (server, CreateCustomer { client }) + } + + #[tokio::test] + async fn test_create_customer_success() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("POST", "/v1/customers") + .with_status(200) + .with_body( + json!({ + "id": "cus_test_abc", + "email": "test@example.com", + "created": 1700000000 + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + idempotency_key: None, + email: Some("test@example.com".to_string()), + name: Some("Test User".to_string()), + description: None, + }) + .await; + match result { + Output::Ok { id, email, created } => { + assert_eq!(id, "cus_test_abc"); + assert_eq!(email.as_deref(), Some("test@example.com")); + assert_eq!(created, 1700000000); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } + + #[tokio::test] + async fn test_create_customer_auth_error() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("POST", "/v1/customers") + .with_status(401) + .with_body( + json!({ + "error": { + "type": "authentication_error", + "message": "Invalid API Key provided" + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + idempotency_key: None, + email: Some("test@example.com".to_string()), + name: None, + description: None, + }) + .await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::Unauthorized, + .. + } + )); + } +} diff --git a/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs new file mode 100644 index 0000000..c5ef901 --- /dev/null +++ b/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs @@ -0,0 +1,340 @@ +//! # `xyz.taluslabs.payments.stripe.create-payment-intent@` +//! +//! Creates a Stripe PaymentIntent. +//! +//! Credentials come from the `STRIPE_API_KEY` env var at startup (see +//! `src/stripe_client.rs::from_env`). They are NEVER fields on `Input` — +//! tool inputs flow through the Nexus DAG on Sui as plaintext. + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + /// Optional Idempotency-Key for safe retry. + pub idempotency_key: Option, + /// Amount in the smallest currency unit (cents for USD). + pub amount: i64, + /// ISO-4217 lowercase currency code. + pub currency: String, + /// Existing Stripe customer id (`cus_…`). + pub customer: Option, + /// Free-form description shown on the dashboard. + pub description: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + id: String, + client_secret: String, + status: String, + amount: i64, + currency: String, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct CreatePaymentIntent { + client: StripeClient, +} + +#[derive(Deserialize)] +struct StripePaymentIntent { + id: String, + client_secret: String, + status: String, + amount: i64, + currency: String, +} + +impl NexusTool for CreatePaymentIntent { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), + } + } + + fn fqn() -> ToolFqn { + fqn!(concat!( + "xyz.taluslabs.payments.stripe.create-payment-intent@", + env!("TOOL_FQN_VERSION") + )) + } + + fn path() -> &'static str { + "/create-payment-intent" + } + + fn description() -> &'static str { + "Creates a Stripe PaymentIntent." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + if input.amount <= 0 { + return Output::Err { + reason: "amount must be positive (and in smallest currency unit, e.g. cents)" + .to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + if input.currency.trim().is_empty() { + return Output::Err { + reason: "currency must be a non-empty ISO-4217 code".to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + + let mut form: Vec<(&str, String)> = vec![ + ("amount", input.amount.to_string()), + ("currency", input.currency.to_lowercase()), + ]; + if let Some(c) = &input.customer { + form.push(("customer", c.clone())); + } + if let Some(d) = &input.description { + form.push(("description", d.clone())); + } + + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; + + match client + .post_form::("v1/payment_intents", &form) + .await + { + Ok(pi) => Output::Ok { + id: pi.id, + client_secret: pi.client_secret, + status: pi.status, + amount: pi.amount, + currency: pi.currency, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, CreatePaymentIntent) { + let server = Server::new_async().await; + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE_FOR_TESTS_ONLY"); + (server, CreatePaymentIntent { client }) + } + + fn test_input() -> Input { + Input { + idempotency_key: Some("test-idempotency-key-001".to_string()), + amount: 2000, + currency: "usd".to_string(), + customer: None, + description: Some("Test payment".to_string()), + } + } + + #[tokio::test] + async fn test_create_payment_intent_success() { + let (mut server, tool) = create_server_and_tool().await; + + let mock = server + .mock("POST", "/v1/payment_intents") + .match_header("authorization", "Bearer sk_test_FAKE_FOR_TESTS_ONLY") + .match_header("idempotency-key", "test-idempotency-key-001") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "pi_test_123", + "client_secret": "pi_test_123_secret_abc", + "status": "requires_payment_method", + "amount": 2000, + "currency": "usd" + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + match result { + Output::Ok { + id, + client_secret, + status, + amount, + currency, + } => { + assert_eq!(id, "pi_test_123"); + assert_eq!(client_secret, "pi_test_123_secret_abc"); + assert_eq!(status, "requires_payment_method"); + assert_eq!(amount, 2000); + assert_eq!(currency, "usd"); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + mock.assert_async().await; + } + + #[tokio::test] + async fn test_create_payment_intent_card_error() { + let (mut server, tool) = create_server_and_tool().await; + + let _mock = server + .mock("POST", "/v1/payment_intents") + .with_status(402) + .with_header("content-type", "application/json") + .with_body( + json!({ + "error": { + "type": "card_error", + "code": "card_declined", + "message": "Your card was declined.", + "param": "card" + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + match result { + Output::Err { + reason, + kind, + status_code, + } => { + assert_eq!(kind, StripeErrorKind::CardError); + assert!(reason.contains("Your card was declined")); + assert_eq!(status_code, Some(402)); + } + Output::Ok { .. } => panic!("expected Err, got Ok"), + } + } + + #[tokio::test] + async fn test_create_payment_intent_idempotency_error() { + let (mut server, tool) = create_server_and_tool().await; + + let _mock = server + .mock("POST", "/v1/payment_intents") + .with_status(400) + .with_header("content-type", "application/json") + .with_body( + json!({ + "error": { + "type": "idempotency_error", + "message": "Keys for idempotent requests can only be used with the same parameters they were first used with." + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::IdempotencyError, + .. + } + )); + } + + #[tokio::test] + async fn test_create_payment_intent_validation_zero_amount() { + let (_, tool) = create_server_and_tool().await; + let mut input = test_input(); + input.amount = 0; + let result = tool.invoke(input).await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::InvalidRequest, + .. + } + )); + } + + #[tokio::test] + async fn test_create_payment_intent_validation_empty_currency() { + let (_, tool) = create_server_and_tool().await; + let mut input = test_input(); + input.currency = "".to_string(); + let result = tool.invoke(input).await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::InvalidRequest, + .. + } + )); + } + + #[tokio::test] + async fn test_create_payment_intent_rate_limit() { + let (mut server, tool) = create_server_and_tool().await; + + let _mock = server + .mock("POST", "/v1/payment_intents") + .with_status(429) + .with_header("content-type", "application/json") + .with_body( + json!({ + "error": { + "type": "rate_limit_error", + "message": "Too many requests" + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::RateLimitExceeded, + .. + } + )); + } +} diff --git a/offchain/tools/payments-stripe/src/tools/get_balance.rs b/offchain/tools/payments-stripe/src/tools/get_balance.rs new file mode 100644 index 0000000..5b9077d --- /dev/null +++ b/offchain/tools/payments-stripe/src/tools/get_balance.rs @@ -0,0 +1,128 @@ +//! # `xyz.taluslabs.payments.stripe.get-balance@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient, tools::models::BalanceAmount}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input {} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + available: Vec, + pending: Vec, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct GetBalance { + client: StripeClient, +} + +#[derive(Deserialize)] +struct BalanceResponse { + available: Vec, + pending: Vec, +} + +impl NexusTool for GetBalance { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), + } + } + + fn fqn() -> ToolFqn { + fqn!(concat!( + "xyz.taluslabs.payments.stripe.get-balance@", + env!("TOOL_FQN_VERSION") + )) + } + + fn path() -> &'static str { + "/get-balance" + } + + fn description() -> &'static str { + "Retrieves the Stripe platform balance." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, _input: Self::Input) -> Self::Output { + match self.client.get::("v1/balance").await { + Ok(b) => Output::Ok { + available: b.available, + pending: b.pending, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, GetBalance) { + let server = Server::new_async().await; + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); + (server, GetBalance { client }) + } + + #[tokio::test] + async fn test_get_balance_success() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("GET", "/v1/balance") + .with_status(200) + .with_body( + json!({ + "available": [{ "amount": 1000, "currency": "usd" }], + "pending": [{ "amount": 500, "currency": "usd" }] + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(Input {}).await; + match result { + Output::Ok { available, pending } => { + assert_eq!(available.len(), 1); + assert_eq!(available[0].amount, 1000); + assert_eq!(pending[0].amount, 500); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } +} diff --git a/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs new file mode 100644 index 0000000..3dac6bc --- /dev/null +++ b/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs @@ -0,0 +1,201 @@ +//! # `xyz.taluslabs.payments.stripe.get-payment-intent@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub payment_intent_id: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + id: String, + status: String, + amount: i64, + currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + client_secret: Option, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct GetPaymentIntent { + client: StripeClient, +} + +#[derive(Deserialize)] +struct StripePaymentIntent { + id: String, + status: String, + amount: i64, + currency: String, + client_secret: Option, +} + +impl NexusTool for GetPaymentIntent { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), + } + } + + fn fqn() -> ToolFqn { + fqn!(concat!( + "xyz.taluslabs.payments.stripe.get-payment-intent@", + env!("TOOL_FQN_VERSION") + )) + } + + fn path() -> &'static str { + "/get-payment-intent" + } + + fn description() -> &'static str { + "Retrieves a Stripe PaymentIntent by id." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + if input.payment_intent_id.trim().is_empty() { + return Output::Err { + reason: "payment_intent_id must not be empty".to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + + let endpoint = format!("v1/payment_intents/{}", input.payment_intent_id); + + match self.client.get::(&endpoint).await { + Ok(pi) => Output::Ok { + id: pi.id, + status: pi.status, + amount: pi.amount, + currency: pi.currency, + client_secret: pi.client_secret, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, GetPaymentIntent) { + let server = Server::new_async().await; + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); + (server, GetPaymentIntent { client }) + } + + #[tokio::test] + async fn test_get_payment_intent_success() { + let (mut server, tool) = create_server_and_tool().await; + let mock = server + .mock("GET", "/v1/payment_intents/pi_test_123") + .match_header("authorization", "Bearer sk_test_FAKE") + .with_status(200) + .with_body( + json!({ + "id": "pi_test_123", + "status": "succeeded", + "amount": 1500, + "currency": "usd", + "client_secret": null + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + payment_intent_id: "pi_test_123".to_string(), + }) + .await; + match result { + Output::Ok { id, status, .. } => { + assert_eq!(id, "pi_test_123"); + assert_eq!(status, "succeeded"); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + mock.assert_async().await; + } + + #[tokio::test] + async fn test_get_payment_intent_not_found() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("GET", "/v1/payment_intents/pi_missing") + .with_status(404) + .with_body( + json!({ + "error": { + "type": "invalid_request_error", + "message": "No such payment_intent: pi_missing" + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + payment_intent_id: "pi_missing".to_string(), + }) + .await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::InvalidRequest, + .. + } + )); + } + + #[tokio::test] + async fn test_get_payment_intent_empty_id() { + let (_, tool) = create_server_and_tool().await; + let result = tool + .invoke(Input { + payment_intent_id: "".to_string(), + }) + .await; + assert!(matches!(result, Output::Err { .. })); + } +} diff --git a/offchain/tools/payments-stripe/src/tools/list_charges.rs b/offchain/tools/payments-stripe/src/tools/list_charges.rs new file mode 100644 index 0000000..5323017 --- /dev/null +++ b/offchain/tools/payments-stripe/src/tools/list_charges.rs @@ -0,0 +1,211 @@ +//! # `xyz.taluslabs.payments.stripe.list-charges@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient, tools::models::ChargeSummary}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub limit: Option, + pub customer: Option, + pub starting_after: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + charges: Vec, + has_more: bool, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct ListCharges { + client: StripeClient, +} + +#[derive(Deserialize)] +struct ListResponse { + data: Vec, + has_more: bool, +} + +impl NexusTool for ListCharges { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), + } + } + + fn fqn() -> ToolFqn { + fqn!(concat!( + "xyz.taluslabs.payments.stripe.list-charges@", + env!("TOOL_FQN_VERSION") + )) + } + + fn path() -> &'static str { + "/list-charges" + } + + fn description() -> &'static str { + "Lists Stripe charges with optional filtering and pagination." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + if let Some(l) = input.limit { + if !(1..=100).contains(&l) { + return Output::Err { + reason: "limit must be in 1..=100".to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + } + + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(l) = input.limit { + query.push(("limit", l.to_string())); + } + if let Some(c) = &input.customer { + query.push(("customer", c.clone())); + } + if let Some(s) = &input.starting_after { + query.push(("starting_after", s.clone())); + } + + let qs = serde_urlencoded_minimal(&query); + let endpoint = if qs.is_empty() { + "v1/charges".to_string() + } else { + format!("v1/charges?{qs}") + }; + + match self.client.get::(&endpoint).await { + Ok(r) => Output::Ok { + charges: r.data, + has_more: r.has_more, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +fn serde_urlencoded_minimal(pairs: &[(&str, String)]) -> String { + pairs + .iter() + .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v),)) + .collect::>() + .join("&") +} + +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + b' ' => out.push_str("%20"), + _ => out.push_str(&format!("%{:02X}", byte)), + } + } + out +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, ListCharges) { + let server = Server::new_async().await; + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); + (server, ListCharges { client }) + } + + #[tokio::test] + async fn test_list_charges_success() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("GET", "/v1/charges?limit=2") + .with_status(200) + .with_body( + json!({ + "data": [ + { "id": "ch_1", "amount": 1000, "currency": "usd", "status": "succeeded", "customer": "cus_a" }, + { "id": "ch_2", "amount": 2500, "currency": "usd", "status": "succeeded" } + ], + "has_more": true + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + limit: Some(2), + customer: None, + starting_after: None, + }) + .await; + match result { + Output::Ok { charges, has_more } => { + assert_eq!(charges.len(), 2); + assert!(has_more); + assert_eq!(charges[0].id, "ch_1"); + assert_eq!(charges[1].customer, None); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } + + #[tokio::test] + async fn test_list_charges_limit_out_of_range() { + let (_, tool) = create_server_and_tool().await; + let result = tool + .invoke(Input { + limit: Some(150), + customer: None, + starting_after: None, + }) + .await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::InvalidRequest, + .. + } + )); + } +} diff --git a/offchain/tools/payments-stripe/src/tools/mod.rs b/offchain/tools/payments-stripe/src/tools/mod.rs new file mode 100644 index 0000000..ac22334 --- /dev/null +++ b/offchain/tools/payments-stripe/src/tools/mod.rs @@ -0,0 +1,11 @@ +//! Stripe endpoints. Each submodule is one stateless `NexusTool`. + +pub(crate) const STRIPE_API_BASE: &str = "https://api.stripe.com"; + +pub(crate) mod confirm_payment_intent; +pub(crate) mod create_customer; +pub(crate) mod create_payment_intent; +pub(crate) mod get_balance; +pub(crate) mod get_payment_intent; +pub(crate) mod list_charges; +pub(crate) mod models; diff --git a/offchain/tools/payments-stripe/src/tools/models.rs b/offchain/tools/payments-stripe/src/tools/models.rs new file mode 100644 index 0000000..efb1610 --- /dev/null +++ b/offchain/tools/payments-stripe/src/tools/models.rs @@ -0,0 +1,24 @@ +//! Shared Stripe response shapes used by two or more endpoints. + +use { + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +/// Stripe Balance amount entry. +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub(crate) struct BalanceAmount { + pub amount: i64, + pub currency: String, +} + +/// Stripe Charge summary as returned by `/v1/charges` list calls. +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub(crate) struct ChargeSummary { + pub id: String, + pub amount: i64, + pub currency: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub customer: Option, +} diff --git a/offchain/tools/payments-stripe/tools.json b/offchain/tools/payments-stripe/tools.json new file mode 100644 index 0000000..bdcfd30 --- /dev/null +++ b/offchain/tools/payments-stripe/tools.json @@ -0,0 +1,7 @@ +{ + "tool_name": "payments-stripe", + "command": "payments-stripe", + "environment": { + "RUST_LOG": "info" + } +}