Skip to main content

payrail_core/
payment.rs

1use std::collections::BTreeMap;
2
3use url::Url;
4use uuid::Uuid;
5
6use crate::{
7    Customer, IdempotencyKey, MerchantReference, Money, NextAction, PaymentError, PaymentId,
8    PaymentMethod, PaymentProvider, PaymentStatus, ProviderReference,
9};
10
11/// Request metadata.
12pub type Metadata = BTreeMap<String, String>;
13
14/// Request to create a payment.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct CreatePaymentRequest {
17    amount: Money,
18    reference: MerchantReference,
19    description: Option<String>,
20    customer: Option<Customer>,
21    payment_method: PaymentMethod,
22    callback_url: Option<Url>,
23    return_url: Option<Url>,
24    cancel_url: Option<Url>,
25    idempotency_key: Option<IdempotencyKey>,
26    metadata: Metadata,
27}
28
29impl CreatePaymentRequest {
30    /// Starts a create payment request builder.
31    #[inline]
32    pub fn builder() -> CreatePaymentRequestBuilder {
33        CreatePaymentRequestBuilder::default()
34    }
35
36    /// Returns the amount.
37    #[inline]
38    #[must_use]
39    pub const fn amount(&self) -> &Money {
40        &self.amount
41    }
42
43    /// Returns the merchant reference.
44    #[inline]
45    #[must_use]
46    pub const fn reference(&self) -> &MerchantReference {
47        &self.reference
48    }
49
50    /// Returns the description.
51    #[inline]
52    #[must_use]
53    pub fn description(&self) -> Option<&str> {
54        self.description.as_deref()
55    }
56
57    /// Returns the customer.
58    #[inline]
59    #[must_use]
60    pub const fn customer(&self) -> Option<&Customer> {
61        self.customer.as_ref()
62    }
63
64    /// Returns the payment method.
65    #[inline]
66    #[must_use]
67    pub const fn payment_method(&self) -> &PaymentMethod {
68        &self.payment_method
69    }
70
71    /// Returns the callback URL.
72    #[inline]
73    #[must_use]
74    pub const fn callback_url(&self) -> Option<&Url> {
75        self.callback_url.as_ref()
76    }
77
78    /// Returns the return URL.
79    #[inline]
80    #[must_use]
81    pub const fn return_url(&self) -> Option<&Url> {
82        self.return_url.as_ref()
83    }
84
85    /// Returns the cancel URL.
86    #[inline]
87    #[must_use]
88    pub const fn cancel_url(&self) -> Option<&Url> {
89        self.cancel_url.as_ref()
90    }
91
92    /// Returns the idempotency key.
93    #[inline]
94    #[must_use]
95    pub const fn idempotency_key(&self) -> Option<&IdempotencyKey> {
96        self.idempotency_key.as_ref()
97    }
98
99    /// Returns metadata.
100    #[inline]
101    #[must_use]
102    pub const fn metadata(&self) -> &Metadata {
103        &self.metadata
104    }
105}
106
107/// Builder for [`CreatePaymentRequest`].
108#[derive(Debug, Default, Clone)]
109#[must_use]
110pub struct CreatePaymentRequestBuilder {
111    amount: Option<Money>,
112    reference: Option<MerchantReference>,
113    description: Option<String>,
114    customer: Option<Customer>,
115    payment_method: Option<PaymentMethod>,
116    callback_url: Option<Url>,
117    return_url: Option<Url>,
118    cancel_url: Option<Url>,
119    idempotency_key: Option<IdempotencyKey>,
120    metadata: Metadata,
121}
122
123impl CreatePaymentRequestBuilder {
124    /// Sets the amount.
125    pub fn amount(mut self, amount: Money) -> Self {
126        self.amount = Some(amount);
127        self
128    }
129
130    /// Sets the merchant reference.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error when the reference is invalid.
135    pub fn reference(mut self, reference: impl AsRef<str>) -> Result<Self, PaymentError> {
136        self.reference = Some(MerchantReference::new(reference)?);
137        Ok(self)
138    }
139
140    /// Sets the description.
141    pub fn description(mut self, description: impl Into<String>) -> Self {
142        self.description = Some(description.into());
143        self
144    }
145
146    /// Sets the customer.
147    pub fn customer(mut self, customer: Customer) -> Self {
148        self.customer = Some(customer);
149        self
150    }
151
152    /// Sets the payment method.
153    pub fn payment_method(mut self, payment_method: PaymentMethod) -> Self {
154        self.payment_method = Some(payment_method);
155        self
156    }
157
158    /// Sets the callback URL.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error when the URL is invalid.
163    pub fn callback_url(mut self, url: impl AsRef<str>) -> Result<Self, PaymentError> {
164        self.callback_url = Some(parse_url(url.as_ref())?);
165        Ok(self)
166    }
167
168    /// Sets the return URL.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error when the URL is invalid.
173    pub fn return_url(mut self, url: impl AsRef<str>) -> Result<Self, PaymentError> {
174        self.return_url = Some(parse_url(url.as_ref())?);
175        Ok(self)
176    }
177
178    /// Sets the cancel URL.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error when the URL is invalid.
183    pub fn cancel_url(mut self, url: impl AsRef<str>) -> Result<Self, PaymentError> {
184        self.cancel_url = Some(parse_url(url.as_ref())?);
185        Ok(self)
186    }
187
188    /// Sets the idempotency key.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error when the key is invalid.
193    pub fn idempotency_key(mut self, key: impl AsRef<str>) -> Result<Self, PaymentError> {
194        self.idempotency_key = Some(IdempotencyKey::new(key)?);
195        Ok(self)
196    }
197
198    /// Adds one metadata entry.
199    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
200        self.metadata.insert(key.into(), value.into());
201        self
202    }
203
204    /// Builds the request.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error when a required field is missing.
209    pub fn build(self) -> Result<CreatePaymentRequest, PaymentError> {
210        Ok(CreatePaymentRequest {
211            amount: self
212                .amount
213                .ok_or(PaymentError::MissingRequiredField("amount"))?,
214            reference: self
215                .reference
216                .ok_or(PaymentError::MissingRequiredField("reference"))?,
217            description: self.description,
218            customer: self.customer,
219            payment_method: self
220                .payment_method
221                .ok_or(PaymentError::MissingRequiredField("payment_method"))?,
222            callback_url: self.callback_url,
223            return_url: self.return_url,
224            cancel_url: self.cancel_url,
225            idempotency_key: self.idempotency_key,
226            metadata: self.metadata,
227        })
228    }
229}
230
231fn parse_url(value: &str) -> Result<Url, PaymentError> {
232    Url::parse(value).map_err(|error| PaymentError::InvalidUrl(error.to_string()))
233}
234
235/// Normalized payment creation response.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct PaymentSession {
238    /// PayRail payment ID.
239    pub payment_id: PaymentId,
240    /// Provider handling the payment.
241    pub provider: PaymentProvider,
242    /// Provider reference.
243    pub provider_reference: ProviderReference,
244    /// Merchant reference.
245    pub merchant_reference: MerchantReference,
246    /// Normalized status.
247    pub status: PaymentStatus,
248    /// Next required action.
249    pub next_action: Option<NextAction>,
250}
251
252impl PaymentSession {
253    /// Creates a normalized session with a generated PayRail payment ID.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if generated ID validation fails.
258    pub fn new(
259        provider: PaymentProvider,
260        provider_reference: ProviderReference,
261        merchant_reference: MerchantReference,
262        status: PaymentStatus,
263        next_action: Option<NextAction>,
264    ) -> Result<Self, PaymentError> {
265        Ok(Self {
266            payment_id: PaymentId::new(format!("pay_{}", Uuid::new_v4()))?,
267            provider,
268            provider_reference,
269            merchant_reference,
270            status,
271            next_action,
272        })
273    }
274}
275
276/// Normalized payment status response.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct PaymentStatusResponse {
279    /// Provider handling the payment.
280    pub provider: PaymentProvider,
281    /// Provider reference.
282    pub provider_reference: ProviderReference,
283    /// Normalized status.
284    pub status: PaymentStatus,
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn builder_requires_fields() {
293        assert!(matches!(
294            CreatePaymentRequest::builder().build(),
295            Err(PaymentError::MissingRequiredField("amount"))
296        ));
297    }
298
299    #[test]
300    fn builder_creates_request() {
301        let request = CreatePaymentRequest::builder()
302            .amount(Money::new_minor(1_000, "USD").expect("money should be valid"))
303            .reference("ORDER-1")
304            .expect("reference should be valid")
305            .description("Order 1")
306            .customer(Customer::new().with_email("customer@example.com"))
307            .payment_method(PaymentMethod::card())
308            .callback_url("https://example.com/webhook")
309            .expect("url should be valid")
310            .return_url("https://example.com/success")
311            .expect("url should be valid")
312            .cancel_url("https://example.com/cancel")
313            .expect("url should be valid")
314            .idempotency_key("ORDER-1:create")
315            .expect("key should be valid")
316            .metadata("cart", "primary")
317            .build()
318            .expect("request should be valid");
319
320        assert_eq!(request.reference().as_str(), "ORDER-1");
321        assert_eq!(request.description(), Some("Order 1"));
322        assert_eq!(
323            request
324                .customer()
325                .expect("customer should be present")
326                .email(),
327            Some("customer@example.com")
328        );
329        assert!(request.callback_url().is_some());
330        assert!(request.return_url().is_some());
331        assert!(request.cancel_url().is_some());
332        assert_eq!(
333            request
334                .idempotency_key()
335                .expect("key should be present")
336                .as_str(),
337            "ORDER-1:create"
338        );
339        assert_eq!(
340            request
341                .metadata()
342                .get("cart")
343                .expect("metadata should exist"),
344            "primary"
345        );
346    }
347
348    #[test]
349    fn payment_session_new_generates_payment_id() {
350        let session = PaymentSession::new(
351            PaymentProvider::Stripe,
352            ProviderReference::new("provider-ref").expect("reference should be valid"),
353            MerchantReference::new("ORDER-1").expect("reference should be valid"),
354            PaymentStatus::Created,
355            None,
356        )
357        .expect("session should be valid");
358
359        assert!(session.payment_id.as_str().starts_with("pay_"));
360        assert_eq!(session.provider, PaymentProvider::Stripe);
361    }
362}