Skip to main content

rustlavel_payment/
channel.rs

1//! How the customer pays.
2//!
3//! The Indonesian set, because that is where the first driver lives. The
4//! enums are not closed — a driver for another market adds its variants — but
5//! they are enums rather than strings so that a typo in a bank code is a
6//! compile error rather than a charge nobody can pay.
7
8use std::fmt;
9
10/// Banks that issue virtual account numbers.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum Bank {
13    Bca,
14    Bni,
15    Bri,
16    Cimb,
17    Danamon,
18    Mandiri,
19    Permata,
20    Bnc,
21}
22
23/// E-wallets a charge can be pushed to.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum Wallet {
26    Dana,
27    Ovo,
28    ShopeePay,
29    LinkAja,
30    GoPay,
31}
32
33/// Retail counters that take cash against a payment code.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum Retail {
36    Alfamart,
37    Indomaret,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub enum Channel {
42    /// A bank transfer to a number that exists for this one charge.
43    VirtualAccount(Bank),
44    /// The national QR standard; any participating app can scan it.
45    Qris,
46    EWallet(Wallet),
47    /// Cash, at a counter, against a code.
48    Retail(Retail),
49}
50
51impl Channel {
52    /// The stable lower-case name, for storage and for a driver's request.
53    pub fn code(&self) -> String {
54        match self {
55            Channel::VirtualAccount(bank) => format!("va_{}", lower(&format!("{bank:?}"))),
56            Channel::Qris => "qris".to_string(),
57            Channel::EWallet(wallet) => format!("ewallet_{}", lower(&format!("{wallet:?}"))),
58            Channel::Retail(store) => format!("retail_{}", lower(&format!("{store:?}"))),
59        }
60    }
61
62    /// The inverse of [`Channel::code`].
63    pub fn parse(code: &str) -> Option<Channel> {
64        let code = code.trim().to_ascii_lowercase();
65        if code == "qris" {
66            return Some(Channel::Qris);
67        }
68        let (kind, name) = code.split_once('_')?;
69        match kind {
70            "va" => Some(Channel::VirtualAccount(match name {
71                "bca" => Bank::Bca,
72                "bni" => Bank::Bni,
73                "bri" => Bank::Bri,
74                "cimb" => Bank::Cimb,
75                "danamon" => Bank::Danamon,
76                "mandiri" => Bank::Mandiri,
77                "permata" => Bank::Permata,
78                "bnc" => Bank::Bnc,
79                _ => return None,
80            })),
81            "ewallet" => Some(Channel::EWallet(match name {
82                "dana" => Wallet::Dana,
83                "ovo" => Wallet::Ovo,
84                "shopeepay" => Wallet::ShopeePay,
85                "linkaja" => Wallet::LinkAja,
86                "gopay" => Wallet::GoPay,
87                _ => return None,
88            })),
89            "retail" => Some(Channel::Retail(match name {
90                "alfamart" => Retail::Alfamart,
91                "indomaret" => Retail::Indomaret,
92                _ => return None,
93            })),
94            _ => None,
95        }
96    }
97}
98
99fn lower(text: &str) -> String {
100    text.to_ascii_lowercase()
101}
102
103impl fmt::Display for Channel {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        f.write_str(&self.code())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    /// Every channel survives the trip through its code, which is what is
114    /// stored and what a driver sends.
115    #[test]
116    fn every_channel_round_trips_through_its_code() {
117        let all = [
118            Channel::VirtualAccount(Bank::Bca),
119            Channel::VirtualAccount(Bank::Bni),
120            Channel::VirtualAccount(Bank::Bri),
121            Channel::VirtualAccount(Bank::Cimb),
122            Channel::VirtualAccount(Bank::Danamon),
123            Channel::VirtualAccount(Bank::Mandiri),
124            Channel::VirtualAccount(Bank::Permata),
125            Channel::VirtualAccount(Bank::Bnc),
126            Channel::Qris,
127            Channel::EWallet(Wallet::Dana),
128            Channel::EWallet(Wallet::Ovo),
129            Channel::EWallet(Wallet::ShopeePay),
130            Channel::EWallet(Wallet::LinkAja),
131            Channel::EWallet(Wallet::GoPay),
132            Channel::Retail(Retail::Alfamart),
133            Channel::Retail(Retail::Indomaret),
134        ];
135        for channel in all {
136            assert_eq!(Channel::parse(&channel.code()), Some(channel), "{channel}");
137        }
138        assert_eq!(Channel::VirtualAccount(Bank::Bca).code(), "va_bca");
139        assert_eq!(Channel::EWallet(Wallet::ShopeePay).code(), "ewallet_shopeepay");
140    }
141
142    #[test]
143    fn an_unknown_code_is_none_rather_than_a_default() {
144        assert_eq!(Channel::parse("va_unknownbank"), None);
145        assert_eq!(Channel::parse("cash"), None);
146        assert_eq!(Channel::parse(""), None);
147    }
148}