1use crate::channel::Channel;
4use crate::money::Money;
5use rustlavel_core::Json;
6use std::time::Duration;
7
8#[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#[derive(Debug, Clone, PartialEq)]
18pub struct ChargeRequest {
19 pub reference: String,
23 pub amount: Money,
24 pub channel: Channel,
25 pub expires_in: Duration,
28 pub customer: Customer,
29 pub description: String,
32 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum ChargeStatus {
89 Pending,
91 Paid,
93 Expired,
95 Cancelled,
97 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 pub fn is_final(self) -> bool {
125 !matches!(self, ChargeStatus::Pending)
126 }
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct Instructions {
133 pub account_number: Option<String>,
135 pub qr_string: Option<String>,
137 pub url: Option<String>,
139 pub payment_code: Option<String>,
141}
142
143#[derive(Debug, Clone, PartialEq)]
145pub struct Charge {
146 pub id: String,
149 pub reference: String,
151 pub status: ChargeStatus,
152 pub channel: Channel,
153 pub amount: Money,
154 pub instructions: Instructions,
155 pub expires_at: u64,
157 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 #[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}