1use std::fmt;
2
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
4use chacha20poly1305::{aead::OsRng, KeyInit, XChaCha20Poly1305};
5use serde_json::{Map as JsonMap, Value as JsonValue};
6use zeroize::{Zeroize, Zeroizing};
7
8use crate::{
9 validation::validate_uuid_v4, BillingPeriod, EventId, PaykitError, PaymentAmount,
10 PaymentEndpointIdentifier, PaymentReference, PaymentRequestId, PrivateMessageKind, PublicKey,
11 Result,
12};
13
14pub(crate) const RECEIPT_ENCRYPTION_ALGORITHM: &str = "XChaCha20Poly1305";
15pub const ENCRYPTED_RECEIPT_MAX_BYTES: usize = 256 * 1024;
17
18#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20pub struct ReceiptId(String);
21
22impl ReceiptId {
23 pub fn new(id: impl Into<String>) -> Result<Self> {
25 validate_uuid_v4(id.into(), "Receipt ID").map(Self)
26 }
27
28 pub fn new_v4() -> Self {
30 Self(uuid::Uuid::new_v4().to_string())
31 }
32
33 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37}
38
39impl std::fmt::Display for ReceiptId {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.write_str(&self.0)
42 }
43}
44
45impl AsRef<str> for ReceiptId {
46 fn as_ref(&self) -> &str {
47 &self.0
48 }
49}
50
51#[derive(Clone, PartialEq, Eq)]
55pub struct ReceiptDraft {
56 pub receipt_id: Option<ReceiptId>,
59 pub payment_reference: PaymentReference,
61 pub payment_request_id: Option<PaymentRequestId>,
63 pub billing_period: Option<BillingPeriod>,
65 pub payment_endpoint_identifier: Option<PaymentEndpointIdentifier>,
67 pub amount: Option<PaymentAmount>,
69 pub metadata: JsonMap<String, JsonValue>,
71}
72
73impl fmt::Debug for ReceiptDraft {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.debug_struct("ReceiptDraft")
76 .field("receipt_id", &self.receipt_id)
77 .field("payment_reference", &"<redacted>")
78 .field("payment_request_id", &self.payment_request_id)
79 .field("billing_period", &self.billing_period)
80 .field(
81 "payment_endpoint_identifier",
82 &self.payment_endpoint_identifier,
83 )
84 .field("amount", &self.amount.as_ref().map(|_| "<redacted>"))
85 .field(
86 "metadata",
87 &format_args!("<redacted:{} fields>", self.metadata.len()),
88 )
89 .finish()
90 }
91}
92
93#[derive(Clone, PartialEq, Eq)]
95pub struct Receipt {
96 pub receipt_id: ReceiptId,
98 pub payment_reference: PaymentReference,
100 pub payment_request_id: Option<PaymentRequestId>,
102 pub billing_period: Option<BillingPeriod>,
104 pub recipient_public_key: PublicKey,
106 pub payment_endpoint_identifier: Option<PaymentEndpointIdentifier>,
108 pub amount: Option<PaymentAmount>,
110 pub metadata: JsonMap<String, JsonValue>,
112}
113
114impl fmt::Debug for Receipt {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.debug_struct("Receipt")
117 .field("receipt_id", &self.receipt_id)
118 .field("payment_reference", &"<redacted>")
119 .field("payment_request_id", &self.payment_request_id)
120 .field("billing_period", &self.billing_period)
121 .field("recipient_public_key", &self.recipient_public_key)
122 .field(
123 "payment_endpoint_identifier",
124 &self.payment_endpoint_identifier,
125 )
126 .field("amount", &self.amount.as_ref().map(|_| "<redacted>"))
127 .field(
128 "metadata",
129 &format_args!("<redacted:{} fields>", self.metadata.len()),
130 )
131 .finish()
132 }
133}
134
135#[derive(Clone, PartialEq, Eq)]
145pub struct ReceiptDecryptionKey(String);
146
147impl ReceiptDecryptionKey {
148 pub fn generate() -> Self {
150 let mut key = XChaCha20Poly1305::generate_key(&mut OsRng);
151 let encoded = URL_SAFE_NO_PAD.encode(key.as_slice());
152 key.as_mut_slice().zeroize();
155 Self(encoded)
156 }
157
158 pub fn new(key: impl Into<String>) -> Result<Self> {
160 let mut key = key.into();
168 let mut decoded: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::new());
169 if URL_SAFE_NO_PAD.decode_vec(&key, &mut decoded).is_err() {
173 key.zeroize();
174 return Err(PaykitError::Validation(
175 "Receipt Decryption Key must be base64url".into(),
176 ));
177 }
178 if decoded.len() != 32 {
179 key.zeroize();
180 return Err(PaykitError::Validation(
181 "Receipt Decryption Key must decode to 32 bytes".into(),
182 ));
183 }
184 Ok(Self(key))
187 }
188
189 pub fn as_str(&self) -> &str {
193 &self.0
194 }
195
196 pub(super) fn bytes(&self) -> Result<[u8; 32]> {
197 let mut decoded =
201 URL_SAFE_NO_PAD
202 .decode(&self.0)
203 .map_err(|_| PaykitError::InvalidData {
204 context: "Receipt Decryption Key is not valid base64url".into(),
205 source: None,
206 })?;
207 let key_bytes =
208 <[u8; 32]>::try_from(decoded.as_slice()).map_err(|_| PaykitError::InvalidData {
209 context: "Receipt Decryption Key must decode to 32 bytes".into(),
210 source: None,
211 });
212 decoded.zeroize();
215 key_bytes
216 }
217}
218
219impl AsRef<str> for ReceiptDecryptionKey {
220 fn as_ref(&self) -> &str {
221 &self.0
222 }
223}
224
225impl std::fmt::Debug for ReceiptDecryptionKey {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 f.write_str("ReceiptDecryptionKey([redacted])")
228 }
229}
230
231impl std::fmt::Display for ReceiptDecryptionKey {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 f.write_str("[redacted Receipt Decryption Key]")
234 }
235}
236
237impl Drop for ReceiptDecryptionKey {
244 fn drop(&mut self) {
245 self.0.zeroize();
246 }
247}
248
249#[derive(Clone, PartialEq, Eq)]
254pub struct PreparedReceipt {
255 pub receipt: Receipt,
257 pub encrypted_receipt: String,
259 pub access: ReceiptAccess,
261}
262
263impl fmt::Debug for PreparedReceipt {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 f.debug_struct("PreparedReceipt")
266 .field("receipt", &self.receipt)
267 .field(
268 "encrypted_receipt",
269 &format_args!("<redacted:{} bytes>", self.encrypted_receipt.len()),
270 )
271 .field("access", &self.access)
272 .finish()
273 }
274}
275
276#[derive(Clone, PartialEq, Eq)]
278pub struct ReceiptAccess {
279 pub version: u8,
281 pub kind: PrivateMessageKind,
283 pub event_id: EventId,
285 pub receipt_id: ReceiptId,
287 pub payment_reference: PaymentReference,
289 pub payment_request_id: Option<PaymentRequestId>,
291 pub billing_period: Option<BillingPeriod>,
293 pub location: String,
295 pub key: ReceiptDecryptionKey,
297}
298
299impl fmt::Debug for ReceiptAccess {
300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301 f.debug_struct("ReceiptAccess")
302 .field("version", &self.version)
303 .field("kind", &self.kind)
304 .field("event_id", &self.event_id)
305 .field("receipt_id", &self.receipt_id)
306 .field("payment_reference", &"<redacted>")
307 .field("payment_request_id", &self.payment_request_id)
308 .field("billing_period", &self.billing_period)
309 .field("location", &"<redacted>")
310 .field("key", &"<redacted>")
311 .finish()
312 }
313}
314
315#[derive(Clone, PartialEq)]
318pub struct ReceiptAccessEventMessage {
319 pub kind: PrivateMessageKind,
321 pub app_id: Option<crate::PaykitAppId>,
323 pub event_id: Option<EventId>,
325 pub receipt_id: Option<ReceiptId>,
327 pub raw_json: String,
329 pub access: std::result::Result<ReceiptAccess, String>,
332}
333
334impl fmt::Debug for ReceiptAccessEventMessage {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 f.debug_struct("ReceiptAccessEventMessage")
337 .field("kind", &self.kind)
338 .field("app_id", &self.app_id)
339 .field("event_id", &self.event_id)
340 .field("receipt_id", &self.receipt_id)
341 .field(
342 "raw_json",
343 &format_args!("<redacted:{} bytes>", self.raw_json.len()),
344 )
345 .field("parsed", &self.access.is_ok())
346 .field("validation_error", &self.validation_error())
347 .finish()
348 }
349}
350
351impl ReceiptAccessEventMessage {
352 pub fn kind(&self) -> PrivateMessageKind {
354 self.kind
355 }
356
357 pub fn app_id(&self) -> Option<&crate::PaykitAppId> {
359 self.app_id.as_ref()
360 }
361
362 pub fn is_valid(&self) -> bool {
364 self.access.is_ok()
365 }
366
367 pub fn parsed_access(&self) -> Option<&ReceiptAccess> {
369 self.access.as_ref().ok()
370 }
371
372 pub fn validation_error(&self) -> Option<&str> {
374 self.access.as_ref().err().map(String::as_str)
375 }
376
377 pub fn event_id(&self) -> Option<&EventId> {
382 self.event_id.as_ref()
383 }
384
385 pub fn receipt_id(&self) -> Option<&ReceiptId> {
390 self.receipt_id.as_ref()
391 }
392}
393
394fn validate_request_context(
395 payment_request_id: Option<&PaymentRequestId>,
396 billing_period: Option<&BillingPeriod>,
397 label: &str,
398) -> Result<()> {
399 if payment_request_id.is_none() && billing_period.is_some() {
400 return Err(PaykitError::Validation(format!(
401 "{label} billing_period requires payment_request_id"
402 )));
403 }
404 if let Some(period) = billing_period {
405 period.validate_with_label(label)?;
406 }
407 Ok(())
408}
409
410impl ReceiptDraft {
411 pub(crate) fn validate_request_context(&self) -> Result<()> {
412 validate_request_context(
413 self.payment_request_id.as_ref(),
414 self.billing_period.as_ref(),
415 "Receipt Draft",
416 )
417 }
418}
419
420impl Receipt {
421 pub(crate) fn validate_request_context(&self) -> Result<()> {
422 validate_request_context(
423 self.payment_request_id.as_ref(),
424 self.billing_period.as_ref(),
425 "Receipt",
426 )
427 }
428}
429
430impl ReceiptAccess {
431 pub(crate) fn validate_request_context(&self) -> Result<()> {
432 validate_request_context(
433 self.payment_request_id.as_ref(),
434 self.billing_period.as_ref(),
435 "Receipt Access",
436 )
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn receipt_debug_redacts_payment_reference() {
446 let payment_reference = PaymentReference::new("invoice-secret-123").unwrap();
447 let draft = ReceiptDraft {
448 receipt_id: Some(ReceiptId::new_v4()),
449 payment_reference: payment_reference.clone(),
450 payment_request_id: Some(PaymentRequestId::new_v4()),
451 billing_period: None,
452 payment_endpoint_identifier: Some(
453 PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap(),
454 ),
455 amount: Some(PaymentAmount::new("0.001".to_string(), "btc".to_string()).unwrap()),
456 metadata: JsonMap::from_iter([(
457 "note".to_string(),
458 JsonValue::String("private receipt note".to_string()),
459 )]),
460 };
461 let receipt = Receipt {
462 receipt_id: draft.receipt_id.clone().unwrap(),
463 payment_reference,
464 payment_request_id: draft.payment_request_id.clone(),
465 billing_period: None,
466 recipient_public_key: pubky::Keypair::random().public_key().clone(),
467 payment_endpoint_identifier: draft.payment_endpoint_identifier.clone(),
468 amount: draft.amount.clone(),
469 metadata: draft.metadata.clone(),
470 };
471
472 let access = ReceiptAccess {
473 version: 1,
474 kind: PrivateMessageKind::ReceiptAccess,
475 event_id: EventId::new_v4(),
476 receipt_id: receipt.receipt_id.clone(),
477 payment_reference: receipt.payment_reference.clone(),
478 payment_request_id: receipt.payment_request_id.clone(),
479 billing_period: None,
480 location: ReceiptAccess::location_for(&receipt.receipt_id),
481 key: ReceiptDecryptionKey::generate(),
482 };
483 let prepared = PreparedReceipt {
484 receipt: receipt.clone(),
485 encrypted_receipt: "encrypted-secret".into(),
486 access: access.clone(),
487 };
488
489 for debug in [
490 format!("{draft:?}"),
491 format!("{receipt:?}"),
492 format!("{access:?}"),
493 format!("{prepared:?}"),
494 ] {
495 assert!(!debug.contains("invoice-secret-123"));
496 assert!(!debug.contains("private receipt note"));
497 assert!(!debug.contains("encrypted-secret"));
498 assert!(!debug.contains(access.key.as_str()));
499 assert!(!debug.contains(&access.location));
500 }
501 }
502
503 #[test]
504 fn test_receipt_decryption_key_new_rejects_non_32_byte_material() {
505 let short = URL_SAFE_NO_PAD.encode([7u8; 16]);
507 assert!(matches!(
508 ReceiptDecryptionKey::new(short).unwrap_err(),
509 PaykitError::Validation(_)
510 ));
511 }
512
513 #[test]
514 fn test_receipt_decryption_key_new_rejects_non_base64url() {
515 assert!(matches!(
516 ReceiptDecryptionKey::new("not valid base64!!").unwrap_err(),
517 PaykitError::Validation(_)
518 ));
519 }
520
521 #[test]
522 fn test_receipt_decryption_key_bytes_roundtrip() {
523 let key = ReceiptDecryptionKey::generate();
526 let bytes = key.bytes().unwrap();
527 assert_eq!(URL_SAFE_NO_PAD.encode(bytes), key.as_str());
528 }
529
530 #[test]
531 fn test_receipt_decryption_key_clone_survives_dropped_original() {
532 let expected;
535 let clone;
536 {
537 let original = ReceiptDecryptionKey::generate();
538 expected = original.as_str().to_string();
539 clone = original.clone();
540 }
542 assert_eq!(clone.as_str(), expected);
543 assert_eq!(clone.bytes().unwrap().len(), 32);
544 }
545}