Skip to main content

paykit_lib/payment_request/
api.rs

1use tracing::instrument;
2
3use crate::{
4    error::map_error, EncryptedLink, PaykitAppId, PrivateApplicationMessage, PrivateMessageKind,
5    Result,
6};
7
8use super::{
9    types::{
10        PaymentProof, PaymentRequest, PaymentRequestAcceptance, PaymentRequestCancellation,
11        PaymentRequestEvent, PaymentRequestEventMessage, PaymentRequestRejection,
12    },
13    wire::{
14        parse_acceptance_json, parse_cancellation_json, parse_conversion_quote_json,
15        parse_event_header, parse_payment_proof_json, parse_payment_request_json,
16        parse_rejection_json, serialize_acceptance_json, serialize_cancellation_json,
17        serialize_conversion_quote_json, serialize_payment_proof_json,
18        serialize_payment_request_json, serialize_rejection_json,
19    },
20    PaymentConversionQuote,
21};
22
23/// Parse `raw` as the Payment Request protocol event selected by `kind`, or
24/// return `None` when `kind` is not a Payment Request protocol event kind.
25///
26/// Routing and dispatch live in this single `match`, which deliberately has no
27/// wildcard arm: adding a `PrivateMessageKind` variant fails to compile until
28/// it is explicitly routed to a parser or rejected here, so the kind selector
29/// and the per-kind dispatch cannot drift apart.
30fn parse_event(kind: PrivateMessageKind, raw: &str) -> Option<Result<PaymentRequestEvent>> {
31    match kind {
32        PrivateMessageKind::PaymentRequest => {
33            Some(parse_payment_request_json(raw).map(PaymentRequestEvent::Request))
34        }
35        PrivateMessageKind::PaymentRequestAcceptance => {
36            Some(parse_acceptance_json(raw).map(PaymentRequestEvent::Acceptance))
37        }
38        PrivateMessageKind::PaymentRequestRejection => {
39            Some(parse_rejection_json(raw).map(PaymentRequestEvent::Rejection))
40        }
41        PrivateMessageKind::PaymentRequestCancellation => {
42            Some(parse_cancellation_json(raw).map(PaymentRequestEvent::Cancellation))
43        }
44        PrivateMessageKind::PaymentConversionQuote => {
45            Some(parse_conversion_quote_json(raw).map(PaymentRequestEvent::ConversionQuote))
46        }
47        PrivateMessageKind::PaymentProof => {
48            Some(parse_payment_proof_json(raw).map(PaymentRequestEvent::Proof))
49        }
50        // Non-request kinds are ignored, producing nothing derived from `raw`
51        // (decrypted private payload), so there is no error context to leak.
52        PrivateMessageKind::PrivatePaymentList
53        | PrivateMessageKind::DeliveryConfirmation
54        | PrivateMessageKind::ReceiptAccess
55        | PrivateMessageKind::AllowanceProposal
56        | PrivateMessageKind::AllowanceAcceptance
57        | PrivateMessageKind::AllowanceRejection
58        | PrivateMessageKind::AllowanceEnd => None,
59    }
60}
61
62/// Parse a raw Private Application Message as a Payment Request protocol event.
63///
64/// Returns `None` when the message kind is not one of the Payment Request
65/// protocol event kinds. Recognized but malformed Payment Request events return
66/// `Some` with [`PaymentRequestEventMessage::is_valid`] set to `false`.
67pub fn parse_payment_request_event_message(
68    message: &PrivateApplicationMessage,
69) -> Option<PaymentRequestEventMessage> {
70    let kind = message.known_kind()?;
71    let event = parse_event(kind, &message.raw_json)?.map_err(|err| err.to_string());
72    let (app_id, event_id, payment_request_id) = parse_event_header(&message.raw_json);
73    Some(PaymentRequestEventMessage {
74        kind,
75        app_id,
76        event_id,
77        payment_request_id,
78        raw_json: message.raw_json.clone(),
79        event,
80    })
81}
82
83/// Serialize a Payment Request protocol Event Message to canonical JSON.
84pub fn serialize_payment_request_event(
85    app_id: &PaykitAppId,
86    event: &PaymentRequestEvent,
87) -> Result<String> {
88    match event {
89        PaymentRequestEvent::Request(event) => serialize_payment_request_json(app_id, event),
90        PaymentRequestEvent::Acceptance(event) => serialize_acceptance_json(app_id, event),
91        PaymentRequestEvent::Rejection(event) => serialize_rejection_json(app_id, event),
92        PaymentRequestEvent::Cancellation(event) => serialize_cancellation_json(app_id, event),
93        PaymentRequestEvent::Proof(event) => serialize_payment_proof_json(app_id, event),
94        PaymentRequestEvent::ConversionQuote(event) => {
95            serialize_conversion_quote_json(app_id, event)
96        }
97    }
98}
99
100/// Send a `paykit.payment_request` Event Message.
101///
102/// Payment Requests are payee-initiated; caller code must enforce the sender
103/// role.
104#[instrument(skip(link, event))]
105pub async fn send_payment_request(
106    link: &mut EncryptedLink,
107    app_id: &PaykitAppId,
108    event: &PaymentRequest,
109) -> Result<()> {
110    let json = serialize_payment_request_json(app_id, event)
111        .map_err(|err| map_error("send_payment_request", err))?;
112    link.send_payment_request_message(json.as_bytes())
113        .await
114        .map_err(|err| map_error("send_payment_request", err))
115}
116
117/// Send a `paykit.payment_request_acceptance` Event Message.
118///
119/// Acceptances are payer-initiated; caller code must enforce the sender role.
120#[instrument(skip(link, event))]
121pub async fn send_payment_request_acceptance(
122    link: &mut EncryptedLink,
123    app_id: &PaykitAppId,
124    event: &PaymentRequestAcceptance,
125) -> Result<()> {
126    let json = serialize_acceptance_json(app_id, event)
127        .map_err(|err| map_error("send_payment_request_acceptance", err))?;
128    link.send_payment_request_acceptance_message(json.as_bytes())
129        .await
130        .map_err(|err| map_error("send_payment_request_acceptance", err))
131}
132
133/// Send a `paykit.payment_request_rejection` Event Message.
134///
135/// Rejections are payer-initiated; caller code must enforce the sender role.
136#[instrument(skip(link, event))]
137pub async fn send_payment_request_rejection(
138    link: &mut EncryptedLink,
139    app_id: &PaykitAppId,
140    event: &PaymentRequestRejection,
141) -> Result<()> {
142    let json = serialize_rejection_json(app_id, event)
143        .map_err(|err| map_error("send_payment_request_rejection", err))?;
144    link.send_payment_request_rejection_message(json.as_bytes())
145        .await
146        .map_err(|err| map_error("send_payment_request_rejection", err))
147}
148
149/// Send a `paykit.payment_request_cancellation` Event Message.
150///
151/// Cancellations may be sent by either payer or payee; caller code must enforce
152/// the sender role.
153#[instrument(skip(link, event))]
154pub async fn send_payment_request_cancellation(
155    link: &mut EncryptedLink,
156    app_id: &PaykitAppId,
157    event: &PaymentRequestCancellation,
158) -> Result<()> {
159    let json = serialize_cancellation_json(app_id, event)
160        .map_err(|err| map_error("send_payment_request_cancellation", err))?;
161    link.send_payment_request_cancellation_message(json.as_bytes())
162        .await
163        .map_err(|err| map_error("send_payment_request_cancellation", err))
164}
165
166/// Send a `paykit.payment_proof` Event Message.
167///
168/// Payment Proofs are payer-initiated; caller code must enforce the sender role.
169#[instrument(skip(link, event))]
170pub async fn send_payment_proof(
171    link: &mut EncryptedLink,
172    app_id: &PaykitAppId,
173    event: &PaymentProof,
174) -> Result<()> {
175    let json = serialize_payment_proof_json(app_id, event)
176        .map_err(|err| map_error("send_payment_proof", err))?;
177    link.send_payment_proof_message(json.as_bytes())
178        .await
179        .map_err(|err| map_error("send_payment_proof", err))
180}
181
182/// Send payee-issued rates for one recurring Billing Period.
183/// The caller owns role, session capabilities, key rotation and recurrence eligibility.
184#[instrument(skip(link, event))]
185pub async fn send_payment_conversion_quote(
186    link: &mut EncryptedLink,
187    app_id: &PaykitAppId,
188    event: &PaymentConversionQuote,
189) -> Result<()> {
190    let json = serialize_conversion_quote_json(app_id, event)
191        .map_err(|err| map_error("send_payment_conversion_quote", err))?;
192    link.send_payment_conversion_quote_message(json.as_bytes())
193        .await
194        .map_err(|err| map_error("send_payment_conversion_quote", err))
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn app_id() -> PaykitAppId {
202        PaykitAppId::new("test-app").unwrap()
203    }
204
205    // parse_event owns both routing and dispatch: non-request kinds must be
206    // ignored (`None`), not parsed and not turned into an error. Returning
207    // `None` means nothing derived from `raw` (decrypted private payload) is
208    // ever produced for these kinds, so there is no error context that could
209    // leak the plaintext. The public parser covers this path via `known_kind`;
210    // this exercises parse_event directly.
211    #[test]
212    fn test_parse_event_non_request_kind_ignored() {
213        // Sentinel plaintext standing in for a decrypted private payload.
214        let raw = "{\"secret\":\"SENTINEL_DECRYPTED_PLAINTEXT\"}";
215        for kind in [
216            PrivateMessageKind::PrivatePaymentList,
217            PrivateMessageKind::DeliveryConfirmation,
218            PrivateMessageKind::ReceiptAccess,
219            PrivateMessageKind::AllowanceProposal,
220            PrivateMessageKind::AllowanceAcceptance,
221            PrivateMessageKind::AllowanceRejection,
222            PrivateMessageKind::AllowanceEnd,
223        ] {
224            assert!(
225                parse_event(kind, raw).is_none(),
226                "expected non-request kind {kind} to be ignored",
227            );
228        }
229    }
230
231    use crate::{EventId, PaymentRequestId};
232
233    fn event_id() -> EventId {
234        EventId::new("8a0d8b4c-913f-4e31-9f2c-2a6f5bb4d201").expect("hard-coded UUID v4 is valid")
235    }
236
237    fn request_id() -> PaymentRequestId {
238        PaymentRequestId::new("b7f9c2a1-6d43-4b0e-a8d4-0fe2c712ab33")
239            .expect("hard-coded UUID v4 is valid")
240    }
241
242    // Build a received message whose header `kind` field claims
243    // `paykit.payment_request` regardless of the raw JSON payload, so every
244    // caller below also pins that routing never falls back to the header.
245    fn message_with_request_header(raw_json: &str) -> PrivateApplicationMessage {
246        PrivateApplicationMessage {
247            version: Some(1),
248            kind: Some(PrivateMessageKind::PaymentRequest.as_str().to_string()),
249            app_id: Some(app_id().as_str().to_string()),
250            raw_json: raw_json.to_string(),
251        }
252    }
253
254    // Serialize `event`, wrap it as a received Private Application Message,
255    // and parse it back through the public entry point, asserting structural
256    // equality and the parsed header ids.
257    fn assert_serialize_round_trip(event: PaymentRequestEvent) {
258        let serialized = serialize_payment_request_event(&app_id(), &event)
259            .expect("construction-valid event must serialize");
260        let message = PrivateApplicationMessage {
261            version: Some(1),
262            kind: Some(event.kind().as_str().to_string()),
263            app_id: Some(app_id().as_str().to_string()),
264            raw_json: serialized,
265        };
266        let parsed = parse_payment_request_event_message(&message)
267            .expect("serialized Payment Request event kind must be routed");
268        assert!(
269            parsed.is_valid(),
270            "round-tripped event must stay valid: {:?}",
271            parsed.validation_error()
272        );
273        assert_eq!(parsed.kind(), event.kind());
274        assert_eq!(parsed.event_id(), Some(event.event_id()));
275        assert_eq!(
276            parsed.payment_request_id(),
277            Some(event.payment_request_id())
278        );
279        assert_eq!(parsed.parsed_event(), Some(&event));
280    }
281
282    // Mirror of `payment_request_event_parser_uses_raw_json_headers` in
283    // `crate::tests::payment_request`, which pins a stale ReceiptAccess header
284    // routing a payment-request raw payload. Here the header claims
285    // `paykit.payment_request` while the raw JSON kind is
286    // `paykit.receipt_access`: the raw JSON kind wins, the message is not a
287    // Payment Request protocol event, and the parser must return `None`.
288    #[test]
289    fn test_parse_payment_request_event_message_raw_json_kind_wins_over_header() {
290        let message =
291            message_with_request_header(r#"{"version":1,"kind":"paykit.receipt_access"}"#);
292        assert!(parse_payment_request_event_message(&message).is_none());
293    }
294
295    // Pins current behavior for unroutable raw payloads: a missing,
296    // non-string, or unrecognized raw JSON `kind` yields `None` (not an
297    // error), even when the message header claims a request kind.
298    #[test]
299    fn test_parse_payment_request_event_message_unusable_raw_json_kind_is_none() {
300        for raw_json in [
301            r#"{"version":1}"#,
302            r#"{"version":1,"kind":42}"#,
303            r#"{"version":1,"kind":null}"#,
304            r#"{"version":1,"kind":true}"#,
305            r#"{"version":1,"kind":["paykit.payment_request"]}"#,
306            r#"{"version":1,"kind":{"kind":"paykit.payment_request"}}"#,
307            r#"{"version":1,"kind":"paykit.unknown_kind"}"#,
308        ] {
309            let message = message_with_request_header(raw_json);
310            assert!(
311                parse_payment_request_event_message(&message).is_none(),
312                "expected None for raw JSON {raw_json}"
313            );
314        }
315    }
316
317    #[test]
318    fn test_payment_request_acceptance_serialize_round_trip() {
319        assert_serialize_round_trip(PaymentRequestEvent::Acceptance(
320            PaymentRequestAcceptance::new(event_id(), request_id()),
321        ));
322    }
323
324    #[test]
325    fn test_payment_request_rejection_serialize_round_trip_covers_reason_presence() {
326        assert_serialize_round_trip(PaymentRequestEvent::Rejection(
327            PaymentRequestRejection::new(
328                event_id(),
329                request_id(),
330                Some("amount no longer payable".to_string()),
331            ),
332        ));
333
334        let without_reason = PaymentRequestEvent::Rejection(PaymentRequestRejection::new(
335            event_id(),
336            request_id(),
337            None,
338        ));
339        let serialized = serialize_payment_request_event(&app_id(), &without_reason)
340            .expect("construction-valid event must serialize");
341        assert!(
342            !serialized.contains("\"reason\""),
343            "a None reason must be omitted from the wire JSON"
344        );
345        assert_serialize_round_trip(without_reason);
346    }
347
348    #[test]
349    fn test_payment_request_cancellation_serialize_round_trip_covers_reason_presence() {
350        assert_serialize_round_trip(PaymentRequestEvent::Cancellation(
351            PaymentRequestCancellation::new(
352                event_id(),
353                request_id(),
354                Some("request superseded".to_string()),
355            ),
356        ));
357
358        let without_reason = PaymentRequestEvent::Cancellation(PaymentRequestCancellation::new(
359            event_id(),
360            request_id(),
361            None,
362        ));
363        let serialized = serialize_payment_request_event(&app_id(), &without_reason)
364            .expect("construction-valid event must serialize");
365        assert!(
366            !serialized.contains("\"reason\""),
367            "a None reason must be omitted from the wire JSON"
368        );
369        assert_serialize_round_trip(without_reason);
370    }
371}