Skip to main content

rustlavel_billing/
subscription.rs

1//! A customer on a plan, and the invoices that keep them there.
2
3use rustlavel_core::Json;
4use rustlavel_payment::{Channel, Instructions, Money};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SubscriptionStatus {
8    /// Subscribed, first invoice unpaid. No access yet.
9    Pending,
10    /// Paid up to `period_end`.
11    Active,
12    /// The period has ended and the renewal is unpaid. Still served, until
13    /// the plan's grace runs out.
14    Grace,
15    /// Grace ran out. Not served; [`crate::Billing::renew`] raises the
16    /// invoice that brings it back.
17    Suspended,
18    /// Over. A new subscription is a new row.
19    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    /// Whether the customer is served in this state. Grace counts: that is
45    /// what grace is for.
46    pub fn is_served(self) -> bool {
47        matches!(self, SubscriptionStatus::Active | SubscriptionStatus::Grace)
48    }
49}
50
51/// Who is subscribing, as much as a payment screen needs.
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct Subscriber {
54    /// The application's id for the customer — also the ledger account owner
55    /// the plan's credits are paid into.
56    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    /// Set by [`crate::Billing::change_plan`]; becomes `plan_id` when the next
84    /// invoice is paid.
85    pub next_plan_id: Option<String>,
86    pub status: SubscriptionStatus,
87    /// How the customer paid last time, and how the renewal will be offered.
88    pub channel: Channel,
89    /// Unix seconds. Both zero until the first invoice is paid.
90    pub period_start: u64,
91    pub period_end: u64,
92    /// Set by [`crate::Billing::cancel`]: no renewal is raised, and the
93    /// subscription ends when the period does.
94    pub cancel_at_period_end: bool,
95    pub created_at: u64,
96    pub updated_at: u64,
97}
98
99impl Subscription {
100    /// Served at `now`? Active or in grace, and — because a scheduler may not
101    /// have run since the period ended — not past the grace the plan allows.
102    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    /// Raised and payable.
124    Open,
125    Paid,
126    /// Withdrawn by the application — a plan change, a cancellation — before
127    /// it was paid. Its charge was cancelled at the gateway.
128    Void,
129    /// Not paid in time. The subscription it was for is suspended or
130    /// cancelled; paying it now is not possible.
131    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    /// Also the charge's `reference` at the gateway and the ledger reference
158    /// of the credits it buys — one id through all three, so a webhook and a
159    /// top-up are matched to the invoice with no lookup table.
160    pub id: String,
161    pub subscription_id: String,
162    pub customer: String,
163    pub plan_id: String,
164    /// 1 for the first invoice, then counting. Unique per subscription, which
165    /// is what stops two schedulers raising the same renewal.
166    pub number: i64,
167    pub amount: Money,
168    /// Credited when paid.
169    pub credits: i64,
170    /// The period this invoice buys. For the first invoice both are zero
171    /// until it is paid, because access starts at payment, not at signup.
172    pub period_start: u64,
173    pub period_end: u64,
174    pub status: InvoiceStatus,
175    /// The gateway's charge id. `None` while the charge has not been created
176    /// yet — the gateway was down when the invoice was raised — or after the
177    /// gateway expired it; the next tick creates a fresh one either way.
178    pub charge_id: Option<String>,
179    pub channel: Channel,
180    /// How to pay: the VA number, the QR, the URL. Empty until there is a
181    /// charge.
182    pub instructions: Instructions,
183    /// Unix seconds. After this the subscription is in grace.
184    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    /// The scheduler may be late. A subscription whose grace ran out an hour
266    /// ago must not be served just because nothing has flipped its status yet.
267    #[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}