promptpay_rs/
lib.rs

1use std::error::Error;
2use std::fmt;
3
4/// ข้อผิดพลาดที่เกิดขึ้นในระหว่างการสร้าง PromptPay QR code
5#[derive(Debug)]
6pub struct PromptPayError {
7    details: String,
8}
9
10impl PromptPayError {
11    /// สร้าง instance ใหม่ของ `PromptPayError` ด้วยข้อความข้อผิดพลาด
12    /// # Arguments
13    /// * `msg` - ข้อความที่อธิบายข้อผิดพลาด
14    /// # Returns
15    /// instance ของ `PromptPayError`
16    fn new(msg: &str) -> PromptPayError {
17        PromptPayError {
18            details: msg.to_string(),
19        }
20    }
21}
22
23impl fmt::Display for PromptPayError {
24    /// จัดรูปแบบการแสดงผลข้อผิดพลาดสำหรับ `PromptPayError`
25    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
26        write!(f, "{}", self.details)
27    }
28}
29
30impl Error for PromptPayError {
31    /// คืนค่าคำอธิบายของข้อผิดพลาด
32    fn description(&self) -> &str {
33        &self.details
34    }
35}
36
37/// โครงสร้างสำหรับสร้าง PromptPay QR code ตามมาตรฐาน EMVCo
38pub struct PromptPayQR {
39    merchant_id: String,   // รหัสผู้รับเงิน (เช่น เบอร์โทรศัพท์, Tax ID, หรือ E-Wallet ID)
40    amount: Option<f64>,   // จำนวนเงิน (ถ้ามี)
41    country_code: String,  // รหัสประเทศ (เช่น "TH" สำหรับประเทศไทย)
42    currency_code: String, // รหัสสกุลเงิน (เช่น "764" สำหรับบาทไทย)
43}
44
45/// Trait สำหรับ Formatter ที่สามารถแปลงผลลัพธ์เป็นรูปแบบต่างๆ
46pub trait FormatterTrait {
47    /// แปลง payload เป็น String
48    fn to_string(&self) -> String;
49    // fn to_image_byte(&self)
50}
51
52/// โครงสร้างสำหรับจัดการผลลัพธ์
53#[derive(Debug)]
54pub struct Formatter {
55    payload: String,
56}
57
58impl Formatter {
59    /// สร้าง instance ใหม่ของ `Formatter`
60    /// # Arguments
61    /// * `payload` - ข้อมูลที่ได้จากการสร้าง QRCode
62    /// # Returns
63    /// instance ของ `Formatter`
64    pub fn new(payload: &str) -> Self {
65        Self {
66            payload: payload.to_string(),
67        }
68    }
69}
70
71impl FormatterTrait for Formatter {
72    /// คืนค่า payload ในรูปแบบ String
73    fn to_string(&self) -> String {
74        self.payload.clone()
75    }
76}
77
78impl PromptPayQR {
79    /// สร้าง instance ใหม่ของ `PromptPayQR`
80    /// # Arguments
81    /// * `merchant_id` - รหัสผู้รับเงิน (เบอร์โทรศัพท์, Tax ID, หรือ E-Wallet ID)
82    /// # Returns
83    /// instance ของ `PromptPayQR` ด้วยค่าเริ่มต้นสำหรับประเทศไทย (TH, 764)
84    pub fn new(merchant_id: &str) -> Self {
85        PromptPayQR {
86            merchant_id: merchant_id.to_string(),
87            amount: None,
88            country_code: "TH".to_string(),
89            currency_code: "764".to_string(),
90        }
91    }
92
93    /// กำหนดจำนวนเงินสำหรับการทำธุรกรรม
94    /// # Arguments
95    /// * `amount` - จำนวนเงิน (ในหน่วยบาท, รูปแบบทศนิยมสองตำแหน่ง)
96    /// # Returns
97    /// อ้างอิงถึง instance นี้เพื่อให้สามารถ chain method ได้
98    pub fn set_amount(&mut self, amount: f64) -> &mut Self {
99        self.amount = Some(amount);
100        self
101    }
102
103    /// ลบตัวอักษรที่ไม่ใช่ตัวเลขออกจากรหัสผู้รับเงิน
104    /// # Arguments
105    /// * `id` - รหัสผู้รับเงิน (เช่น เบอร์โทรศัพท์หรือ Tax ID)
106    /// # Returns
107    /// สตริงที่มีเฉพาะตัวเลข
108    fn sanitize_target(&self, id: &str) -> String {
109        id.chars().filter(|c| c.is_digit(10)).collect()
110    }
111
112    /// จัดรูปแบบรหัสผู้รับเงินให้เป็นไปตามมาตรฐาน PromptPay
113    /// - ถ้าเป็นเบอร์โทรศัพท์ (< 13 หลัก): แปลงรหัสประเทศจาก "0" เป็น "66" และเติมศูนย์ให้ครบ 13 หลัก
114    /// - ถ้าเป็น Tax ID หรือ E-Wallet ID (≥ 13 หลัก): ใช้ตามเดิม
115    /// # Arguments
116    /// * `id` - รหัสผู้รับเงิน
117    /// # Returns
118    /// รหัสผู้รับเงินที่ถูกจัดรูปแบบแล้ว
119    fn format_target(&self, id: &str) -> String {
120        if id.len() >= 13 {
121            id.to_string()
122        } else if id.starts_with("0") {
123            // แปลงรหัสประเทศจาก "0" เป็น "66" เฉพาะตัวแรก และเติมศูนย์ให้ครบ 13 หลัก
124            let replaced = id.replacen("0", "66", 1);
125            format!("{:0>13}", replaced)
126        } else {
127            // เติมศูนย์ให้ครบ 13 หลัก
128            format!("{:0>13}", id)
129        }
130    }
131
132    /// สร้าง payload สำหรับ QR Code PromptPay ตามมาตรฐาน EMVCo
133    /// # Returns
134    /// ผลลัพธ์เป็น `Result` ที่มี Formatter หรือข้อผิดพลาด
135    pub fn create(&self) -> Result<Formatter, PromptPayError> {
136        if self.merchant_id.is_empty() {
137            return Err(PromptPayError::new("Merchant ID is required"));
138        }
139
140        // sanitize ข้อมูลที่รับมา
141        let merchant_id = self.sanitize_target(&self.merchant_id);
142
143        let mut payload = String::new();
144
145        // เพิ่ม Payload Format Indicator (ID 00, ค่า "01" สำหรับ EMVCo QR)
146        payload.push_str("000201");
147
148        // เพิ่ม Point of Initiation Method
149        // - "010211" สำหรับ QR แบบ static (ไม่มีจำนวนเงิน)
150        // - "010212" สำหรับ QR แบบ dynamic (มีจำนวนเงิน)
151        payload.push_str(if self.amount.is_some() {
152            "010212"
153        } else {
154            "010211"
155        });
156
157        // สร้าง Merchant Account Information (ID 29)
158        let mut merchant_info = String::new();
159        // เพิ่ม PromptPay AID (Application Identifier)
160        merchant_info.push_str("0016A000000677010111"); // PromptPay AID
161        // กำหนดประเภทของรหัสผู้รับเงิน
162        // - "01" สำหรับเบอร์โทรศัพท์
163        // - "02" สำหรับ Tax ID
164        // - "03" สำหรับ E-Wallet ID
165        let target_type = if merchant_id.len() >= 15 {
166            "03" // E-Wallet ID
167        } else if merchant_id.len() >= 13 {
168            "02" // Tax ID
169        } else {
170            "01" // Phone Number
171        };
172        let formatted_target = self.format_target(&merchant_id);
173        let merchant_id_field = format!(
174            "{}{:02}{}",
175            target_type,
176            formatted_target.len(),
177            formatted_target
178        );
179        merchant_info.push_str(&merchant_id_field);
180
181        // เพิ่มความยาวและข้อมูล Merchant Account Information
182        let merchant_info_len = format!("{:02}", merchant_info.len());
183        payload.push_str(&format!("29{}", merchant_info_len));
184        payload.push_str(&merchant_info);
185
186        // เพิ่ม Country Code (ID 58, "TH" สำหรับประเทศไทย)
187        payload.push_str(&format!("5802{}", self.country_code));
188
189        // เพิ่ม Currency Code (ID 53, "764" สำหรับบาทไทย)
190        payload.push_str(&format!("5303{}", self.currency_code));
191
192        // เพิ่มจำนวนเงิน (ถ้ามี) (ID 54)
193        if let Some(amount) = self.amount {
194            let amount_str = format!("{:.2}", amount);
195            let amount_len = format!("{:02}", amount_str.len());
196            payload.push_str(&format!("54{}", amount_len));
197            payload.push_str(&amount_str);
198        }
199
200        // เพิ่ม CRC (ID 63)
201        payload.push_str("6304");
202        let crc = self.calculate_crc(&payload);
203        payload.push_str(&format!("{:04X}", crc));
204
205        Ok(Formatter::new(&payload))
206    }
207
208    /// คำนวณ CRC-16 (CCITT) สำหรับ payload เพื่อใช้ใน QR Code
209    /// ใช้ polynomial 0x1021 และค่าเริ่มต้น 0xFFFF ตามมาตรฐาน EMVCo
210    /// # Arguments
211    /// * `data` - สตริง payload ที่ใช้คำนวณ CRC (รวม "6304")
212    /// # Returns
213    /// ค่า CRC ในรูปแบบ u16
214    fn calculate_crc(&self, data: &str) -> u16 {
215        let mut crc: u16 = 0xFFFF;
216        let polynomial: u16 = 0x1021;
217
218        for byte in data.bytes() {
219            crc ^= (byte as u16) << 8;
220            for _ in 0..8 {
221                if (crc & 0x8000) != 0 {
222                    crc = (crc << 1) ^ polynomial;
223                } else {
224                    crc <<= 1;
225                }
226            }
227        }
228        crc
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    /// ทดสอบการสร้าง payload สำหรับ QR Code ด้วยหมายเลขโทรศัพท์และจำนวนเงิน
237    #[test]
238    fn test_create_qr_phone_with_amount() {
239        let mut qr = PromptPayQR::new("0812345678");
240        qr.set_amount(100.50);
241        let result = qr.create().unwrap();
242        let data = result.to_string();
243        assert!(!data.is_empty());
244        assert!(data.starts_with("000201010212")); // Dynamic QR
245        assert!(data.contains("01130066812345678")); // ตรวจสอบหมายเลขโทรศัพท์
246        assert!(data.contains("5406100.50")); // ตรวจสอบจำนวนเงิน
247        assert!(data.contains("5802TH")); // Country Code
248        assert!(data.contains("5303764")); // Currency Code
249        assert!(data.len() >= 8);
250        let crc_part = &data[data.len() - 8..];
251        assert!(crc_part.starts_with("6304"));
252        assert!(crc_part[4..].chars().all(|c| c.is_ascii_hexdigit()));
253    }
254
255    /// ทดสอบการสร้าง payload สำหรับ QR Code ด้วยหมายเลขโทรศัพท์ที่ขึ้นต้นด้วย +66
256    #[test]
257    fn test_create_qr_phone_plus_66() {
258        let mut qr = PromptPayQR::new("+66-8-1234-500 0");
259        qr.set_amount(100.50);
260        let result = qr.create().unwrap();
261        let data = result.to_string();
262        assert!(!data.is_empty());
263        assert!(data.starts_with("000201010212")); // Dynamic QR
264        assert!(data.contains("01130066812345000")); // ตรวจสอบหมายเลขโทรศัพท์
265        assert!(data.contains("5406100.50")); // ตรวจสอบจำนวนเงิน
266        assert!(data.contains("5802TH"));
267        assert!(data.contains("5303764"));
268        assert!(data.len() >= 8);
269        let crc_part = &data[data.len() - 8..];
270        assert!(crc_part.starts_with("6304"));
271        assert!(crc_part[4..].chars().all(|c| c.is_ascii_hexdigit()));
272    }
273
274    /// ทดสอบการสร้าง payload สำหรับ QR Code ด้วยหมายเลขโทรศัพท์ที่ไม่มีจำนวนเงิน
275    #[test]
276    fn test_create_qr_phone_no_amount() {
277        let qr = PromptPayQR::new("0812345678");
278        let result = qr.create().unwrap();
279        let data = result.to_string();
280        assert!(!data.is_empty());
281        assert!(data.starts_with("000201010211")); // Static QR
282        assert!(data.contains("01130066812345678")); // ตรวจสอบหมายเลขโทรศัพท์
283        assert!(!data.contains("54")); // ไม่มีฟิลด์จำนวนเงิน
284        assert!(data.contains("5802TH"));
285        assert!(data.contains("5303764"));
286        assert!(data.len() >= 8);
287        let crc_part = &data[data.len() - 8..];
288        assert!(crc_part.starts_with("6304"));
289        assert!(crc_part[4..].chars().all(|c| c.is_ascii_hexdigit()));
290    }
291
292    /// ทดสอบการสร้าง payload สำหรับ QR Code ด้วย Tax ID
293    #[test]
294    fn test_create_qr_tax_id() {
295        let qr = PromptPayQR::new("1234567890123");
296        let result = qr.create().unwrap();
297        let data = result.to_string();
298        assert!(!data.is_empty());
299        assert!(data.starts_with("000201010211")); // Static QR
300        assert!(data.contains("02131234567890123")); // ตรวจสอบ Tax ID
301        assert!(!data.contains("54")); // ไม่มีฟิลด์จำนวนเงิน
302        assert!(data.contains("5802TH"));
303        assert!(data.contains("5303764"));
304        assert!(data.len() >= 8);
305        let crc_part = &data[data.len() - 8..];
306        assert!(crc_part.starts_with("6304"));
307        assert!(crc_part[4..].chars().all(|c| c.is_ascii_hexdigit()));
308    }
309
310    /// ทดสอบการสร้าง payload สำหรับ QR Code ด้วย E-Wallet ID
311    #[test]
312    fn test_create_qr_ewallet_id() {
313        let qr = PromptPayQR::new("123456789012345");
314        let result = qr.create().unwrap();
315        let data = result.to_string();
316        assert!(!data.is_empty());
317        assert!(data.starts_with("000201010211")); // Static QR
318        assert!(data.contains("0315123456789012345")); // ตรวจสอบ E-Wallet ID
319        assert!(!data.contains("54")); // ไม่มีฟิลด์จำนวนเงิน
320        assert!(data.contains("5802TH"));
321        assert!(data.contains("5303764"));
322        assert!(data.len() >= 8);
323        let crc_part = &data[data.len() - 8..];
324        assert!(crc_part.starts_with("6304"));
325        assert!(crc_part[4..].chars().all(|c| c.is_ascii_hexdigit()));
326    }
327
328    /// ทดสอบการจัดการข้อผิดพลาดเมื่อ merchant_id ว่างเปล่า
329    #[test]
330    fn test_create_qr_empty_merchant_id() {
331        let qr = PromptPayQR::new("");
332        let result = qr.create();
333        assert!(result.is_err());
334        assert_eq!(result.unwrap_err().to_string(), "Merchant ID is required");
335    }
336
337    /// ทดสอบการล้างข้อมูล (sanitize_target) สำหรับหมายเลขโทรศัพท์ที่มีตัวอักษรพิเศษ
338    #[test]
339    fn test_sanitize_target_phone() {
340        let qr = PromptPayQR::new("+66-8-1234-500 0");
341        let sanitized = qr.sanitize_target(&qr.merchant_id);
342        assert_eq!(sanitized, "66812345000");
343    }
344
345    /// ทดสอบการจัดรูปแบบ (format_target) สำหรับหมายเลขโทรศัพท์
346    #[test]
347    fn test_format_target_phone() {
348        let qr = PromptPayQR::new("0812345678");
349        let formatted = qr.format_target(&qr.sanitize_target(&qr.merchant_id));
350        assert_eq!(formatted, "0066812345678");
351    }
352
353    /// ทดสอบการจัดรูปแบบ (format_target) สำหรับ Tax ID
354    #[test]
355    fn test_format_target_tax_id() {
356        let qr = PromptPayQR::new("1234567890123");
357        let formatted = qr.format_target(&qr.sanitize_target(&qr.merchant_id));
358        assert_eq!(formatted, "1234567890123");
359    }
360
361    /// ทดสอบการจัดรูปแบบ (format_target) สำหรับ E-Wallet ID
362    #[test]
363    fn test_format_target_ewallet_id() {
364        let qr = PromptPayQR::new("123456789012345");
365        let formatted = qr.format_target(&qr.sanitize_target(&qr.merchant_id));
366        assert_eq!(formatted, "123456789012345");
367    }
368
369    /// ทดสอบการคำนวณ CRC - ใช้ payload จริงที่สร้างจาก create() method
370    #[test]
371    fn test_calculate_crc() {
372        let qr = PromptPayQR::new("0812345678");
373        let result = qr.create().unwrap();
374        let full_payload = result.to_string();
375
376        // แยก payload ที่ไม่รวม CRC (ตัด 4 หลักสุดท้ายออก) และเพิ่ม "6304"
377        let payload_without_crc = &full_payload[..full_payload.len() - 4];
378        let crc = qr.calculate_crc(payload_without_crc);
379        let expected_crc = &full_payload[full_payload.len() - 4..];
380
381        assert_eq!(format!("{:04X}", crc), expected_crc);
382    }
383
384    /// ทดสอบการคำนวณ CRC ด้วยค่าที่ทราบแน่นอน
385    #[test]
386    fn test_calculate_crc_known_value() {
387        let qr = PromptPayQR::new("0812345678");
388        // สร้าง payload จริงและใช้ส่วนที่ไม่รวม CRC
389        let result = qr.create().unwrap();
390        let full_payload = result.to_string();
391        let payload_without_crc = &full_payload[..full_payload.len() - 4];
392        let crc = qr.calculate_crc(payload_without_crc);
393        // ค่า CRC ที่คำนวณได้จริง
394        assert_eq!(format!("{:04X}", crc), "5D82");
395    }
396}