diff --git a/.env.example b/.env.example index a60e94f17..c9399b21f 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,10 @@ REDIS_URL=redis://localhost:6379 # COVENANT_ACEDATA_ALLOW=acedata.search # comma-separated tool allowlist; empty = all # COVENANT_ACEDATA_IMAGE_MODEL=flux-pro # COVENANT_ACEDATA_MUSIC_MODEL=chirp-v4 +# Invoica money + compliance provider (covenantd). Off by default; the API key +# is a Bearer credential, keep it off-repo. Settlement stays on Invoica's rail; +# Covenant scopes, brokers, and audits the call. Registers the invoica.* tools. +# COVENANT_INVOICA_ENABLED=true +# COVENANT_INVOICA_API_KEY= # Invoica API key (Bearer); required to enable +# COVENANT_INVOICA_BASE_URL=https://api.invoica.ai +# COVENANT_INVOICA_CHAIN=solana # default settlement chain stamped on new invoices diff --git a/agent-os/Cargo.lock b/agent-os/Cargo.lock index 8050daf22..cede1831c 100644 --- a/agent-os/Cargo.lock +++ b/agent-os/Cargo.lock @@ -1244,6 +1244,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "covenant-invoica" +version = "0.0.0" +dependencies = [ + "async-trait", + "covenant-mcp", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "wiremock", +] + [[package]] name = "covenant-ipc" version = "0.1.0" @@ -1598,6 +1613,7 @@ dependencies = [ "covenant-budget", "covenant-hyre", "covenant-identity", + "covenant-invoica", "covenant-ipc", "covenant-llm", "covenant-manifest", diff --git a/agent-os/Cargo.toml b/agent-os/Cargo.toml index 2931b7f2e..ba81964c9 100644 --- a/agent-os/Cargo.toml +++ b/agent-os/Cargo.toml @@ -23,6 +23,7 @@ members = [ "crates/covenant-metaplex", "crates/covenant-sns", "crates/covenant-acedata", + "crates/covenant-invoica", "crates/covenant-sap-bridge", "crates/covenant-stake-keeper", "crates/covenantd", diff --git a/agent-os/crates/covenant-invoica/Cargo.toml b/agent-os/crates/covenant-invoica/Cargo.toml new file mode 100644 index 000000000..af28e8f59 --- /dev/null +++ b/agent-os/crates/covenant-invoica/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "covenant-invoica" +version = "0.0.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Invoica provider for Covenant. Wraps Invoica's invoicing, settlement, and tax REST API as audit-logged, capability-gated MCP tools, with the daemon brokering the API key so the agent never holds it." + +[dependencies] +covenant-mcp = { path = "../covenant-mcp" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +reqwest = { workspace = true } +tokio = { version = "1", features = ["time"] } + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +wiremock = "0.6" diff --git a/agent-os/crates/covenant-invoica/src/client.rs b/agent-os/crates/covenant-invoica/src/client.rs new file mode 100644 index 000000000..ca34b51ba --- /dev/null +++ b/agent-os/crates/covenant-invoica/src/client.rs @@ -0,0 +1,427 @@ +//! REST client for Invoica's invoice API. +//! +//! Holds the API key and injects it as `Authorization: Bearer` on every +//! request; the daemon constructs the client, so the key never reaches the +//! agent. Reads (get/list) retry a cold-started gateway; the create write is +//! single-shot, since a created invoice is not idempotent. Responses are +//! returned as raw JSON, because Invoica's invoice shape differs between its +//! published SDK and its live backend. + +use std::time::Duration; + +use serde::Serialize; +use serde_json::Value; + +use crate::types::CreateInvoiceRequest; +use crate::{InvoicaError, Result}; + +#[derive(Clone)] +pub struct InvoicaClient { + http: reqwest::Client, + base_url: String, + api_key: String, +} + +impl InvoicaClient { + pub fn new(base_url: impl Into, api_key: impl Into) -> Self { + let http = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(20)) + .build() + .expect("build invoica http client"); + // A key or host sourced from an env file or secret often carries a + // trailing newline; left in, the key makes an invalid `Authorization` + // header that fails every request at send time. Trim at the boundary. + Self { + http, + base_url: base_url.into().trim().trim_end_matches('/').to_string(), + api_key: api_key.into().trim().to_string(), + } + } + + /// `POST /v1/invoices`. Not retried: a created invoice is not idempotent. + pub async fn create_invoice(&self, req: &CreateInvoiceRequest) -> Result { + self.post("/v1/invoices", req).await + } + + /// `GET /v1/invoices/:id`. + pub async fn get_invoice(&self, id: &str) -> Result { + self.get(&format!("/v1/invoices/{}", urlencode(id))).await + } + + /// `GET /v1/invoices` with optional filters. Returns the `{invoices, total, + /// page}` blob verbatim. + pub async fn list_invoices(&self, params: &[(String, String)]) -> Result { + let mut path = String::from("/v1/invoices"); + if !params.is_empty() { + let q: Vec = params + .iter() + .map(|(k, v)| format!("{}={}", urlencode(k), urlencode(v))) + .collect(); + path.push('?'); + path.push_str(&q.join("&")); + } + self.get(&path).await + } + + async fn get(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + let mut attempt = 0; + loop { + attempt += 1; + match self.http.get(&url).bearer_auth(&self.api_key).send().await { + Ok(resp) => { + if attempt < MAX_ATTEMPTS && is_retryable_status(resp.status()) { + let delay = retry_after(&resp).unwrap_or_else(|| backoff(attempt)); + tokio::time::sleep(delay).await; + continue; + } + return read(resp, path).await; + } + Err(e) if attempt < MAX_ATTEMPTS && is_transient(&e) => { + tokio::time::sleep(backoff(attempt)).await; + continue; + } + Err(e) => return Err(e.into()), + } + } + } + + async fn post(&self, path: &str, body: &impl Serialize) -> Result { + let url = format!("{}{}", self.base_url, path); + let resp = self + .http + .post(&url) + .bearer_auth(&self.api_key) + .json(body) + .send() + .await?; + read(resp, path).await + } +} + +async fn read(resp: reqwest::Response, ctx: &str) -> Result { + let status = resp.status(); + let body = resp.text().await?; + if !status.is_success() { + let (message, code) = parse_error(&body, status); + return Err(InvoicaError::Api { + status: status.as_u16(), + code, + message, + }); + } + if body.trim().is_empty() { + return Err(InvoicaError::Decode(format!("{ctx}: empty response body"))); + } + let value: Value = serde_json::from_str(&body) + .map_err(|e| InvoicaError::Decode(format!("{ctx}: {e}; body: {}", truncate(&body))))?; + if value.is_null() { + return Err(InvoicaError::Decode(format!( + "{ctx}: response body was null" + ))); + } + Ok(value) +} + +/// Invoica reports failures two ways: the invoice routes return a flat +/// `{ error: "..." }`, the tax and settlement routes a nested +/// `{ error: { message, code } }`. Pull message and code from whichever shape +/// is present. +fn parse_error(body: &str, status: reqwest::StatusCode) -> (String, Option) { + let v: Value = serde_json::from_str(body).unwrap_or(Value::Null); + let message = v + .get("error") + .and_then(Value::as_str) + .or_else(|| v.pointer("/error/message").and_then(Value::as_str)) + .map(String::from) + .unwrap_or_else(|| { + if body.is_empty() { + status.canonical_reason().unwrap_or("request failed").into() + } else { + truncate(body) + } + }); + let code = v + .get("code") + .and_then(Value::as_str) + .or_else(|| v.pointer("/error/code").and_then(Value::as_str)) + .map(String::from); + (message, code) +} + +/// Total GET attempts before giving up. +const MAX_ATTEMPTS: u32 = 3; + +/// Cap on an honored `Retry-After`, so a hostile or fat-fingered value can't +/// stall a call up against the request timeout. +const RETRY_AFTER_CAP_SECS: u64 = 5; + +/// Backoff before the next GET attempt: 400ms, then 800ms. +fn backoff(attempt: u32) -> Duration { + Duration::from_millis(400 * u64::from(attempt)) +} + +/// Statuses worth a retry: a cold-starting gateway (502/503/504) or a brief +/// rate-limit (429). A 429 or 503 may carry `Retry-After`, which [`retry_after`] +/// honors in place of the default backoff. +fn is_retryable_status(status: reqwest::StatusCode) -> bool { + matches!(status.as_u16(), 502 | 503 | 504 | 429) +} + +/// The `Retry-After` delay a response asks for, in delta-seconds, capped by +/// [`RETRY_AFTER_CAP_SECS`]. The HTTP-date form is not read; Invoica's gateway +/// sends seconds. +fn retry_after(resp: &reqwest::Response) -> Option { + let secs: u64 = resp + .headers() + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse() + .ok()?; + Some(Duration::from_secs(secs.min(RETRY_AFTER_CAP_SECS))) +} + +/// A connect failure or timeout against a parked instance; retryable. +fn is_transient(e: &reqwest::Error) -> bool { + e.is_timeout() || e.is_connect() +} + +fn urlencode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} + +fn truncate(s: &str) -> String { + let cut: String = s.chars().take(200).collect(); + if cut.chars().count() < s.chars().count() { + format!("{cut}...") + } else { + cut + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{bearer_token, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn req() -> CreateInvoiceRequest { + CreateInvoiceRequest { + amount: 100.0, + customer_email: "buyer@acme.test".into(), + customer_name: "Acme Inc".into(), + currency: Some("USD".into()), + chain: Some("solana".into()), + buyer_country_code: None, + buyer_state_code: None, + company_id: None, + } + } + + #[tokio::test] + async fn create_sends_bearer_and_returns_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/invoices")) + .and(bearer_token("secret-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "inv_1", "invoiceNumber": 1001, "status": "PENDING", "amount": 100.0, "currency": "USD" + }))) + .mount(&server) + .await; + let v = InvoicaClient::new(server.uri(), "secret-key") + .create_invoice(&req()) + .await + .unwrap(); + assert_eq!(v["id"], "inv_1"); + assert_eq!(v["invoiceNumber"], 1001); + } + + #[tokio::test] + async fn get_retries_cold_start_502_then_succeeds() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/invoices/inv_1")) + .respond_with(ResponseTemplate::new(502)) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/invoices/inv_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": "inv_1" }))) + .mount(&server) + .await; + let v = InvoicaClient::new(server.uri(), "k") + .get_invoice("inv_1") + .await + .unwrap(); + assert_eq!(v["id"], "inv_1"); + } + + #[tokio::test] + async fn create_is_not_retried() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/invoices")) + .respond_with(ResponseTemplate::new(502)) + .expect(1) + .mount(&server) + .await; + let err = InvoicaClient::new(server.uri(), "k") + .create_invoice(&req()) + .await + .unwrap_err(); + assert!(matches!(err, InvoicaError::Api { status: 502, .. })); + } + + #[tokio::test] + async fn flat_error_surfaces_message_and_code() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/invoices/bad")) + .respond_with( + ResponseTemplate::new(404) + .set_body_json(json!({ "error": "invoice not found", "code": "NOT_FOUND" })), + ) + .mount(&server) + .await; + let err = InvoicaClient::new(server.uri(), "k") + .get_invoice("bad") + .await + .unwrap_err(); + match err { + InvoicaError::Api { + status, + code, + message, + } => { + assert_eq!(status, 404); + assert_eq!(code.as_deref(), Some("NOT_FOUND")); + assert_eq!(message, "invoice not found"); + } + other => panic!("expected Api error, got {other:?}"), + } + } + + #[tokio::test] + async fn nested_error_shape_is_parsed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/invoices/x")) + .respond_with(ResponseTemplate::new(400).set_body_json( + json!({ "success": false, "error": { "message": "bad request", "code": "VALIDATION" } }), + )) + .mount(&server) + .await; + let err = InvoicaClient::new(server.uri(), "k") + .get_invoice("x") + .await + .unwrap_err(); + match err { + InvoicaError::Api { code, message, .. } => { + assert_eq!(message, "bad request"); + assert_eq!(code.as_deref(), Some("VALIDATION")); + } + other => panic!("expected Api error, got {other:?}"), + } + } + + #[tokio::test] + async fn empty_success_body_is_an_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/invoices/inv_1")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + let err = InvoicaClient::new(server.uri(), "k") + .get_invoice("inv_1") + .await + .unwrap_err(); + assert!(matches!(err, InvoicaError::Decode(_))); + } + + #[tokio::test] + async fn null_success_body_is_an_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/invoices/inv_1")) + .respond_with(ResponseTemplate::new(200).set_body_string("null")) + .mount(&server) + .await; + let err = InvoicaClient::new(server.uri(), "k") + .get_invoice("inv_1") + .await + .unwrap_err(); + assert!(matches!(err, InvoicaError::Decode(_))); + } + + #[tokio::test] + async fn get_retries_429_honoring_retry_after() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/invoices/inv_1")) + .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0")) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/invoices/inv_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": "inv_1" }))) + .mount(&server) + .await; + let v = InvoicaClient::new(server.uri(), "k") + .get_invoice("inv_1") + .await + .unwrap(); + assert_eq!(v["id"], "inv_1"); + } + + #[tokio::test] + async fn new_trims_whitespace_in_key() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/invoices/inv_1")) + .and(bearer_token("secret-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": "inv_1" }))) + .mount(&server) + .await; + let v = InvoicaClient::new(server.uri(), "secret-key\n") + .get_invoice("inv_1") + .await + .unwrap(); + assert_eq!(v["id"], "inv_1"); + } + + #[test] + fn api_error_display_includes_code_when_present() { + let with = InvoicaError::Api { + status: 404, + code: Some("NOT_FOUND".into()), + message: "invoice not found".into(), + }; + assert_eq!( + with.to_string(), + "invoica api [404]: invoice not found [NOT_FOUND]" + ); + let without = InvoicaError::Api { + status: 500, + code: None, + message: "boom".into(), + }; + assert_eq!(without.to_string(), "invoica api [500]: boom"); + } +} diff --git a/agent-os/crates/covenant-invoica/src/config.rs b/agent-os/crates/covenant-invoica/src/config.rs new file mode 100644 index 000000000..a963124d3 --- /dev/null +++ b/agent-os/crates/covenant-invoica/src/config.rs @@ -0,0 +1,68 @@ +//! Invoica provider configuration. +//! +//! Carries only non-secret tunables. The API key is read from env and handed +//! to the [`crate::InvoicaClient`] separately, so it never lands on a +//! serializable struct that could be logged or written to disk. + +use serde::{Deserialize, Serialize}; + +/// Invoica's REST base, serving the Bearer-key invoice API. +pub const DEFAULT_BASE_URL: &str = "https://api.invoica.ai"; + +/// Settlement chain stamped on new invoices. Invoica's published SDK `Chain` +/// type is EVM-only, but its backend `openapi.json` whitelists +/// `base|polygon|arbitrum|skale|solana`, and Covenant's x402 rail is Solana, so +/// the connector defaults there. +pub const DEFAULT_CHAIN: &str = "solana"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InvoicaConfig { + /// Master switch. `false` registers no tools and makes no calls. + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_base_url")] + pub base_url: String, + /// Chain stamped on new invoices when the caller does not name one. + #[serde(default = "default_chain")] + pub chain: String, +} + +fn default_base_url() -> String { + DEFAULT_BASE_URL.to_string() +} + +fn default_chain() -> String { + DEFAULT_CHAIN.to_string() +} + +impl Default for InvoicaConfig { + fn default() -> Self { + Self { + enabled: false, + base_url: default_base_url(), + chain: default_chain(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_off_solana_prod_host() { + let c = InvoicaConfig::default(); + assert!(!c.enabled); + assert_eq!(c.base_url, DEFAULT_BASE_URL); + assert_eq!(c.chain, "solana"); + } + + #[test] + fn config_carries_no_secret() { + let json = serde_json::to_string(&InvoicaConfig::default()).unwrap(); + assert!( + !json.to_lowercase().contains("key"), + "config leaks a key field" + ); + } +} diff --git a/agent-os/crates/covenant-invoica/src/lib.rs b/agent-os/crates/covenant-invoica/src/lib.rs new file mode 100644 index 000000000..fa4127f85 --- /dev/null +++ b/agent-os/crates/covenant-invoica/src/lib.rs @@ -0,0 +1,54 @@ +//! Invoica provider for Covenant. +//! +//! Invoica is the money and compliance layer for agents (invoicing, +//! settlement, tax). Covenant is the authority and accountability layer. They +//! barely overlap: settlement stays on Invoica's rail, and Covenant scopes, +//! brokers, and audits the call around it. +//! +//! Phase 1 wraps Invoica's Bearer-key invoice API as Covenant MCP tools: +//! create, get, and list invoices, and read settlement state off the invoice. +//! The daemon holds the Invoica API key inside the [`InvoicaClient`] and +//! injects it per request, so the agent reaches the tool through the daemon and +//! never sees the key. +//! Every tool sits behind a `tool.call.*` capability and lands on the audit +//! chain through the daemon's generic tool path. +//! +//! Invoica's x402-paid surface (the priced invoice/settle/tax endpoints at +//! `/api/x402/*`), the provenance envelope binding a task to its settlement +//! signature and audit trail, and the live x402 payment path are Phase 2. This +//! crate stays on the Bearer-key REST surface. +//! +//! Lane discipline: Covenant never settles or runs the tax engine itself +//! (Invoica owns that), and Invoica never issues capability grants or holds the +//! audit chain (Covenant owns that). + +#![deny(unsafe_code)] + +pub mod client; +pub mod config; +pub mod tools; +pub mod types; + +pub use client::InvoicaClient; +pub use config::{InvoicaConfig, DEFAULT_BASE_URL, DEFAULT_CHAIN}; +pub use tools::{invoica_tools, PROVIDER}; +pub use types::CreateInvoiceRequest; + +/// Errors surfaced by the Invoica provider. +#[derive(Debug, thiserror::Error)] +pub enum InvoicaError { + #[error("http: {0}")] + Http(#[from] reqwest::Error), + /// A non-2xx response, with Invoica's machine `code` when it sends one. + #[error("invoica api [{}]: {}{}", status, message, + code.as_deref().map(|c| format!(" [{c}]")).unwrap_or_default())] + Api { + status: u16, + code: Option, + message: String, + }, + #[error("decode: {0}")] + Decode(String), +} + +pub type Result = std::result::Result; diff --git a/agent-os/crates/covenant-invoica/src/tools.rs b/agent-os/crates/covenant-invoica/src/tools.rs new file mode 100644 index 000000000..58ff9ee98 --- /dev/null +++ b/agent-os/crates/covenant-invoica/src/tools.rs @@ -0,0 +1,407 @@ +//! Invoica MCP tools. +//! +//! Four actions over Invoica's invoice API: create, get, and list invoices, +//! and read settlement state. Each holds a shared [`InvoicaClient`] carrying +//! the brokered key, so the tool runs the authenticated call and the agent +//! only sees the result. The daemon's generic tool path capability-gates and +//! audits each call. The x402-paid surface (the priced invoice/settle/tax +//! endpoints) is Phase 2. + +use std::sync::Arc; + +use async_trait::async_trait; +use covenant_mcp::{Content, Tool, ToolCallResult, ToolError}; +use serde_json::{json, Map, Value}; + +use crate::client::InvoicaClient; +use crate::config::InvoicaConfig; +use crate::types::CreateInvoiceRequest; + +pub const PROVIDER: &str = "invoica"; +pub const INVOICE_CREATE: &str = "invoica.invoice.create"; +pub const INVOICE_GET: &str = "invoica.invoice.get"; +pub const INVOICE_LIST: &str = "invoica.invoice.list"; +pub const SETTLEMENT_CHECK: &str = "invoica.settlement.check"; + +const CURRENCIES: [&str; 3] = ["USD", "EUR", "GBP"]; +const MAX_FIELD_LEN: usize = 512; +const MAX_PAGE_SIZE: u64 = 100; + +/// Build the Invoica tool set. Empty when disabled. `default_chain` from config +/// stamps invoices the caller does not pin a chain on. +pub fn invoica_tools(client: Arc, cfg: &InvoicaConfig) -> Vec> { + if !cfg.enabled { + return Vec::new(); + } + vec![ + Arc::new(CreateTool { + client: client.clone(), + default_chain: cfg.chain.clone(), + }), + Arc::new(GetTool { + client: client.clone(), + }), + Arc::new(ListTool { + client: client.clone(), + }), + Arc::new(SettlementTool { client }), + ] +} + +fn ok(value: Value) -> ToolCallResult { + ToolCallResult::ok(vec![Content::json(value)]) +} + +fn arg_str(args: &Value, key: &str) -> Result { + let s = args + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArguments(format!("missing non-empty \"{key}\"")))?; + bounded(key, s) +} + +fn opt_str(args: &Value, key: &str) -> Result, ToolError> { + match args.get(key).and_then(Value::as_str).map(str::trim) { + Some(s) if !s.is_empty() => Ok(Some(bounded(key, s)?)), + _ => Ok(None), + } +} + +fn bounded(key: &str, s: &str) -> Result { + if s.len() > MAX_FIELD_LEN { + return Err(ToolError::InvalidArguments(format!( + "\"{key}\" exceeds {MAX_FIELD_LEN} bytes" + ))); + } + Ok(s.to_string()) +} + +/// An invoice id argument. Rejects `.` and `..`, which would resolve to a +/// different path than `/v1/invoices/:id` once the URL is normalized. +fn invoice_id(args: &Value, key: &str) -> Result { + let id = arg_str(args, key)?; + if id == "." || id == ".." { + return Err(ToolError::InvalidArguments(format!( + "\"{key}\" is not a valid invoice id" + ))); + } + Ok(id) +} + +fn positive_amount(args: &Value) -> Result { + args.get("amount") + .and_then(Value::as_f64) + .filter(|a| a.is_finite() && *a > 0.0) + .ok_or_else(|| ToolError::InvalidArguments("\"amount\" must be a positive number".into())) +} + +struct CreateTool { + client: Arc, + default_chain: String, +} + +#[async_trait] +impl Tool for CreateTool { + fn name(&self) -> &str { + INVOICE_CREATE + } + fn description(&self) -> &str { + "Create an Invoica invoice for a completed service. Amount is in the invoice currency \ + (USD, EUR, or GBP); settlement is in USDC on chain. Returns the created invoice." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "amount": { "type": "number", "description": "Invoice amount in the given currency, e.g. 100 for 100.00." }, + "customerEmail": { "type": "string" }, + "customerName": { "type": "string" }, + "currency": { "type": "string", "enum": ["USD", "EUR", "GBP"] }, + "chain": { "type": "string", "description": "Settlement chain; defaults to the configured chain." }, + "buyerCountryCode": { "type": "string", "description": "ISO country code, for tax." }, + "buyerStateCode": { "type": "string", "description": "US state code, for tax." }, + "companyId": { "type": "string" } + }, + "required": ["amount", "customerEmail", "customerName"], + "additionalProperties": false + }) + } + async fn call(&self, args: Value) -> Result { + let currency = match opt_str(&args, "currency")? { + Some(c) if !CURRENCIES.contains(&c.as_str()) => { + return Err(ToolError::InvalidArguments( + "\"currency\" must be USD, EUR, or GBP".into(), + )); + } + other => other, + }; + let req = CreateInvoiceRequest { + amount: positive_amount(&args)?, + customer_email: arg_str(&args, "customerEmail")?, + customer_name: arg_str(&args, "customerName")?, + currency, + chain: Some(opt_str(&args, "chain")?.unwrap_or_else(|| self.default_chain.clone())), + buyer_country_code: opt_str(&args, "buyerCountryCode")?, + buyer_state_code: opt_str(&args, "buyerStateCode")?, + company_id: opt_str(&args, "companyId")?, + }; + match self.client.create_invoice(&req).await { + Ok(inv) => Ok(ok(json!({ "provider": PROVIDER, "invoice": inv }))), + Err(e) => Ok(ToolCallResult::error(e.to_string())), + } + } +} + +struct GetTool { + client: Arc, +} + +#[async_trait] +impl Tool for GetTool { + fn name(&self) -> &str { + INVOICE_GET + } + fn description(&self) -> &str { + "Fetch a single Invoica invoice by id." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"], + "additionalProperties": false + }) + } + async fn call(&self, args: Value) -> Result { + let id = invoice_id(&args, "id")?; + match self.client.get_invoice(&id).await { + Ok(inv) => Ok(ok(json!({ "provider": PROVIDER, "invoice": inv }))), + Err(e) => Ok(ToolCallResult::error(e.to_string())), + } + } +} + +struct ListTool { + client: Arc, +} + +#[async_trait] +impl Tool for ListTool { + fn name(&self) -> &str { + INVOICE_LIST + } + fn description(&self) -> &str { + "List Invoica invoices, optionally filtered by status or chain." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": { "type": "string" }, + "chain": { "type": "string" }, + "page": { "type": "integer", "minimum": 1 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 } + }, + "additionalProperties": false + }) + } + async fn call(&self, args: Value) -> Result { + let mut params: Vec<(String, String)> = Vec::new(); + for key in ["status", "chain"] { + if let Some(v) = opt_str(&args, key)? { + params.push((key.to_string(), v)); + } + } + if let Some(p) = args.get("page").and_then(Value::as_u64).filter(|p| *p >= 1) { + params.push(("page".into(), p.to_string())); + } + if let Some(l) = args.get("limit").and_then(Value::as_u64) { + params.push(("limit".into(), l.clamp(1, MAX_PAGE_SIZE).to_string())); + } + match self.client.list_invoices(¶ms).await { + Ok(v) => Ok(ok(json!({ "provider": PROVIDER, "result": v }))), + Err(e) => Ok(ToolCallResult::error(e.to_string())), + } + } +} + +/// Invoica has no settlement-by-invoice endpoint; settlement state lives on the +/// invoice itself (`status`, `paymentDetails`, `settledAt`, `completedAt`). This +/// reads the invoice and projects those fields. +struct SettlementTool { + client: Arc, +} + +#[async_trait] +impl Tool for SettlementTool { + fn name(&self) -> &str { + SETTLEMENT_CHECK + } + fn description(&self) -> &str { + "Check settlement state for an Invoica invoice: its status, the on-chain payment details \ + and signature once paid, and when it settled." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { "invoiceId": { "type": "string" } }, + "required": ["invoiceId"], + "additionalProperties": false + }) + } + async fn call(&self, args: Value) -> Result { + let id = invoice_id(&args, "invoiceId")?; + let inv = match self.client.get_invoice(&id).await { + Ok(inv) => inv, + Err(e) => return Ok(ToolCallResult::error(e.to_string())), + }; + let mut out = Map::new(); + out.insert("invoiceId".into(), json!(id)); + for key in ["status", "paymentDetails", "settledAt", "completedAt"] { + if let Some(v) = inv.get(key) { + out.insert(key.into(), v.clone()); + } + } + Ok(ok( + json!({ "provider": PROVIDER, "settlement": Value::Object(out) }), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn enabled() -> InvoicaConfig { + InvoicaConfig { + enabled: true, + ..Default::default() + } + } + + fn create_args() -> Value { + json!({ "amount": 100.0, "customerEmail": "b@acme.test", "customerName": "Acme" }) + } + + #[test] + fn disabled_registers_nothing() { + let c = Arc::new(InvoicaClient::new("http://x", "k")); + assert!(invoica_tools(c, &InvoicaConfig::default()).is_empty()); + } + + #[test] + fn enabled_registers_the_four_tools() { + let c = Arc::new(InvoicaClient::new("http://x", "k")); + let tools = invoica_tools(c, &enabled()); + let names: Vec<_> = tools.iter().map(|t| t.name()).collect(); + assert_eq!( + names, + [INVOICE_CREATE, INVOICE_GET, INVOICE_LIST, SETTLEMENT_CHECK] + ); + } + + #[tokio::test] + async fn create_defaults_chain_and_returns_invoice() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/invoices")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "inv_9", "invoiceNumber": 9, "status": "PENDING", "amount": 100.0, "currency": "USD" + }))) + .mount(&server) + .await; + let tool = CreateTool { + client: Arc::new(InvoicaClient::new(server.uri(), "k")), + default_chain: "solana".into(), + }; + let res = tool.call(create_args()).await.unwrap(); + assert!(!res.is_error); + let body = match &res.content[0] { + Content::Json { value } => value.clone(), + other => panic!("expected json, got {other:?}"), + }; + assert_eq!(body["invoice"]["id"], "inv_9"); + assert_eq!(body["provider"], "invoica"); + } + + #[tokio::test] + async fn create_rejects_missing_customer_and_bad_amount() { + let tool = CreateTool { + client: Arc::new(InvoicaClient::new("http://x", "k")), + default_chain: "solana".into(), + }; + let no_email = tool + .call(json!({ "amount": 1.0, "customerName": "Acme" })) + .await + .unwrap_err(); + assert!(matches!(no_email, ToolError::InvalidArguments(_))); + let bad_amt = tool + .call(json!({ "amount": -5, "customerEmail": "b@a.co", "customerName": "Acme" })) + .await + .unwrap_err(); + assert!(matches!(bad_amt, ToolError::InvalidArguments(_))); + } + + #[tokio::test] + async fn create_rejects_unknown_currency() { + let tool = CreateTool { + client: Arc::new(InvoicaClient::new("http://x", "k")), + default_chain: "solana".into(), + }; + let mut args = create_args(); + args["currency"] = json!("BTC"); + assert!(matches!( + tool.call(args).await.unwrap_err(), + ToolError::InvalidArguments(_) + )); + } + + #[tokio::test] + async fn get_and_settlement_reject_dot_segment_ids() { + let get = GetTool { + client: Arc::new(InvoicaClient::new("http://x", "k")), + }; + let settle = SettlementTool { + client: Arc::new(InvoicaClient::new("http://x", "k")), + }; + for bad in [".", ".."] { + assert!(matches!( + get.call(json!({ "id": bad })).await.unwrap_err(), + ToolError::InvalidArguments(_) + )); + assert!(matches!( + settle.call(json!({ "invoiceId": bad })).await.unwrap_err(), + ToolError::InvalidArguments(_) + )); + } + } + + #[tokio::test] + async fn settlement_projects_invoice_state() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/invoices/inv_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "inv_1", "status": "SETTLED", "amount": 100.0, + "paymentDetails": { "txHash": "5xy", "network": "solana-mainnet" }, + "settledAt": "2026-07-01T00:00:00Z" + }))) + .mount(&server) + .await; + let tool = SettlementTool { + client: Arc::new(InvoicaClient::new(server.uri(), "k")), + }; + let res = tool.call(json!({ "invoiceId": "inv_1" })).await.unwrap(); + let body = match &res.content[0] { + Content::Json { value } => value.clone(), + other => panic!("expected json, got {other:?}"), + }; + assert_eq!(body["settlement"]["status"], "SETTLED"); + assert_eq!(body["settlement"]["paymentDetails"]["txHash"], "5xy"); + assert_eq!(body["settlement"]["invoiceId"], "inv_1"); + } +} diff --git a/agent-os/crates/covenant-invoica/src/types.rs b/agent-os/crates/covenant-invoica/src/types.rs new file mode 100644 index 000000000..64ddae930 --- /dev/null +++ b/agent-os/crates/covenant-invoica/src/types.rs @@ -0,0 +1,62 @@ +//! Request types for Invoica's invoice API. +//! +//! Only the request body is typed. Invoice responses are returned to the agent +//! as raw JSON: Invoica's published `@invoica/sdk` and its live backend +//! disagree on the response shape (the SDK documents `number`/`customerId`, +//! the backend returns `invoiceNumber`/`customerEmail`), so pinning a struct +//! to either would break against the other. The request fields below track the +//! backend's `openapi.json` (`POST /v1/invoices`), which is the contract the +//! live API actually serves. + +use serde::Serialize; + +/// Body for `POST /v1/invoices`. `amount` is a major-unit fiat value (the +/// backend stores it via `parseFloat`, so `100.0` is 100.00, not cents). +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateInvoiceRequest { + pub amount: f64, + pub customer_email: String, + pub customer_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_country_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_state_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub company_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn serializes_camel_case_and_omits_empty_optionals() { + let req = CreateInvoiceRequest { + amount: 100.0, + customer_email: "buyer@acme.test".into(), + customer_name: "Acme Inc".into(), + currency: Some("USD".into()), + chain: Some("solana".into()), + buyer_country_code: None, + buyer_state_code: None, + company_id: None, + }; + let out = serde_json::to_value(&req).unwrap(); + assert_eq!( + out, + json!({ + "amount": 100.0, + "customerEmail": "buyer@acme.test", + "customerName": "Acme Inc", + "currency": "USD", + "chain": "solana" + }) + ); + } +} diff --git a/agent-os/crates/covenantd/Cargo.toml b/agent-os/crates/covenantd/Cargo.toml index 289fd1a9f..07b8aa753 100644 --- a/agent-os/crates/covenantd/Cargo.toml +++ b/agent-os/crates/covenantd/Cargo.toml @@ -36,6 +36,7 @@ covenant-hyre = { path = "../covenant-hyre" } covenant-metaplex = { path = "../covenant-metaplex" } covenant-sns = { path = "../covenant-sns" } covenant-acedata = { path = "../covenant-acedata" } +covenant-invoica = { path = "../covenant-invoica" } anyhow = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } diff --git a/agent-os/crates/covenantd/src/main.rs b/agent-os/crates/covenantd/src/main.rs index 1b817ac41..e50f3deb5 100644 --- a/agent-os/crates/covenantd/src/main.rs +++ b/agent-os/crates/covenantd/src/main.rs @@ -207,6 +207,16 @@ async fn main() -> Result<()> { } None => None, }; + if let Some((client, cfg)) = invoica_from_env() { + let added = covenant_invoica::invoica_tools(Arc::new(client), &cfg); + info!( + count = added.len(), + base_url = %cfg.base_url, + chain = %cfg.chain, + "invoica provider enabled (brokered key; tools capability-gated and audited, settlement on invoica's rail)" + ); + tools_vec.extend(added); + } let mcp_cfg = covenant_mcp::config::McpConfigFile::from_path(&secrets_path) .with_context(|| format!("parse mcp config in {}", secrets_path.display()))?; for srv in mcp_cfg.servers() { @@ -914,6 +924,51 @@ fn acedata_from_env() -> Option<( } } +/// Build the Invoica provider from env, or None when the operator hasn't opted +/// in. The API key is read here (the only place) and handed to the client, so +/// it never lands on the serializable config. All off by default. +/// +/// - `COVENANT_INVOICA_ENABLED` truthy registers the tools +/// - `COVENANT_INVOICA_API_KEY` the brokered Bearer key (required when enabled) +/// - `COVENANT_INVOICA_BASE_URL` overrides the API host (optional) +/// - `COVENANT_INVOICA_CHAIN` overrides the default settlement chain (optional) +fn invoica_from_env() -> Option<( + covenant_invoica::InvoicaClient, + covenant_invoica::InvoicaConfig, +)> { + let enabled = std::env::var("COVENANT_INVOICA_ENABLED") + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + if !enabled { + return None; + } + let mut cfg = covenant_invoica::InvoicaConfig { + enabled: true, + ..Default::default() + }; + if let Ok(url) = std::env::var("COVENANT_INVOICA_BASE_URL") { + if !url.trim().is_empty() { + cfg.base_url = url.trim().to_string(); + } + } + if let Ok(chain) = std::env::var("COVENANT_INVOICA_CHAIN") { + if !chain.trim().is_empty() { + cfg.chain = chain.trim().to_string(); + } + } + let api_key = match std::env::var("COVENANT_INVOICA_API_KEY") { + Ok(k) if !k.trim().is_empty() => k.trim().to_string(), + _ => { + tracing::warn!( + "COVENANT_INVOICA_ENABLED is set but COVENANT_INVOICA_API_KEY is missing; invoica disabled" + ); + return None; + } + }; + let client = covenant_invoica::InvoicaClient::new(cfg.base_url.clone(), api_key); + Some((client, cfg)) +} + /// Build the Hyre provider config from env, or None when the operator /// hasn't opted in. The catalog itself loads from the vendored manifest; /// these vars only tune the rail and the spend policy.