Skip to main content

paykit_lib/receipt/
access.rs

1use tracing::{debug, instrument};
2
3use crate::{
4    error::map_error, EncryptedLink, PaykitAppId, PaykitError, PrivateMessageKind, PublicKey,
5    Result, PAYKIT_PRIVATE_PATH_PREFIX,
6};
7
8use super::{
9    wire::serialize_receipt_access_json, PreparedReceipt, Receipt, ReceiptAccess,
10    ReceiptDecryptionKey, ReceiptDraft, ReceiptId,
11};
12
13impl ReceiptAccess {
14    /// Return the canonical Receipt Location path for a Receipt ID.
15    pub fn location_for(receipt_id: &ReceiptId) -> String {
16        format!("{PAYKIT_PRIVATE_PATH_PREFIX}/receipts/{receipt_id}")
17    }
18
19    /// Validate that this access descriptor points at the canonical location for
20    /// its Receipt ID.
21    ///
22    /// This public validator is for caller-supplied values and returns
23    /// [`PaykitError::Validation`] on mismatch. Wire parsing maps the same
24    /// mismatch to [`PaykitError::InvalidData`] because incoming private message
25    /// payloads are external data.
26    pub fn validate_location(&self) -> Result<()> {
27        if !self.has_canonical_location() {
28            return Err(PaykitError::Validation(
29                "Receipt Access location does not match Receipt ID".into(),
30            ));
31        }
32        Ok(())
33    }
34
35    /// Validate caller-supplied Receipt Access before sending or storing.
36    pub fn validate(&self) -> Result<()> {
37        if self.version != 1 {
38            return Err(PaykitError::Validation(
39                "Receipt Access version must be 1".into(),
40            ));
41        }
42        if self.kind != PrivateMessageKind::ReceiptAccess {
43            return Err(PaykitError::Validation(
44                "Receipt Access kind must be paykit.receipt_access".into(),
45            ));
46        }
47        self.validate_request_context()?;
48        self.validate_location()
49    }
50
51    pub(crate) fn validate_wire_location(&self) -> Result<()> {
52        if !self.has_canonical_location() {
53            return Err(PaykitError::InvalidData {
54                context: "Receipt Access location does not match Receipt ID".into(),
55                source: None,
56            });
57        }
58        Ok(())
59    }
60
61    fn has_canonical_location(&self) -> bool {
62        let expected_location = Self::location_for(&self.receipt_id);
63        self.location == expected_location
64    }
65}
66
67/// Prepare a plaintext Receipt, Encrypted Receipt, and Receipt Access
68/// descriptor without touching the network.
69///
70/// The returned [`PreparedReceipt`] contains the Receipt Decryption Key and
71/// must be handled as sensitive data.
72#[instrument(skip(link, draft))]
73pub fn prepare_receipt(link: &EncryptedLink, draft: ReceiptDraft) -> Result<PreparedReceipt> {
74    prepare_receipt_for_recipient(link.recipient().clone(), draft)
75}
76
77/// Prepare a plaintext Receipt, Encrypted Receipt, and Receipt Access
78/// descriptor for an explicit recipient public key.
79///
80/// This is useful for stateful runtimes that queue Receipt Access delivery
81/// separately from receipt preparation.
82#[instrument(skip(recipient_public_key, draft))]
83pub fn prepare_receipt_for_recipient(
84    recipient_public_key: PublicKey,
85    draft: ReceiptDraft,
86) -> Result<PreparedReceipt> {
87    debug!("preparing encrypted receipt");
88    draft.validate_request_context()?;
89    let receipt_id = draft.receipt_id.unwrap_or_else(ReceiptId::new_v4);
90    if let Some(amount) = &draft.amount {
91        amount.validate_with_label("Receipt amount")?;
92    }
93    let payment_reference = draft.payment_reference;
94    let payment_request_id = draft.payment_request_id;
95    let billing_period = draft.billing_period;
96    let location = ReceiptAccess::location_for(&receipt_id);
97    let key = ReceiptDecryptionKey::generate();
98    let receipt = Receipt {
99        receipt_id: receipt_id.clone(),
100        payment_reference: payment_reference.clone(),
101        payment_request_id: payment_request_id.clone(),
102        billing_period: billing_period.clone(),
103        recipient_public_key,
104        payment_endpoint_identifier: draft.payment_endpoint_identifier,
105        amount: draft.amount,
106        metadata: draft.metadata,
107    };
108    let encrypted_receipt = receipt
109        .encrypt(&key)
110        .map_err(|err| map_error("prepare_receipt", err))?;
111    let access = ReceiptAccess {
112        version: 1,
113        kind: PrivateMessageKind::ReceiptAccess,
114        event_id: crate::EventId::new_v4(),
115        receipt_id,
116        payment_reference,
117        payment_request_id,
118        billing_period,
119        location,
120        key,
121    };
122
123    Ok(PreparedReceipt {
124        receipt,
125        encrypted_receipt,
126        access,
127    })
128}
129
130/// Store a prepared Encrypted Receipt at its Receipt Location.
131///
132/// This only performs the homeserver write; send the access descriptor with
133/// [`send_receipt_access`].
134#[instrument(skip(session, prepared))]
135pub async fn store_prepared_receipt(
136    session: &pubky::PubkySession,
137    prepared: &PreparedReceipt,
138) -> Result<()> {
139    debug!("storing prepared encrypted receipt");
140    validate_prepared_receipt(prepared)?;
141
142    session
143        .storage()
144        .put(
145            prepared.access.location.clone(),
146            prepared.encrypted_receipt.clone(),
147        )
148        .await
149        .map_err(|err| store_prepared_receipt_error(&prepared.access.location, err.into()))?;
150
151    Ok(())
152}
153
154/// Build the transport error returned when storing a Prepared Receipt fails.
155///
156/// SECURITY / REDACTION: `location` is the identity-wide Receipt Location
157/// (`/pub/paykit/v0/private/receipts/{id}`). It contains a Receipt ID and must
158/// not be embedded in caller-facing error text.
159/// The location is accepted here only to make the redaction explicit and
160/// testable; it is deliberately dropped, leaving a static, non-sensitive label.
161/// The concrete cause stays in `source`, which is not rendered across the FFI.
162pub(crate) fn store_prepared_receipt_error(_location: &str, source: anyhow::Error) -> PaykitError {
163    PaykitError::Transport {
164        context: "failed to store encrypted receipt".into(),
165        source,
166    }
167}
168
169pub(super) fn validate_prepared_receipt(prepared: &PreparedReceipt) -> Result<()> {
170    prepared.access.validate()?;
171    if prepared.receipt.receipt_id != prepared.access.receipt_id {
172        return Err(PaykitError::Validation(
173            "Prepared Receipt plaintext Receipt ID does not match Receipt Access".into(),
174        ));
175    }
176    if prepared.receipt.payment_reference != prepared.access.payment_reference {
177        return Err(PaykitError::Validation(
178            "Prepared Receipt plaintext Payment Reference does not match Receipt Access".into(),
179        ));
180    }
181    if prepared.receipt.payment_request_id != prepared.access.payment_request_id {
182        return Err(PaykitError::Validation(
183            "Prepared Receipt plaintext Payment Request ID does not match Receipt Access".into(),
184        ));
185    }
186    if prepared.receipt.billing_period != prepared.access.billing_period {
187        return Err(PaykitError::Validation(
188            "Prepared Receipt plaintext Billing Period does not match Receipt Access".into(),
189        ));
190    }
191    let decrypted = Receipt::decrypt(
192        &prepared.encrypted_receipt,
193        &prepared.access.key,
194        &prepared.access.location,
195    )
196    .map_err(|err| {
197        PaykitError::Validation(format!(
198            "Prepared Receipt encrypted payload does not decrypt with Receipt Access: {err}"
199        ))
200    })?;
201
202    if decrypted != prepared.receipt {
203        return Err(PaykitError::Validation(
204            "Prepared Receipt encrypted payload does not match plaintext Receipt".into(),
205        ));
206    }
207
208    Ok(())
209}
210
211/// Send a prepared Receipt Access descriptor over an Encrypted Link.
212#[instrument(skip(link, access))]
213pub async fn send_receipt_access(
214    link: &mut EncryptedLink,
215    app_id: &PaykitAppId,
216    access: &ReceiptAccess,
217) -> Result<()> {
218    debug!("sending Receipt Access message");
219    access.validate()?;
220    let json = serialize_receipt_access_json(app_id, access)
221        .map_err(|err| map_error("send_receipt_access", err))?;
222    link.send_receipt_access_message(json.as_bytes())
223        .await
224        .map_err(|err| map_error("send_receipt_access", err))
225}