use std::collections::BTreeMap;
use std::fmt;
use std::hash::{BuildHasher, Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::Error;
use crate::money::Money;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum PaymentStatus {
Open,
Pending,
Authorized,
Paid,
Failed,
Cancelled,
Expired,
Unknown(Box<str>),
}
impl PaymentStatus {
#[must_use]
pub fn is_paid(&self) -> bool {
matches!(self, Self::Paid)
}
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(
self,
Self::Paid | Self::Failed | Self::Cancelled | Self::Expired
)
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Open => "open",
Self::Pending => "pending",
Self::Authorized => "authorized",
Self::Paid => "paid",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
Self::Expired => "expired",
Self::Unknown(raw) => raw,
}
}
#[must_use]
pub fn raw(&self) -> Option<&str> {
match self {
Self::Unknown(raw) => Some(raw),
_ => None,
}
}
}
impl fmt::Display for PaymentStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct Payment {
pub id: String,
pub status: PaymentStatus,
pub amount: Money,
pub checkout_url: Option<String>,
pub reference: Option<String>,
pub metadata: BTreeMap<String, String>,
}
impl Payment {
#[must_use]
pub fn new(id: impl Into<String>, status: PaymentStatus, amount: Money) -> Self {
Self {
id: id.into(),
status,
amount,
checkout_url: None,
reference: None,
metadata: BTreeMap::new(),
}
}
#[must_use]
pub fn with_checkout_url(mut self, url: impl Into<String>) -> Self {
self.checkout_url = Some(url.into());
self
}
#[must_use]
pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
self.reference = Some(reference.into());
self
}
#[must_use]
pub fn with_metadata_map(mut self, metadata: BTreeMap<String, String>) -> Self {
self.metadata = metadata;
self
}
}
fn is_absolute_http_url(url: &str) -> bool {
url.starts_with("http://") || url.starts_with("https://")
}
static IDEMPOTENCY_COUNTER: AtomicU64 = AtomicU64::new(0);
fn generate_idempotency_key(seed: impl Hash) -> String {
use std::collections::hash_map::RandomState;
let counter = IDEMPOTENCY_COUNTER.fetch_add(1, AtomicOrdering::Relaxed);
let nanos = now_nanos();
let mut first = RandomState::new().build_hasher();
counter.hash(&mut first);
nanos.hash(&mut first);
seed.hash(&mut first);
let a = first.finish();
let mut second = RandomState::new().build_hasher();
a.hash(&mut second);
counter.hash(&mut second);
let b = second.finish();
format!("paykit-{a:016x}{b:016x}")
}
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
fn now_nanos() -> u128 {
0
}
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
fn now_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
#[derive(Debug, Clone)]
pub struct CreatePayment {
amount: Money,
description: String,
redirect_url: String,
webhook_url: Option<String>,
reference: Option<String>,
idempotency_key: String,
metadata: BTreeMap<String, String>,
}
impl PartialEq for CreatePayment {
fn eq(&self, other: &Self) -> bool {
self.amount == other.amount
&& self.description == other.description
&& self.redirect_url == other.redirect_url
&& self.webhook_url == other.webhook_url
&& self.reference == other.reference
&& self.metadata == other.metadata
}
}
impl Eq for CreatePayment {}
impl CreatePayment {
#[must_use]
pub fn new(
amount: Money,
description: impl Into<String>,
redirect_url: impl Into<String>,
) -> Self {
let description = description.into();
let redirect_url = redirect_url.into();
let idempotency_key = generate_idempotency_key((
amount.minor_units(),
amount.currency().as_str(),
description.as_str(),
redirect_url.as_str(),
));
Self {
amount,
description,
redirect_url,
webhook_url: None,
reference: None,
idempotency_key,
metadata: BTreeMap::new(),
}
}
#[must_use]
pub fn with_webhook_url(mut self, url: impl Into<String>) -> Self {
self.webhook_url = Some(url.into());
self
}
#[must_use]
pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
self.reference = Some(reference.into());
self
}
#[must_use]
pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = key.into();
self
}
#[must_use]
pub fn with_metadata_entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn validate(&self) -> Result<(), Error> {
if !is_absolute_http_url(&self.redirect_url) {
return Err(Error::InvalidRequest(format!(
"redirect_url must be an absolute http(s) URL, got {:?}",
self.redirect_url
)));
}
if let Some(url) = &self.webhook_url {
if !is_absolute_http_url(url) {
return Err(Error::InvalidRequest(format!(
"webhook_url must be an absolute http(s) URL, got {url:?}"
)));
}
}
Ok(())
}
#[must_use]
pub fn amount(&self) -> Money {
self.amount
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub fn redirect_url(&self) -> &str {
&self.redirect_url
}
#[must_use]
pub fn webhook_url(&self) -> Option<&str> {
self.webhook_url.as_deref()
}
#[must_use]
pub fn reference(&self) -> Option<&str> {
self.reference.as_deref()
}
#[must_use]
pub fn idempotency_key(&self) -> &str {
&self.idempotency_key
}
#[must_use]
pub fn metadata(&self) -> &BTreeMap<String, String> {
&self.metadata
}
}
#[derive(Debug, Clone)]
pub struct RefundRequest {
amount: Money,
description: Option<String>,
idempotency_key: String,
}
impl PartialEq for RefundRequest {
fn eq(&self, other: &Self) -> bool {
self.amount == other.amount && self.description == other.description
}
}
impl Eq for RefundRequest {}
impl RefundRequest {
#[must_use]
pub fn new(amount: Money) -> Self {
let idempotency_key =
generate_idempotency_key((amount.minor_units(), amount.currency().as_str()));
Self {
amount,
description: None,
idempotency_key,
}
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = key.into();
self
}
#[must_use]
pub fn amount(&self) -> Money {
self.amount
}
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
#[must_use]
pub fn idempotency_key(&self) -> &str {
&self.idempotency_key
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum RefundStatus {
Queued,
Pending,
Processing,
Refunded,
Failed,
Unknown(Box<str>),
}
impl RefundStatus {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Queued => "queued",
Self::Pending => "pending",
Self::Processing => "processing",
Self::Refunded => "refunded",
Self::Failed => "failed",
Self::Unknown(raw) => raw,
}
}
#[must_use]
pub fn raw(&self) -> Option<&str> {
match self {
Self::Unknown(raw) => Some(raw),
_ => None,
}
}
}
impl fmt::Display for RefundStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct Refund {
pub id: String,
pub payment_id: String,
pub amount: Money,
pub status: RefundStatus,
}
impl Refund {
#[must_use]
pub fn new(
id: impl Into<String>,
payment_id: impl Into<String>,
amount: Money,
status: RefundStatus,
) -> Self {
Self {
id: id.into(),
payment_id: payment_id.into(),
amount,
status,
}
}
}
#[derive(Debug)]
pub struct VerifiedPayment {
payment: Payment,
}
impl VerifiedPayment {
pub(crate) fn new(payment: Payment) -> Self {
Self { payment }
}
#[must_use]
pub fn payment(&self) -> &Payment {
&self.payment
}
#[must_use]
pub fn into_payment(self) -> Payment {
self.payment
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::money::Currency;
fn amount() -> Money {
Money::from_minor(1000, Currency::EUR)
}
#[test]
fn only_paid_is_paid() {
assert!(PaymentStatus::Paid.is_paid());
for status in [
PaymentStatus::Open,
PaymentStatus::Pending,
PaymentStatus::Authorized,
PaymentStatus::Failed,
PaymentStatus::Cancelled,
PaymentStatus::Expired,
PaymentStatus::Unknown("weird".into()),
] {
assert!(!status.is_paid(), "{status:?} should not be paid");
}
}
#[test]
fn terminal_statuses() {
for status in [
PaymentStatus::Paid,
PaymentStatus::Failed,
PaymentStatus::Cancelled,
PaymentStatus::Expired,
] {
assert!(status.is_terminal(), "{status:?} should be terminal");
}
for status in [
PaymentStatus::Open,
PaymentStatus::Pending,
PaymentStatus::Authorized,
] {
assert!(!status.is_terminal(), "{status:?} should not be terminal");
}
}
#[test]
fn unknown_status_is_not_terminal() {
assert!(!PaymentStatus::Unknown("future_status".into()).is_terminal());
}
#[test]
fn payment_new_has_no_optional_fields_set() {
let payment = Payment::new("pay_1", PaymentStatus::Open, amount());
assert_eq!(payment.id, "pay_1");
assert_eq!(payment.status, PaymentStatus::Open);
assert_eq!(payment.amount, amount());
assert_eq!(payment.checkout_url, None);
assert_eq!(payment.reference, None);
assert!(payment.metadata.is_empty());
}
#[test]
fn payment_builder_methods_set_fields() {
let mut metadata = BTreeMap::new();
metadata.insert("order_id".to_string(), "42".to_string());
let payment = Payment::new("pay_1", PaymentStatus::Paid, amount())
.with_checkout_url("https://provider.example/checkout/pay_1")
.with_reference("order-42")
.with_metadata_map(metadata.clone());
assert_eq!(
payment.checkout_url.as_deref(),
Some("https://provider.example/checkout/pay_1")
);
assert_eq!(payment.reference.as_deref(), Some("order-42"));
assert_eq!(payment.metadata, metadata);
}
#[test]
fn generate_idempotency_key_never_panics_across_many_calls() {
for i in 0..1000 {
let key = generate_idempotency_key(i);
assert!(!key.is_empty());
}
}
#[test]
fn create_payment_new_sets_required_fields() {
let req = CreatePayment::new(amount(), "order #42", "https://shop.example/return");
assert_eq!(req.amount(), amount());
assert_eq!(req.description(), "order #42");
assert_eq!(req.redirect_url(), "https://shop.example/return");
assert_eq!(req.webhook_url(), None);
assert_eq!(req.reference(), None);
assert!(req.metadata().is_empty());
}
#[test]
fn create_payment_new_generates_a_non_empty_idempotency_key() {
let req = CreatePayment::new(amount(), "order #42", "https://shop.example/return");
assert!(!req.idempotency_key().is_empty());
}
#[test]
fn create_payment_new_generates_different_keys_for_different_requests() {
let a = CreatePayment::new(amount(), "order #1", "https://shop.example/return");
let b = CreatePayment::new(amount(), "order #2", "https://shop.example/return");
assert_ne!(a.idempotency_key(), b.idempotency_key());
}
#[test]
fn create_payment_new_generates_different_keys_across_calls_with_identical_content() {
let a = CreatePayment::new(amount(), "order", "https://shop.example/return");
let b = CreatePayment::new(amount(), "order", "https://shop.example/return");
assert_ne!(a.idempotency_key(), b.idempotency_key());
}
#[test]
fn create_payment_builder_methods_set_fields() {
let req = CreatePayment::new(amount(), "order #42", "https://shop.example/return")
.with_webhook_url("https://shop.example/webhooks/payment")
.with_reference("order-42")
.with_idempotency_key("idem-1")
.with_metadata_entry("order_id", "42")
.with_metadata_entry("channel", "web");
assert_eq!(
req.webhook_url(),
Some("https://shop.example/webhooks/payment")
);
assert_eq!(req.reference(), Some("order-42"));
assert_eq!(req.idempotency_key(), "idem-1");
assert_eq!(
req.metadata().get("order_id").map(String::as_str),
Some("42")
);
assert_eq!(
req.metadata().get("channel").map(String::as_str),
Some("web")
);
}
#[test]
fn create_payment_equality_ignores_the_generated_idempotency_key() {
let a = CreatePayment::new(amount(), "order #42", "https://shop.example/return");
let b = CreatePayment::new(amount(), "order #42", "https://shop.example/return");
assert_ne!(a.idempotency_key(), b.idempotency_key());
assert_eq!(a, b);
}
#[test]
fn create_payment_equality_still_distinguishes_other_fields() {
let a = CreatePayment::new(amount(), "order #1", "https://shop.example/return");
let b = CreatePayment::new(amount(), "order #2", "https://shop.example/return");
assert_ne!(a, b);
}
#[test]
fn create_payment_with_idempotency_key_overrides_the_generated_one() {
let req = CreatePayment::new(amount(), "order", "https://shop.example/return")
.with_idempotency_key("caller-chosen");
assert_eq!(req.idempotency_key(), "caller-chosen");
}
#[test]
fn create_payment_metadata_overwrites_same_key() {
let req = CreatePayment::new(amount(), "d", "https://shop.example/return")
.with_metadata_entry("k", "first")
.with_metadata_entry("k", "second");
assert_eq!(req.metadata().len(), 1);
assert_eq!(req.metadata().get("k").map(String::as_str), Some("second"));
}
#[test]
fn validate_accepts_absolute_https_urls() {
let req = CreatePayment::new(amount(), "d", "https://shop.example/return")
.with_webhook_url("https://shop.example/webhooks/payment");
assert!(req.validate().is_ok());
}
#[test]
fn validate_accepts_absolute_http_urls() {
let req = CreatePayment::new(amount(), "d", "http://shop.example/return");
assert!(req.validate().is_ok());
}
#[test]
fn validate_rejects_relative_redirect_url() {
let req = CreatePayment::new(amount(), "d", "/return");
let err = req.validate().unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
assert!(!err.is_retriable());
}
#[test]
fn validate_rejects_non_http_scheme_redirect_url() {
let req = CreatePayment::new(amount(), "d", "ftp://shop.example/return");
assert!(req.validate().is_err());
}
#[test]
fn validate_rejects_relative_webhook_url_even_with_valid_redirect_url() {
let req = CreatePayment::new(amount(), "d", "https://shop.example/return")
.with_webhook_url("/webhooks/payment");
assert!(req.validate().is_err());
}
#[test]
fn refund_request_new_requires_an_amount_and_sets_nothing_else() {
let req = RefundRequest::new(amount());
assert_eq!(req.amount(), amount());
assert_eq!(req.description(), None);
}
#[test]
fn refund_request_new_generates_a_non_empty_idempotency_key() {
let req = RefundRequest::new(amount());
assert!(!req.idempotency_key().is_empty());
}
#[test]
fn refund_request_new_generates_different_keys_across_calls_with_identical_content() {
let a = RefundRequest::new(amount());
let b = RefundRequest::new(amount());
assert_ne!(a.idempotency_key(), b.idempotency_key());
}
#[test]
fn refund_request_builder_methods_set_fields() {
let req = RefundRequest::new(amount())
.with_description("customer requested")
.with_idempotency_key("idem-refund-1");
assert_eq!(req.amount(), amount());
assert_eq!(req.description(), Some("customer requested"));
assert_eq!(req.idempotency_key(), "idem-refund-1");
}
#[test]
fn refund_request_equality_ignores_the_generated_idempotency_key() {
let a = RefundRequest::new(amount());
let b = RefundRequest::new(amount());
assert_ne!(a.idempotency_key(), b.idempotency_key());
assert_eq!(a, b);
}
#[test]
fn refund_request_equality_still_distinguishes_other_fields() {
let a = RefundRequest::new(amount()).with_description("a");
let b = RefundRequest::new(amount()).with_description("b");
assert_ne!(a, b);
}
#[test]
fn refund_new_sets_all_fields() {
let refund = Refund::new("re_1", "pay_1", amount(), RefundStatus::Refunded);
assert_eq!(refund.id, "re_1");
assert_eq!(refund.payment_id, "pay_1");
assert_eq!(refund.amount, amount());
assert_eq!(refund.status, RefundStatus::Refunded);
}
#[test]
fn refund_status_unknown_carries_the_raw_value() {
let status = RefundStatus::Unknown("future_status".into());
assert_eq!(status, RefundStatus::Unknown("future_status".into()));
}
#[test]
fn refund_status_as_str_and_display() {
assert_eq!(RefundStatus::Refunded.as_str(), "refunded");
assert_eq!(RefundStatus::Refunded.to_string(), "refunded");
assert_eq!(
RefundStatus::Unknown("future_status".into()).as_str(),
"future_status"
);
}
#[test]
fn refund_status_raw_is_only_some_for_unknown() {
assert_eq!(RefundStatus::Refunded.raw(), None);
assert_eq!(
RefundStatus::Unknown("future_status".into()).raw(),
Some("future_status")
);
}
#[test]
fn payment_status_as_str_and_display() {
assert_eq!(PaymentStatus::Paid.as_str(), "paid");
assert_eq!(PaymentStatus::Paid.to_string(), "paid");
assert_eq!(
PaymentStatus::Unknown("future_status".into()).as_str(),
"future_status"
);
}
#[test]
fn payment_status_raw_is_only_some_for_unknown() {
assert_eq!(PaymentStatus::Paid.raw(), None);
assert_eq!(
PaymentStatus::Unknown("future_status".into()).raw(),
Some("future_status")
);
}
#[test]
fn verified_payment_exposes_the_wrapped_payment_by_reference_and_by_value() {
let payment = Payment::new("pay_1", PaymentStatus::Paid, amount());
let verified = VerifiedPayment::new(payment.clone());
assert_eq!(verified.payment(), &payment);
assert_eq!(verified.into_payment(), payment);
}
#[test]
#[cfg(feature = "serde")]
fn payment_serde_round_trip() {
let mut metadata = BTreeMap::new();
metadata.insert("order_id".to_string(), "42".to_string());
let payment = Payment::new("pay_1", PaymentStatus::Paid, amount())
.with_checkout_url("https://provider.example/checkout/pay_1")
.with_reference("order-42")
.with_metadata_map(metadata);
let json = serde_json::to_string(&payment).unwrap();
let back: Payment = serde_json::from_str(&json).unwrap();
assert_eq!(back, payment);
}
#[test]
#[cfg(feature = "serde")]
fn refund_serde_round_trip() {
let refund = Refund::new("re_1", "pay_1", amount(), RefundStatus::Refunded);
let json = serde_json::to_string(&refund).unwrap();
let back: Refund = serde_json::from_str(&json).unwrap();
assert_eq!(back, refund);
}
#[test]
#[cfg(feature = "serde")]
fn payment_status_unknown_has_the_expected_external_tag_shape() {
let status = PaymentStatus::Unknown("future_status".into());
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, "{\"Unknown\":\"future_status\"}");
let back: PaymentStatus = serde_json::from_str(&json).unwrap();
assert_eq!(back, status);
}
#[test]
#[cfg(feature = "serde")]
fn payment_status_known_variant_serializes_as_a_bare_string() {
let json = serde_json::to_string(&PaymentStatus::Paid).unwrap();
assert_eq!(json, "\"Paid\"");
}
#[test]
#[cfg(feature = "serde")]
fn refund_status_unknown_has_the_expected_external_tag_shape() {
let status = RefundStatus::Unknown("future_status".into());
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, "{\"Unknown\":\"future_status\"}");
let back: RefundStatus = serde_json::from_str(&json).unwrap();
assert_eq!(back, status);
}
}