Skip to main content

paykit_lib/receipt/
wire.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map as JsonMap, Value as JsonValue};
3
4use crate::{
5    shared_wire::{deserialize_optional_no_null, BillingPeriodWire, PaymentAmountWire},
6    validation::{
7        invalid_data, invalid_plaintext_json, validate_wire_version_kind,
8        validate_wire_version_kind_str,
9    },
10    EventId, PaykitAppId, PaykitError, PaymentEndpointIdentifier, PaymentReference,
11    PaymentRequestId, PrivateApplicationMessage, PrivateMessageKind, PublicKey, Result,
12};
13
14use super::{Receipt, ReceiptAccess, ReceiptAccessEventMessage, ReceiptDecryptionKey, ReceiptId};
15
16#[derive(Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub(super) struct ReceiptWire {
19    pub(super) version: u8,
20    pub(super) kind: String,
21    pub(super) receipt_id: String,
22    pub(super) payment_reference: String,
23    #[serde(default)]
24    #[serde(deserialize_with = "deserialize_optional_no_null")]
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub(super) payment_request_id: Option<String>,
27    #[serde(default)]
28    #[serde(deserialize_with = "deserialize_optional_no_null")]
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub(super) billing_period: Option<BillingPeriodWire>,
31    pub(super) recipient_public_key: String,
32    pub(super) payment_endpoint_identifier: Option<String>,
33    pub(super) amount: Option<PaymentAmountWire>,
34    pub(super) metadata: JsonMap<String, JsonValue>,
35}
36
37#[derive(Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub(super) struct EncryptedReceiptWire {
40    pub(super) version: u8,
41    pub(super) kind: String,
42    pub(super) algorithm: String,
43    pub(super) nonce: String,
44    pub(super) ciphertext: String,
45}
46
47#[derive(Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub(super) struct ReceiptAccessWire {
50    pub(super) version: u8,
51    pub(super) kind: String,
52    pub(super) app_id: String,
53    pub(super) event_id: String,
54    pub(super) receipt_id: String,
55    pub(super) payment_reference: String,
56    #[serde(default)]
57    #[serde(deserialize_with = "deserialize_optional_no_null")]
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub(super) payment_request_id: Option<String>,
60    #[serde(default)]
61    #[serde(deserialize_with = "deserialize_optional_no_null")]
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub(super) billing_period: Option<BillingPeriodWire>,
64    pub(super) location: String,
65    pub(super) key: String,
66}
67
68impl From<&Receipt> for ReceiptWire {
69    fn from(receipt: &Receipt) -> Self {
70        Self {
71            version: 1,
72            kind: "paykit.receipt".to_string(),
73            receipt_id: receipt.receipt_id.as_str().to_string(),
74            payment_reference: receipt.payment_reference.as_str().to_string(),
75            payment_request_id: receipt
76                .payment_request_id
77                .as_ref()
78                .map(|id| id.as_str().to_string()),
79            billing_period: receipt.billing_period.as_ref().map(BillingPeriodWire::from),
80            recipient_public_key: receipt.recipient_public_key.to_string(),
81            payment_endpoint_identifier: receipt
82                .payment_endpoint_identifier
83                .as_ref()
84                .map(|identifier| identifier.as_str().to_string()),
85            amount: receipt.amount.as_ref().map(PaymentAmountWire::from),
86            metadata: receipt.metadata.clone(),
87        }
88    }
89}
90
91impl TryFrom<ReceiptWire> for Receipt {
92    type Error = PaykitError;
93
94    fn try_from(wire: ReceiptWire) -> Result<Self> {
95        validate_wire_version_kind_str(wire.version, &wire.kind, "paykit.receipt", "Receipt")?;
96        let receipt_id =
97            ReceiptId::new(wire.receipt_id).map_err(|err| PaykitError::InvalidData {
98                context: "Receipt contains invalid Receipt ID".into(),
99                source: Some(err.into()),
100            })?;
101        let payment_reference = PaymentReference::new(wire.payment_reference).map_err(|err| {
102            PaykitError::InvalidData {
103                context: "Receipt contains invalid Payment Reference".into(),
104                source: Some(err.into()),
105            }
106        })?;
107        let payment_request_id = wire
108            .payment_request_id
109            .map(PaymentRequestId::new)
110            .transpose()
111            .map_err(|err| PaykitError::InvalidData {
112                context: "Receipt contains invalid Payment Request ID".into(),
113                source: Some(err.into()),
114            })?;
115        let billing_period = wire
116            .billing_period
117            .map(|wire| wire.try_into_with_label("Receipt Billing Period"))
118            .transpose()
119            .map_err(|err| PaykitError::InvalidData {
120                context: "Receipt contains invalid Billing Period".into(),
121                source: Some(err.into()),
122            })?;
123
124        // The parse error's Debug output can echo the offending decrypted field
125        // value; keep the context static and leave the detail in `source`,
126        // which stays local.
127        let recipient_public_key = PublicKey::try_from(wire.recipient_public_key.as_str())
128            .map_err(|err| PaykitError::InvalidData {
129                context: "Receipt contains invalid recipient public key".into(),
130                source: anyhow::anyhow!("invalid recipient public key: {err:?}").into(),
131            })?;
132        let payment_endpoint_identifier = wire
133            .payment_endpoint_identifier
134            .map(PaymentEndpointIdentifier::new)
135            .transpose()
136            .map_err(|err| PaykitError::InvalidData {
137                context: "Receipt contains invalid Payment Endpoint Identifier".into(),
138                source: Some(err.into()),
139            })?;
140        let amount = wire
141            .amount
142            .map(|wire| wire.try_into_with_label("Receipt amount"))
143            .transpose()
144            .map_err(|err| PaykitError::InvalidData {
145                context: "Receipt contains invalid Payment Amount".into(),
146                source: Some(err.into()),
147            })?;
148
149        let receipt = Self {
150            receipt_id,
151            payment_reference,
152            payment_request_id,
153            billing_period,
154            recipient_public_key,
155            payment_endpoint_identifier,
156            amount,
157            metadata: wire.metadata,
158        };
159        receipt
160            .validate_request_context()
161            .map_err(|err| PaykitError::InvalidData {
162                context: "Receipt contains invalid Payment Request context".into(),
163                source: Some(err.into()),
164            })?;
165        Ok(receipt)
166    }
167}
168
169impl ReceiptAccessWire {
170    fn from_access(app_id: &PaykitAppId, access: &ReceiptAccess) -> Self {
171        Self {
172            version: access.version,
173            kind: access.kind.as_str().to_string(),
174            app_id: app_id.as_str().to_string(),
175            event_id: access.event_id.as_str().to_string(),
176            receipt_id: access.receipt_id.as_str().to_string(),
177            payment_reference: access.payment_reference.as_str().to_string(),
178            payment_request_id: access
179                .payment_request_id
180                .as_ref()
181                .map(|id| id.as_str().to_string()),
182            billing_period: access.billing_period.as_ref().map(BillingPeriodWire::from),
183            location: access.location.clone(),
184            key: access.key.as_str().to_string(),
185        }
186    }
187}
188
189impl TryFrom<ReceiptAccessWire> for ReceiptAccess {
190    type Error = PaykitError;
191
192    fn try_from(wire: ReceiptAccessWire) -> Result<Self> {
193        validate_wire_version_kind(
194            wire.version,
195            &wire.kind,
196            PrivateMessageKind::ReceiptAccess,
197            "Receipt Access",
198        )?;
199        PaykitAppId::new(&wire.app_id).map_err(|err| PaykitError::InvalidData {
200            context: "Receipt Access contains invalid App ID".into(),
201            source: Some(err.into()),
202        })?;
203        let event_id = EventId::new(wire.event_id).map_err(|err| PaykitError::InvalidData {
204            context: "Receipt Access contains invalid Event ID".into(),
205            source: Some(err.into()),
206        })?;
207        let receipt_id =
208            ReceiptId::new(wire.receipt_id).map_err(|err| PaykitError::InvalidData {
209                context: "Receipt Access contains invalid Receipt ID".into(),
210                source: Some(err.into()),
211            })?;
212        let payment_reference = PaymentReference::new(wire.payment_reference).map_err(|err| {
213            PaykitError::InvalidData {
214                context: "Receipt Access contains invalid Payment Reference".into(),
215                source: Some(err.into()),
216            }
217        })?;
218        let payment_request_id = wire
219            .payment_request_id
220            .map(PaymentRequestId::new)
221            .transpose()
222            .map_err(|err| PaykitError::InvalidData {
223                context: "Receipt Access contains invalid Payment Request ID".into(),
224                source: Some(err.into()),
225            })?;
226        let billing_period = wire
227            .billing_period
228            .map(|wire| wire.try_into_with_label("Receipt Access Billing Period"))
229            .transpose()
230            .map_err(|err| PaykitError::InvalidData {
231                context: "Receipt Access contains invalid Billing Period".into(),
232                source: Some(err.into()),
233            })?;
234
235        let access = Self {
236            version: 1,
237            kind: PrivateMessageKind::ReceiptAccess,
238            event_id,
239            receipt_id,
240            payment_reference,
241            payment_request_id,
242            billing_period,
243            location: wire.location,
244            key: ReceiptDecryptionKey::new(wire.key).map_err(|err| PaykitError::InvalidData {
245                context: "Receipt Access contains invalid Receipt Decryption Key".into(),
246                source: Some(err.into()),
247            })?,
248        };
249        access
250            .validate_request_context()
251            .map_err(|err| PaykitError::InvalidData {
252                context: "Receipt Access contains invalid Payment Request context".into(),
253                source: Some(err.into()),
254            })?;
255        access.validate_wire_location()?;
256        Ok(access)
257    }
258}
259
260/// Serialize an app-attributed Receipt Access Event Message.
261pub fn serialize_receipt_access_json(
262    app_id: &PaykitAppId,
263    access: &ReceiptAccess,
264) -> Result<String> {
265    serde_json::to_string(&ReceiptAccessWire::from_access(app_id, access)).map_err(|err| {
266        invalid_data(
267            format!("failed to serialize Receipt Access JSON: {err}"),
268            Some(err.into()),
269        )
270    })
271}
272
273/// Parse a Receipt Access JSON message.
274///
275/// Use [`parse_receipt_access_event_message`] when parsing from the raw private
276/// stream.
277pub fn parse_receipt_access_json(json: &str) -> Result<ReceiptAccess> {
278    // SECURITY / REDACTION: `json` is decrypted private-message plaintext
279    // carrying the Receipt Decryption Key and Receipt Location. The serde
280    // error's Display embeds field values on type mismatches, so it must not
281    // be folded into the context or kept as `source` -- this error can cross
282    // the FFI boundary as exception text.
283    let wire: ReceiptAccessWire = serde_json::from_str(json)
284        .map_err(|_| invalid_plaintext_json("failed to parse Receipt Access JSON"))?;
285    ReceiptAccess::try_from(wire)
286}
287
288fn parse_receipt_access_header(
289    raw: &str,
290) -> (Option<PaykitAppId>, Option<EventId>, Option<ReceiptId>) {
291    let Ok(value) = serde_json::from_str::<JsonValue>(raw) else {
292        return (None, None, None);
293    };
294    let app_id = value
295        .get("app_id")
296        .and_then(JsonValue::as_str)
297        .and_then(|id| PaykitAppId::new(id).ok());
298    let event_id = value
299        .get("event_id")
300        .and_then(JsonValue::as_str)
301        .and_then(|id| EventId::new(id).ok());
302    let receipt_id = value
303        .get("receipt_id")
304        .and_then(JsonValue::as_str)
305        .and_then(|id| ReceiptId::new(id).ok());
306    (app_id, event_id, receipt_id)
307}
308
309/// Parse a raw Private Application Message as a Receipt Access Event Message.
310///
311/// Returns `None` when the message kind is not `paykit.receipt_access`.
312/// Recognized but malformed Receipt Access events return `Some` with
313/// [`ReceiptAccessEventMessage::is_valid`] set to `false`.
314pub fn parse_receipt_access_event_message(
315    message: &PrivateApplicationMessage,
316) -> Option<ReceiptAccessEventMessage> {
317    let kind = message.known_kind()?;
318    (kind == PrivateMessageKind::ReceiptAccess).then(|| {
319        let (app_id, event_id, receipt_id) = parse_receipt_access_header(&message.raw_json);
320        let access = parse_receipt_access_json(&message.raw_json).map_err(|err| err.to_string());
321        ReceiptAccessEventMessage {
322            kind,
323            app_id,
324            event_id,
325            receipt_id,
326            raw_json: message.raw_json.clone(),
327            access,
328        }
329    })
330}