1use rustlavel_core::Json;
4use rustlavel_payment::{Channel, Instructions, Money};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SubscriptionStatus {
8 Pending,
10 Active,
12 Grace,
15 Suspended,
18 Cancelled,
20}
21
22impl SubscriptionStatus {
23 pub fn as_str(self) -> &'static str {
24 match self {
25 SubscriptionStatus::Pending => "pending",
26 SubscriptionStatus::Active => "active",
27 SubscriptionStatus::Grace => "grace",
28 SubscriptionStatus::Suspended => "suspended",
29 SubscriptionStatus::Cancelled => "cancelled",
30 }
31 }
32
33 pub fn parse(text: &str) -> Option<SubscriptionStatus> {
34 Some(match text {
35 "pending" => SubscriptionStatus::Pending,
36 "active" => SubscriptionStatus::Active,
37 "grace" => SubscriptionStatus::Grace,
38 "suspended" => SubscriptionStatus::Suspended,
39 "cancelled" => SubscriptionStatus::Cancelled,
40 _ => return None,
41 })
42 }
43
44 pub fn is_served(self) -> bool {
47 matches!(self, SubscriptionStatus::Active | SubscriptionStatus::Grace)
48 }
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct Subscriber {
54 pub id: String,
57 pub name: String,
58 pub email: Option<String>,
59 pub phone: Option<String>,
60}
61
62impl Subscriber {
63 pub fn new(id: impl Into<String>, name: impl Into<String>) -> Subscriber {
64 Subscriber { id: id.into(), name: name.into(), email: None, phone: None }
65 }
66
67 pub fn email(mut self, email: impl Into<String>) -> Subscriber {
68 self.email = Some(email.into());
69 self
70 }
71
72 pub fn phone(mut self, phone: impl Into<String>) -> Subscriber {
73 self.phone = Some(phone.into());
74 self
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Subscription {
80 pub id: String,
81 pub subscriber: Subscriber,
82 pub plan_id: String,
83 pub next_plan_id: Option<String>,
86 pub status: SubscriptionStatus,
87 pub channel: Channel,
89 pub period_start: u64,
91 pub period_end: u64,
92 pub cancel_at_period_end: bool,
95 pub created_at: u64,
96 pub updated_at: u64,
97}
98
99impl Subscription {
100 pub fn is_served_at(&self, now: u64, grace_seconds: u64) -> bool {
103 self.status.is_served() && now < self.period_end.saturating_add(grace_seconds)
104 }
105
106 pub fn to_json(&self) -> Json {
107 Json::object([
108 ("id", Json::from(self.id.as_str())),
109 ("customer", Json::from(self.subscriber.id.as_str())),
110 ("plan", Json::from(self.plan_id.as_str())),
111 ("next_plan", opt(&self.next_plan_id)),
112 ("status", Json::from(self.status.as_str())),
113 ("channel", Json::from(self.channel.code())),
114 ("period_start", Json::from(self.period_start as i64)),
115 ("period_end", Json::from(self.period_end as i64)),
116 ("cancel_at_period_end", Json::from(self.cancel_at_period_end)),
117 ])
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum InvoiceStatus {
123 Open,
125 Paid,
126 Void,
129 Expired,
132}
133
134impl InvoiceStatus {
135 pub fn as_str(self) -> &'static str {
136 match self {
137 InvoiceStatus::Open => "open",
138 InvoiceStatus::Paid => "paid",
139 InvoiceStatus::Void => "void",
140 InvoiceStatus::Expired => "expired",
141 }
142 }
143
144 pub fn parse(text: &str) -> Option<InvoiceStatus> {
145 Some(match text {
146 "open" => InvoiceStatus::Open,
147 "paid" => InvoiceStatus::Paid,
148 "void" => InvoiceStatus::Void,
149 "expired" => InvoiceStatus::Expired,
150 _ => return None,
151 })
152 }
153}
154
155#[derive(Debug, Clone, PartialEq)]
156pub struct Invoice {
157 pub id: String,
161 pub subscription_id: String,
162 pub customer: String,
163 pub plan_id: String,
164 pub number: i64,
167 pub amount: Money,
168 pub credits: i64,
170 pub period_start: u64,
173 pub period_end: u64,
174 pub status: InvoiceStatus,
175 pub charge_id: Option<String>,
179 pub channel: Channel,
180 pub instructions: Instructions,
183 pub due_at: u64,
185 pub paid_at: Option<u64>,
186 pub created_at: u64,
187}
188
189impl Invoice {
190 pub fn is_payable(&self) -> bool {
191 self.status == InvoiceStatus::Open && self.charge_id.is_some()
192 }
193
194 pub fn to_json(&self) -> Json {
195 Json::object([
196 ("id", Json::from(self.id.as_str())),
197 ("subscription", Json::from(self.subscription_id.as_str())),
198 ("customer", Json::from(self.customer.as_str())),
199 ("plan", Json::from(self.plan_id.as_str())),
200 ("number", Json::from(self.number)),
201 ("amount", self.amount.to_json()),
202 ("credits", Json::from(self.credits)),
203 ("period_start", Json::from(self.period_start as i64)),
204 ("period_end", Json::from(self.period_end as i64)),
205 ("status", Json::from(self.status.as_str())),
206 ("charge_id", opt(&self.charge_id)),
207 ("channel", Json::from(self.channel.code())),
208 ("instructions", instructions_to_json(&self.instructions)),
209 ("due_at", Json::from(self.due_at as i64)),
210 ("paid_at", self.paid_at.map_or(Json::Null, |at| Json::from(at as i64))),
211 ])
212 }
213}
214
215pub(crate) fn instructions_to_json(instructions: &Instructions) -> Json {
216 Json::object([
217 ("account_number", opt(&instructions.account_number)),
218 ("qr_string", opt(&instructions.qr_string)),
219 ("url", opt(&instructions.url)),
220 ("payment_code", opt(&instructions.payment_code)),
221 ])
222}
223
224pub(crate) fn instructions_from_json(json: &Json) -> Instructions {
225 let text = |key: &str| json.get(key).and_then(Json::as_str).map(str::to_string);
226 Instructions {
227 account_number: text("account_number"),
228 qr_string: text("qr_string"),
229 url: text("url"),
230 payment_code: text("payment_code"),
231 }
232}
233
234fn opt(value: &Option<String>) -> Json {
235 value.as_deref().map_or(Json::Null, Json::from)
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn statuses_round_trip() {
244 for status in [
245 SubscriptionStatus::Pending,
246 SubscriptionStatus::Active,
247 SubscriptionStatus::Grace,
248 SubscriptionStatus::Suspended,
249 SubscriptionStatus::Cancelled,
250 ] {
251 assert_eq!(SubscriptionStatus::parse(status.as_str()), Some(status));
252 }
253 for status in [InvoiceStatus::Open, InvoiceStatus::Paid, InvoiceStatus::Void, InvoiceStatus::Expired] {
254 assert_eq!(InvoiceStatus::parse(status.as_str()), Some(status));
255 }
256 }
257
258 #[test]
259 fn grace_is_served_and_suspension_is_not() {
260 assert!(SubscriptionStatus::Grace.is_served());
261 assert!(!SubscriptionStatus::Suspended.is_served());
262 assert!(!SubscriptionStatus::Pending.is_served());
263 }
264
265 #[test]
268 fn a_stale_grace_status_does_not_grant_access_past_the_grace() {
269 let subscription = Subscription {
270 id: "sub".into(),
271 subscriber: Subscriber::new("user:1", "Ada"),
272 plan_id: "pro".into(),
273 next_plan_id: None,
274 status: SubscriptionStatus::Grace,
275 channel: Channel::Qris,
276 period_start: 1_000,
277 period_end: 2_000,
278 cancel_at_period_end: false,
279 created_at: 1_000,
280 updated_at: 1_000,
281 };
282 assert!(subscription.is_served_at(2_500, 1_000));
283 assert!(!subscription.is_served_at(3_000, 1_000));
284 }
285
286 #[test]
287 fn instructions_survive_the_json_column() {
288 let instructions = Instructions {
289 account_number: Some("8808123".into()),
290 qr_string: None,
291 url: Some("https://pay.test/x".into()),
292 payment_code: None,
293 };
294 assert_eq!(instructions_from_json(&instructions_to_json(&instructions)), instructions);
295 }
296}