Skip to main content

cloud/envoy/
payments.rs

1//! `payments.*` verb signatures — billing surface catalog (R409-T12).
2//!
3//! Three verbs for the payments plane. Per W144, this is a yubaba-side
4//! category — the fractal puts payments verbs on the yubaba host, not the
5//! yah camp host. The shapes are designed for Stripe, Lemon Squeezy, and
6//! Paddle as exemplar providers.
7//!
8//! - `payments.charge.create`       — create a one-time charge / payment intent
9//! - `payments.subscription.upsert` — create or update a subscription
10//! - `payments.webhook.verify`      — verify a provider webhook signature
11//!
12//! `webhook.verify` is intentionally read-only (pure HMAC check). The
13//! `secret` field carries a sensitive value — adapters must never log it.
14
15use serde::{Deserialize, Serialize};
16
17use super::{InternalVerb, VerbCategory};
18
19// ── payments.charge.create ────────────────────────────────────────────────
20
21/// Marker type for `payments.charge.create`.
22pub struct PaymentsChargeCreate;
23
24/// Request body for `payments.charge.create`.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
27pub struct PaymentsChargeCreateInput {
28    /// Charge amount in the smallest currency unit (cents for USD/EUR, etc.).
29    pub amount_cents: u64,
30    /// ISO 4217 currency code, lowercase: `"usd"`, `"eur"`, `"gbp"`, etc.
31    pub currency: String,
32    /// Human-readable description shown on receipts. Optional.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub description: Option<String>,
35    /// Provider-issued customer ID to attach this charge to. `None` for
36    /// guest / anonymous charges (provider-dependent).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub customer_id: Option<String>,
39}
40
41/// Response body for `payments.charge.create`.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
44pub struct PaymentsChargeCreateOutput {
45    /// Provider-issued charge or payment-intent ID.
46    pub id: String,
47    /// Lifecycle status: `"succeeded"`, `"pending"`, `"failed"`.
48    pub status: String,
49    /// Client secret for client-side confirmation (Stripe's
50    /// `PaymentIntent.client_secret`). `None` for providers that do not
51    /// require a two-step client confirmation flow.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub client_secret: Option<String>,
54}
55
56impl InternalVerb for PaymentsChargeCreate {
57    type Input = PaymentsChargeCreateInput;
58    type Output = PaymentsChargeCreateOutput;
59    const ID: &'static str = "payments.charge.create";
60    const CATEGORY: VerbCategory = VerbCategory::Payments;
61}
62
63// ── payments.subscription.upsert ─────────────────────────────────────────
64
65/// Marker type for `payments.subscription.upsert`.
66pub struct PaymentsSubscriptionUpsert;
67
68/// Request body for `payments.subscription.upsert`.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
71pub struct PaymentsSubscriptionUpsertInput {
72    /// Provider-issued customer ID.
73    pub customer_id: String,
74    /// Provider-specific price or plan ID (Stripe price, Lemon Squeezy
75    /// variant, Paddle price ID).
76    pub price_id: String,
77    /// Seat / licence quantity. Defaults to `1` when absent.
78    #[serde(default = "quantity_one")]
79    pub quantity: u32,
80}
81
82fn quantity_one() -> u32 {
83    1
84}
85
86/// Response body for `payments.subscription.upsert`.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
89pub struct PaymentsSubscriptionUpsertOutput {
90    /// Provider-issued subscription ID.
91    pub id: String,
92    /// Lifecycle status: `"active"`, `"trialing"`, `"past_due"`,
93    /// `"cancelled"`, `"unknown"`.
94    pub status: String,
95    /// RFC 3339 end of the current billing period. `None` when the
96    /// provider doesn't return it.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub current_period_end: Option<String>,
99}
100
101impl InternalVerb for PaymentsSubscriptionUpsert {
102    type Input = PaymentsSubscriptionUpsertInput;
103    type Output = PaymentsSubscriptionUpsertOutput;
104    const ID: &'static str = "payments.subscription.upsert";
105    const CATEGORY: VerbCategory = VerbCategory::Payments;
106}
107
108// ── payments.webhook.verify ───────────────────────────────────────────────
109
110/// Marker type for `payments.webhook.verify`.
111pub struct PaymentsWebhookVerify;
112
113/// Request body for `payments.webhook.verify`.
114///
115/// The `secret` field carries the endpoint's signing secret — adapters must
116/// never log this value. HMAC verification is adapter-side and
117/// network-free; no provider API call is made.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
120pub struct PaymentsWebhookVerifyInput {
121    /// Raw request body bytes as a string (UTF-8). Some providers sign the
122    /// exact bytes, so pass the body before any JSON parsing.
123    pub payload: String,
124    /// Value of the provider's signature header (e.g. `Stripe-Signature`,
125    /// `X-Signature`).
126    pub signature: String,
127    /// Webhook endpoint signing secret. Treat as a credential; do not log.
128    pub secret: String,
129}
130
131/// Response body for `payments.webhook.verify`.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
134pub struct PaymentsWebhookVerifyOutput {
135    /// `true` if the signature is valid.
136    pub valid: bool,
137    /// Provider event type extracted from the payload (e.g.
138    /// `"payment_intent.succeeded"`). `None` when verification failed or
139    /// the type cannot be extracted.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub event_type: Option<String>,
142}
143
144impl InternalVerb for PaymentsWebhookVerify {
145    type Input = PaymentsWebhookVerifyInput;
146    type Output = PaymentsWebhookVerifyOutput;
147    const ID: &'static str = "payments.webhook.verify";
148    const CATEGORY: VerbCategory = VerbCategory::Payments;
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn verb_ids_match_canonical_namespace() {
157        for id in [
158            PaymentsChargeCreate::ID,
159            PaymentsSubscriptionUpsert::ID,
160            PaymentsWebhookVerify::ID,
161        ] {
162            assert!(id.starts_with("payments."), "{id}");
163        }
164    }
165
166    #[test]
167    fn verbs_are_under_payments_category() {
168        assert_eq!(PaymentsChargeCreate::CATEGORY, VerbCategory::Payments);
169        assert_eq!(PaymentsSubscriptionUpsert::CATEGORY, VerbCategory::Payments);
170        assert_eq!(PaymentsWebhookVerify::CATEGORY, VerbCategory::Payments);
171    }
172
173    #[test]
174    fn charge_create_optional_fields_omitted() {
175        let input = PaymentsChargeCreateInput {
176            amount_cents: 1000,
177            currency: "usd".into(),
178            description: None,
179            customer_id: None,
180        };
181        let wire = serde_json::to_value(&input).unwrap();
182        assert!(!wire.as_object().unwrap().contains_key("description"));
183        assert!(!wire.as_object().unwrap().contains_key("customer_id"));
184    }
185
186    #[test]
187    fn subscription_upsert_defaults_quantity_to_one() {
188        let wire = r#"{"customer_id":"cus_1","price_id":"price_1"}"#;
189        let parsed: PaymentsSubscriptionUpsertInput = serde_json::from_str(wire).unwrap();
190        assert_eq!(parsed.quantity, 1);
191    }
192
193    #[test]
194    fn webhook_verify_output_omits_event_type_on_failure() {
195        let out = PaymentsWebhookVerifyOutput {
196            valid: false,
197            event_type: None,
198        };
199        let wire = serde_json::to_value(&out).unwrap();
200        assert_eq!(wire["valid"], false);
201        assert!(!wire.as_object().unwrap().contains_key("event_type"));
202    }
203
204    #[test]
205    fn webhook_verify_output_includes_event_type_on_success() {
206        let out = PaymentsWebhookVerifyOutput {
207            valid: true,
208            event_type: Some("payment_intent.succeeded".into()),
209        };
210        let wire = serde_json::to_value(&out).unwrap();
211        assert_eq!(wire["valid"], true);
212        assert_eq!(wire["event_type"], "payment_intent.succeeded");
213    }
214
215    #[cfg(feature = "json-schema")]
216    #[test]
217    fn verbs_emit_schemas_via_for_verb() {
218        use super::super::VerbDescriptor;
219
220        let charge = VerbDescriptor::for_verb::<PaymentsChargeCreate>();
221        assert_eq!(charge.id, "payments.charge.create");
222        assert!(charge.input_schema.to_string().contains("amount_cents"));
223
224        let sub = VerbDescriptor::for_verb::<PaymentsSubscriptionUpsert>();
225        assert_eq!(sub.id, "payments.subscription.upsert");
226
227        let verify = VerbDescriptor::for_verb::<PaymentsWebhookVerify>();
228        assert_eq!(verify.id, "payments.webhook.verify");
229        assert!(verify.output_schema.to_string().contains("valid"));
230    }
231}