Skip to main content

rustlavel_payment/
charge.rs

1//! A request for payment, and its state.
2
3use crate::channel::Channel;
4use crate::money::Money;
5use rustlavel_core::Json;
6use std::time::Duration;
7
8/// Who is paying, as much as the gateway needs to show them a screen.
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct Customer {
11    pub name: String,
12    pub email: Option<String>,
13    pub phone: Option<String>,
14}
15
16/// What to ask for.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ChargeRequest {
19    /// The application's own id for this charge — an order number, an invoice
20    /// id. Sent to the gateway and echoed back in every event, which is how a
21    /// webhook is matched to the thing it is about.
22    pub reference: String,
23    pub amount: Money,
24    pub channel: Channel,
25    /// How long the customer has. After this the gateway expires the charge
26    /// and says so by webhook.
27    pub expires_in: Duration,
28    pub customer: Customer,
29    /// Shown on the payment screen and the bank statement where the channel
30    /// allows it.
31    pub description: String,
32    /// Anything the application wants back unchanged in the webhook.
33    pub metadata: Json,
34}
35
36impl ChargeRequest {
37    pub fn new(reference: impl Into<String>, amount: Money, channel: Channel) -> ChargeRequest {
38        ChargeRequest {
39            reference: reference.into(),
40            amount,
41            channel,
42            // A day: long enough for a bank transfer somebody meant to make,
43            // short enough that a VA number is not held open for a week.
44            expires_in: Duration::from_secs(24 * 60 * 60),
45            customer: Customer::default(),
46            description: String::new(),
47            metadata: Json::Null,
48        }
49    }
50
51    pub fn expires_in(mut self, duration: Duration) -> ChargeRequest {
52        self.expires_in = duration;
53        self
54    }
55
56    pub fn customer(mut self, customer: Customer) -> ChargeRequest {
57        self.customer = customer;
58        self
59    }
60
61    pub fn description(mut self, description: impl Into<String>) -> ChargeRequest {
62        self.description = description.into();
63        self
64    }
65
66    pub fn metadata(mut self, metadata: Json) -> ChargeRequest {
67        self.metadata = metadata;
68        self
69    }
70
71    /// The checks every driver would otherwise repeat.
72    pub fn validate(&self) -> rustlavel_core::Result<()> {
73        self.amount.positive()?;
74        if self.reference.trim().is_empty() {
75            return Err(rustlavel_core::Error::msg(
76                "a charge needs a reference — the id the webhook will be matched to",
77            ));
78        }
79        if self.expires_in.is_zero() {
80            return Err(rustlavel_core::Error::msg("a charge that expires immediately cannot be paid"));
81        }
82        Ok(())
83    }
84}
85
86/// Where a charge is in its life.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum ChargeStatus {
89    /// Created, and the customer has not paid yet.
90    Pending,
91    /// The gateway has the money.
92    Paid,
93    /// The customer did not pay in time.
94    Expired,
95    /// The application cancelled it before it was paid.
96    Cancelled,
97    /// The gateway could not complete it.
98    Failed,
99}
100
101impl ChargeStatus {
102    pub fn as_str(self) -> &'static str {
103        match self {
104            ChargeStatus::Pending => "pending",
105            ChargeStatus::Paid => "paid",
106            ChargeStatus::Expired => "expired",
107            ChargeStatus::Cancelled => "cancelled",
108            ChargeStatus::Failed => "failed",
109        }
110    }
111
112    pub fn parse(text: &str) -> Option<ChargeStatus> {
113        Some(match text.trim().to_ascii_lowercase().as_str() {
114            "pending" => ChargeStatus::Pending,
115            "paid" | "settled" | "success" => ChargeStatus::Paid,
116            "expired" => ChargeStatus::Expired,
117            "cancelled" | "canceled" => ChargeStatus::Cancelled,
118            "failed" => ChargeStatus::Failed,
119            _ => return None,
120        })
121    }
122
123    /// Whether anything more can happen to it.
124    pub fn is_final(self) -> bool {
125        !matches!(self, ChargeStatus::Pending)
126    }
127}
128
129/// What the customer needs in order to pay. Which fields are set depends on
130/// the channel.
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct Instructions {
133    /// The virtual account number, for a VA charge.
134    pub account_number: Option<String>,
135    /// The QR content, for QRIS. Render it; do not show the string.
136    pub qr_string: Option<String>,
137    /// Where to send the customer, for an e-wallet or a hosted page.
138    pub url: Option<String>,
139    /// The code to read out at a retail counter.
140    pub payment_code: Option<String>,
141}
142
143/// A charge, as the gateway knows it.
144#[derive(Debug, Clone, PartialEq)]
145pub struct Charge {
146    /// The gateway's id. This is what a webhook carries and what
147    /// deduplication keys on.
148    pub id: String,
149    /// The application's reference, echoed back.
150    pub reference: String,
151    pub status: ChargeStatus,
152    pub channel: Channel,
153    pub amount: Money,
154    pub instructions: Instructions,
155    /// Unix seconds.
156    pub expires_at: u64,
157    /// Unix seconds. `None` until paid.
158    pub paid_at: Option<u64>,
159    pub metadata: Json,
160}
161
162impl Charge {
163    pub fn to_json(&self) -> Json {
164        Json::object([
165            ("id", Json::from(self.id.as_str())),
166            ("reference", Json::from(self.reference.as_str())),
167            ("status", Json::from(self.status.as_str())),
168            ("channel", Json::from(self.channel.code())),
169            ("amount", self.amount.to_json()),
170            (
171                "instructions",
172                Json::object([
173                    ("account_number", opt(&self.instructions.account_number)),
174                    ("qr_string", opt(&self.instructions.qr_string)),
175                    ("url", opt(&self.instructions.url)),
176                    ("payment_code", opt(&self.instructions.payment_code)),
177                ]),
178            ),
179            ("expires_at", Json::from(self.expires_at as i64)),
180            ("paid_at", self.paid_at.map_or(Json::Null, |at| Json::from(at as i64))),
181            ("metadata", self.metadata.clone()),
182        ])
183    }
184}
185
186fn opt(value: &Option<String>) -> Json {
187    value.as_deref().map_or(Json::Null, Json::from)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::channel::Bank;
194
195    #[test]
196    fn a_request_is_validated_before_a_driver_sees_it() {
197        let good = ChargeRequest::new("order-7", Money::idr(150_000), Channel::Qris);
198        assert!(good.validate().is_ok());
199
200        let nothing = ChargeRequest::new("order-7", Money::idr(0), Channel::Qris);
201        assert!(nothing.validate().is_err());
202
203        let unnamed = ChargeRequest::new("  ", Money::idr(1), Channel::Qris);
204        assert!(unnamed.validate().is_err(), "a charge with no reference cannot be matched");
205
206        let instant = ChargeRequest::new("x", Money::idr(1), Channel::VirtualAccount(Bank::Bca))
207            .expires_in(Duration::ZERO);
208        assert!(instant.validate().is_err());
209    }
210
211    /// Gateways spell "paid" several ways; the application should not have to
212    /// know which.
213    #[test]
214    fn a_status_is_read_from_the_spellings_gateways_use() {
215        assert_eq!(ChargeStatus::parse("PAID"), Some(ChargeStatus::Paid));
216        assert_eq!(ChargeStatus::parse("settled"), Some(ChargeStatus::Paid));
217        assert_eq!(ChargeStatus::parse("canceled"), Some(ChargeStatus::Cancelled));
218        assert_eq!(ChargeStatus::parse("maybe"), None, "an unknown status must not become one");
219        assert!(ChargeStatus::Paid.is_final());
220        assert!(!ChargeStatus::Pending.is_final());
221    }
222}