paykit_lib/receipt/
access.rs1use 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 pub fn location_for(receipt_id: &ReceiptId) -> String {
16 format!("{PAYKIT_PRIVATE_PATH_PREFIX}/receipts/{receipt_id}")
17 }
18
19 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 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#[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#[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#[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
154pub(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#[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}