use std::fmt;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chacha20poly1305::{aead::OsRng, KeyInit, XChaCha20Poly1305};
use serde_json::{Map as JsonMap, Value as JsonValue};
use zeroize::{Zeroize, Zeroizing};
use crate::{
validation::validate_uuid_v4, BillingPeriod, EventId, PaykitError, PaymentAmount,
PaymentEndpointIdentifier, PaymentReference, PaymentRequestId, PrivateMessageKind, PublicKey,
Result,
};
pub(crate) const RECEIPT_ENCRYPTION_ALGORITHM: &str = "XChaCha20Poly1305";
pub const ENCRYPTED_RECEIPT_MAX_BYTES: usize = 256 * 1024;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ReceiptId(String);
impl ReceiptId {
pub fn new(id: impl Into<String>) -> Result<Self> {
validate_uuid_v4(id.into(), "Receipt ID").map(Self)
}
pub fn new_v4() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ReceiptId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ReceiptId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ReceiptDraft {
pub receipt_id: Option<ReceiptId>,
pub payment_reference: PaymentReference,
pub payment_request_id: Option<PaymentRequestId>,
pub billing_period: Option<BillingPeriod>,
pub payment_endpoint_identifier: Option<PaymentEndpointIdentifier>,
pub amount: Option<PaymentAmount>,
pub metadata: JsonMap<String, JsonValue>,
}
impl fmt::Debug for ReceiptDraft {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReceiptDraft")
.field("receipt_id", &self.receipt_id)
.field("payment_reference", &"<redacted>")
.field("payment_request_id", &self.payment_request_id)
.field("billing_period", &self.billing_period)
.field(
"payment_endpoint_identifier",
&self.payment_endpoint_identifier,
)
.field("amount", &self.amount.as_ref().map(|_| "<redacted>"))
.field(
"metadata",
&format_args!("<redacted:{} fields>", self.metadata.len()),
)
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Receipt {
pub receipt_id: ReceiptId,
pub payment_reference: PaymentReference,
pub payment_request_id: Option<PaymentRequestId>,
pub billing_period: Option<BillingPeriod>,
pub recipient_public_key: PublicKey,
pub payment_endpoint_identifier: Option<PaymentEndpointIdentifier>,
pub amount: Option<PaymentAmount>,
pub metadata: JsonMap<String, JsonValue>,
}
impl fmt::Debug for Receipt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Receipt")
.field("receipt_id", &self.receipt_id)
.field("payment_reference", &"<redacted>")
.field("payment_request_id", &self.payment_request_id)
.field("billing_period", &self.billing_period)
.field("recipient_public_key", &self.recipient_public_key)
.field(
"payment_endpoint_identifier",
&self.payment_endpoint_identifier,
)
.field("amount", &self.amount.as_ref().map(|_| "<redacted>"))
.field(
"metadata",
&format_args!("<redacted:{} fields>", self.metadata.len()),
)
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ReceiptDecryptionKey(String);
impl ReceiptDecryptionKey {
pub fn generate() -> Self {
let mut key = XChaCha20Poly1305::generate_key(&mut OsRng);
let encoded = URL_SAFE_NO_PAD.encode(key.as_slice());
key.as_mut_slice().zeroize();
Self(encoded)
}
pub fn new(key: impl Into<String>) -> Result<Self> {
let mut key = key.into();
let mut decoded: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::new());
if URL_SAFE_NO_PAD.decode_vec(&key, &mut decoded).is_err() {
key.zeroize();
return Err(PaykitError::Validation(
"Receipt Decryption Key must be base64url".into(),
));
}
if decoded.len() != 32 {
key.zeroize();
return Err(PaykitError::Validation(
"Receipt Decryption Key must decode to 32 bytes".into(),
));
}
Ok(Self(key))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub(super) fn bytes(&self) -> Result<[u8; 32]> {
let mut decoded =
URL_SAFE_NO_PAD
.decode(&self.0)
.map_err(|_| PaykitError::InvalidData {
context: "Receipt Decryption Key is not valid base64url".into(),
source: None,
})?;
let key_bytes =
<[u8; 32]>::try_from(decoded.as_slice()).map_err(|_| PaykitError::InvalidData {
context: "Receipt Decryption Key must decode to 32 bytes".into(),
source: None,
});
decoded.zeroize();
key_bytes
}
}
impl AsRef<str> for ReceiptDecryptionKey {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for ReceiptDecryptionKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ReceiptDecryptionKey([redacted])")
}
}
impl std::fmt::Display for ReceiptDecryptionKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[redacted Receipt Decryption Key]")
}
}
impl Drop for ReceiptDecryptionKey {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PreparedReceipt {
pub receipt: Receipt,
pub encrypted_receipt: String,
pub access: ReceiptAccess,
}
impl fmt::Debug for PreparedReceipt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PreparedReceipt")
.field("receipt", &self.receipt)
.field(
"encrypted_receipt",
&format_args!("<redacted:{} bytes>", self.encrypted_receipt.len()),
)
.field("access", &self.access)
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ReceiptAccess {
pub version: u8,
pub kind: PrivateMessageKind,
pub event_id: EventId,
pub receipt_id: ReceiptId,
pub payment_reference: PaymentReference,
pub payment_request_id: Option<PaymentRequestId>,
pub billing_period: Option<BillingPeriod>,
pub location: String,
pub key: ReceiptDecryptionKey,
}
impl fmt::Debug for ReceiptAccess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReceiptAccess")
.field("version", &self.version)
.field("kind", &self.kind)
.field("event_id", &self.event_id)
.field("receipt_id", &self.receipt_id)
.field("payment_reference", &"<redacted>")
.field("payment_request_id", &self.payment_request_id)
.field("billing_period", &self.billing_period)
.field("location", &"<redacted>")
.field("key", &"<redacted>")
.finish()
}
}
#[derive(Clone, PartialEq)]
pub struct ReceiptAccessEventMessage {
pub kind: PrivateMessageKind,
pub app_id: Option<crate::PaykitAppId>,
pub event_id: Option<EventId>,
pub receipt_id: Option<ReceiptId>,
pub raw_json: String,
pub access: std::result::Result<ReceiptAccess, String>,
}
impl fmt::Debug for ReceiptAccessEventMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReceiptAccessEventMessage")
.field("kind", &self.kind)
.field("app_id", &self.app_id)
.field("event_id", &self.event_id)
.field("receipt_id", &self.receipt_id)
.field(
"raw_json",
&format_args!("<redacted:{} bytes>", self.raw_json.len()),
)
.field("parsed", &self.access.is_ok())
.field("validation_error", &self.validation_error())
.finish()
}
}
impl ReceiptAccessEventMessage {
pub fn kind(&self) -> PrivateMessageKind {
self.kind
}
pub fn app_id(&self) -> Option<&crate::PaykitAppId> {
self.app_id.as_ref()
}
pub fn is_valid(&self) -> bool {
self.access.is_ok()
}
pub fn parsed_access(&self) -> Option<&ReceiptAccess> {
self.access.as_ref().ok()
}
pub fn validation_error(&self) -> Option<&str> {
self.access.as_ref().err().map(String::as_str)
}
pub fn event_id(&self) -> Option<&EventId> {
self.event_id.as_ref()
}
pub fn receipt_id(&self) -> Option<&ReceiptId> {
self.receipt_id.as_ref()
}
}
fn validate_request_context(
payment_request_id: Option<&PaymentRequestId>,
billing_period: Option<&BillingPeriod>,
label: &str,
) -> Result<()> {
if payment_request_id.is_none() && billing_period.is_some() {
return Err(PaykitError::Validation(format!(
"{label} billing_period requires payment_request_id"
)));
}
if let Some(period) = billing_period {
period.validate_with_label(label)?;
}
Ok(())
}
impl ReceiptDraft {
pub(crate) fn validate_request_context(&self) -> Result<()> {
validate_request_context(
self.payment_request_id.as_ref(),
self.billing_period.as_ref(),
"Receipt Draft",
)
}
}
impl Receipt {
pub(crate) fn validate_request_context(&self) -> Result<()> {
validate_request_context(
self.payment_request_id.as_ref(),
self.billing_period.as_ref(),
"Receipt",
)
}
}
impl ReceiptAccess {
pub(crate) fn validate_request_context(&self) -> Result<()> {
validate_request_context(
self.payment_request_id.as_ref(),
self.billing_period.as_ref(),
"Receipt Access",
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn receipt_debug_redacts_payment_reference() {
let payment_reference = PaymentReference::new("invoice-secret-123").unwrap();
let draft = ReceiptDraft {
receipt_id: Some(ReceiptId::new_v4()),
payment_reference: payment_reference.clone(),
payment_request_id: Some(PaymentRequestId::new_v4()),
billing_period: None,
payment_endpoint_identifier: Some(
PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap(),
),
amount: Some(PaymentAmount::new("0.001".to_string(), "btc".to_string()).unwrap()),
metadata: JsonMap::from_iter([(
"note".to_string(),
JsonValue::String("private receipt note".to_string()),
)]),
};
let receipt = Receipt {
receipt_id: draft.receipt_id.clone().unwrap(),
payment_reference,
payment_request_id: draft.payment_request_id.clone(),
billing_period: None,
recipient_public_key: pubky::Keypair::random().public_key().clone(),
payment_endpoint_identifier: draft.payment_endpoint_identifier.clone(),
amount: draft.amount.clone(),
metadata: draft.metadata.clone(),
};
let access = ReceiptAccess {
version: 1,
kind: PrivateMessageKind::ReceiptAccess,
event_id: EventId::new_v4(),
receipt_id: receipt.receipt_id.clone(),
payment_reference: receipt.payment_reference.clone(),
payment_request_id: receipt.payment_request_id.clone(),
billing_period: None,
location: ReceiptAccess::location_for(&receipt.receipt_id),
key: ReceiptDecryptionKey::generate(),
};
let prepared = PreparedReceipt {
receipt: receipt.clone(),
encrypted_receipt: "encrypted-secret".into(),
access: access.clone(),
};
for debug in [
format!("{draft:?}"),
format!("{receipt:?}"),
format!("{access:?}"),
format!("{prepared:?}"),
] {
assert!(!debug.contains("invoice-secret-123"));
assert!(!debug.contains("private receipt note"));
assert!(!debug.contains("encrypted-secret"));
assert!(!debug.contains(access.key.as_str()));
assert!(!debug.contains(&access.location));
}
}
#[test]
fn test_receipt_decryption_key_new_rejects_non_32_byte_material() {
let short = URL_SAFE_NO_PAD.encode([7u8; 16]);
assert!(matches!(
ReceiptDecryptionKey::new(short).unwrap_err(),
PaykitError::Validation(_)
));
}
#[test]
fn test_receipt_decryption_key_new_rejects_non_base64url() {
assert!(matches!(
ReceiptDecryptionKey::new("not valid base64!!").unwrap_err(),
PaykitError::Validation(_)
));
}
#[test]
fn test_receipt_decryption_key_bytes_roundtrip() {
let key = ReceiptDecryptionKey::generate();
let bytes = key.bytes().unwrap();
assert_eq!(URL_SAFE_NO_PAD.encode(bytes), key.as_str());
}
#[test]
fn test_receipt_decryption_key_clone_survives_dropped_original() {
let expected;
let clone;
{
let original = ReceiptDecryptionKey::generate();
expected = original.as_str().to_string();
clone = original.clone();
}
assert_eq!(clone.as_str(), expected);
assert_eq!(clone.bytes().unwrap().len(), 32);
}
}