Skip to main content

solid_pod_rs/wac/
payment.rs

1//! `acl:PaymentCondition` -- gate authorisation on payment proof.
2//!
3//! A `PaymentCondition` is satisfied when the request context carries
4//! a `payment_balance_sats` value that is greater than or equal to the
5//! declared `acl:costSats`. This enables per-resource payment gating
6//! via `.acl.json` files, beyond the global `/pay/` prefix.
7//!
8//! The condition body carries a single mandatory field:
9//!
10//! - `acl:costSats` -- cost in satoshis to access the guarded resource.
11//!
12//! The evaluator does not perform the actual debit; it only checks
13//! whether the caller has sufficient balance. The handler layer is
14//! responsible for debiting the ledger after a successful WAC grant
15//! that included a PaymentCondition.
16
17use serde::{Deserialize, Serialize};
18
19use crate::wac::conditions::{ConditionOutcome, RequestContext};
20
21/// Body of an `acl:PaymentCondition`.
22///
23/// Specifies the cost in satoshis required for access. The WAC evaluator
24/// checks `RequestContext::payment_balance_sats >= cost_sats` and returns
25/// `Satisfied` or `Denied` accordingly.
26#[derive(Debug, Clone, Default, Deserialize, Serialize)]
27pub struct PaymentConditionBody {
28    /// Cost in satoshis to access the guarded resource.
29    #[serde(
30        rename = "acl:costSats",
31        default,
32        deserialize_with = "deserialize_cost_sats"
33    )]
34    pub cost_sats: u64,
35}
36
37/// Deserialize `acl:costSats` from either a number or a string integer.
38/// JSON-LD documents may represent integers as strings in some
39/// serialisation modes.
40fn deserialize_cost_sats<'de, D: serde::Deserializer<'de>>(de: D) -> Result<u64, D::Error> {
41    let v: serde_json::Value = Deserialize::deserialize(de)?;
42    match &v {
43        serde_json::Value::Number(n) => n
44            .as_u64()
45            .ok_or_else(|| serde::de::Error::custom("acl:costSats must be a non-negative integer")),
46        serde_json::Value::String(s) => s
47            .parse::<u64>()
48            .map_err(|_| serde::de::Error::custom("acl:costSats string must parse as u64")),
49        _ => Err(serde::de::Error::custom(
50            "acl:costSats must be a number or string",
51        )),
52    }
53}
54
55/// Default evaluator for `acl:PaymentCondition`.
56///
57/// Stateless: checks `RequestContext::payment_balance_sats` against
58/// the declared cost. Does not perform debiting -- that is the
59/// handler's responsibility.
60#[derive(Debug, Default, Clone, Copy)]
61pub struct PaymentConditionEvaluator;
62
63impl PaymentConditionEvaluator {
64    /// Evaluate whether the caller has sufficient balance.
65    ///
66    /// Returns `Satisfied` when `balance >= cost_sats`, `Denied` otherwise.
67    /// When `payment_balance_sats` is `None` (no payment context), the
68    /// condition is denied -- callers without a ledger entry cannot pass
69    /// payment gates.
70    pub fn evaluate(
71        &self,
72        body: &PaymentConditionBody,
73        ctx: &RequestContext<'_>,
74    ) -> ConditionOutcome {
75        match ctx.payment_balance_sats {
76            Some(balance) if balance >= body.cost_sats => ConditionOutcome::Satisfied,
77            _ => ConditionOutcome::Denied,
78        }
79    }
80}
81
82/// Extract the total payment cost from a list of conditions. Used by
83/// handler layers to determine how many sats to debit after a
84/// successful WAC evaluation.
85pub fn total_payment_cost(conditions: &[crate::wac::conditions::Condition]) -> u64 {
86    conditions
87        .iter()
88        .filter_map(|c| match c {
89            crate::wac::conditions::Condition::Payment(body) => Some(body.cost_sats),
90            _ => None,
91        })
92        .sum()
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn evaluator_satisfied_when_balance_sufficient() {
101        let body = PaymentConditionBody { cost_sats: 100 };
102        let ctx = RequestContext {
103            web_id: Some("did:nostr:alice"),
104            client_id: None,
105            issuer: None,
106            payment_balance_sats: Some(100),
107        };
108        assert_eq!(
109            PaymentConditionEvaluator.evaluate(&body, &ctx),
110            ConditionOutcome::Satisfied
111        );
112    }
113
114    #[test]
115    fn evaluator_satisfied_when_balance_exceeds_cost() {
116        let body = PaymentConditionBody { cost_sats: 50 };
117        let ctx = RequestContext {
118            web_id: Some("did:nostr:alice"),
119            client_id: None,
120            issuer: None,
121            payment_balance_sats: Some(1000),
122        };
123        assert_eq!(
124            PaymentConditionEvaluator.evaluate(&body, &ctx),
125            ConditionOutcome::Satisfied
126        );
127    }
128
129    #[test]
130    fn evaluator_denied_when_balance_insufficient() {
131        let body = PaymentConditionBody { cost_sats: 100 };
132        let ctx = RequestContext {
133            web_id: Some("did:nostr:alice"),
134            client_id: None,
135            issuer: None,
136            payment_balance_sats: Some(50),
137        };
138        assert_eq!(
139            PaymentConditionEvaluator.evaluate(&body, &ctx),
140            ConditionOutcome::Denied
141        );
142    }
143
144    #[test]
145    fn evaluator_denied_when_no_payment_context() {
146        let body = PaymentConditionBody { cost_sats: 1 };
147        let ctx = RequestContext {
148            web_id: Some("did:nostr:alice"),
149            client_id: None,
150            issuer: None,
151            payment_balance_sats: None,
152        };
153        assert_eq!(
154            PaymentConditionEvaluator.evaluate(&body, &ctx),
155            ConditionOutcome::Denied
156        );
157    }
158
159    #[test]
160    fn evaluator_zero_cost_always_satisfied() {
161        let body = PaymentConditionBody { cost_sats: 0 };
162        let ctx = RequestContext {
163            web_id: Some("did:nostr:alice"),
164            client_id: None,
165            issuer: None,
166            payment_balance_sats: Some(0),
167        };
168        assert_eq!(
169            PaymentConditionEvaluator.evaluate(&body, &ctx),
170            ConditionOutcome::Satisfied
171        );
172    }
173
174    #[test]
175    fn total_payment_cost_sums_conditions() {
176        use crate::wac::conditions::Condition;
177
178        let conditions = vec![
179            Condition::Payment(PaymentConditionBody { cost_sats: 100 }),
180            Condition::Client(crate::wac::client::ClientConditionBody::default()),
181            Condition::Payment(PaymentConditionBody { cost_sats: 50 }),
182        ];
183        assert_eq!(total_payment_cost(&conditions), 150);
184    }
185
186    #[test]
187    fn total_payment_cost_zero_when_no_payment_conditions() {
188        use crate::wac::conditions::Condition;
189
190        let conditions = vec![Condition::Client(
191            crate::wac::client::ClientConditionBody::default(),
192        )];
193        assert_eq!(total_payment_cost(&conditions), 0);
194    }
195
196    #[test]
197    fn deserialize_from_json() {
198        let json = r#"{"@type": "acl:PaymentCondition", "acl:costSats": 42}"#;
199        let body: PaymentConditionBody =
200            serde_json::from_str(json).expect("deserialize PaymentConditionBody");
201        assert_eq!(body.cost_sats, 42);
202    }
203
204    #[test]
205    fn deserialize_cost_as_string() {
206        let json = r#"{"@type": "acl:PaymentCondition", "acl:costSats": "100"}"#;
207        let body: PaymentConditionBody =
208            serde_json::from_str(json).expect("deserialize from string cost");
209        assert_eq!(body.cost_sats, 100);
210    }
211
212    #[test]
213    fn serialize_roundtrip() {
214        let body = PaymentConditionBody { cost_sats: 500 };
215        let json = serde_json::to_string(&body).unwrap();
216        assert!(json.contains("500"));
217        let parsed: PaymentConditionBody = serde_json::from_str(&json).unwrap();
218        assert_eq!(parsed.cost_sats, 500);
219    }
220}