promptpay_rs/
lib.rs

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