ethqr_gen/
lib.rs

1//! # ethqr-gen
2//!
3//! A Rust library for generating EMVCo-compliant QR codes for payments according
4//! to the Ethiopian Interoperable QR Standard.
5//!
6//! ## Features
7//!
8//! - `EMVCo` QR Code standard compliance
9//! - Support for multiple payment schemes (Visa, Mastercard, IPS ET, etc.)
10//! - Static and dynamic QR code generation
11//! - QR code image generation (with `qr-image` feature)
12//!
13//! ## Quick Start
14//!
15//! ### Static QR Code (no predefined amount)
16//!
17//! ```rust
18//! use ethqr_gen::{QRBuilder, fields::SchemeConfig};
19//!
20//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
21//! let qr_code = QRBuilder::new()
22//!     .merchant_name("Coffee Shop")
23//!     .merchant_city("Addis Ababa")
24//!     .merchant_category_code("5812") // Restaurant
25//!     .add_scheme(SchemeConfig::visa("4111111111111111"))
26//!     .build()?;
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! ### Dynamic QR Code (with specific amount)
32//!
33//! ```rust
34//! use ethqr_gen::{QRBuilder, fields::{SchemeConfig, AdditionalData}};
35//!
36//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
37//! let additional_data = AdditionalData::new()
38//!     .bill_number("INV-001")
39//!     .reference_label("ORDER-123");
40//!
41//! let qr_code = QRBuilder::new()
42//!     .merchant_name("Restaurant")
43//!     .merchant_city("Dire Dawa")
44//!     .merchant_category_code("5812")
45//!     .add_scheme(SchemeConfig::ips_et(
46//!         "581b314e257f41bfbbdc6384daa31d16",
47//!         "CBETETAA",
48//!         "10000171234567890",
49//!     ))
50//!     .transaction_amount("50.00")
51//!     .additional_data(additional_data)
52//!     .build()?;
53//! # Ok(())
54//! # }
55//! ```
56//!
57//! ### QR Code Image Generation (requires `qr-image` feature)
58//!
59//! ```rust
60//! use ethqr_gen::{QRBuilder, fields::SchemeConfig};
61//!
62//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
63//! let qr_image = QRBuilder::new()
64//!     .merchant_name("Coffee Shop")
65//!     .merchant_city("Addis Ababa")
66//!     .merchant_category_code("5812")
67//!     .add_scheme(SchemeConfig::visa("4111111111111111"))
68//!     .build_image()?;
69//!
70//! // Save to file
71//! qr_image.save("/tmp/payment_qr.png")?;
72//! # Ok(())
73//! # }
74//! ```
75//!
76//! ## Payment Schemes
77//!
78//! The library supports multiple payment schemes through the [`SchemeConfig`] type:
79//!
80//! - **Visa**: `SchemeConfig::visa("account_info")`
81//! - **Mastercard**: `SchemeConfig::mastercard("account_info")`
82//! - **Unionpay**: `SchemeConfig::unionpay("account_info")`
83//! - **IPS ET**: `SchemeConfig::ips_et("guid", "bic", "account_info")` (Ethiopian Interbank Payment System)
84
85pub mod crc;
86pub mod error;
87pub mod fields;
88
89use std::fmt::{self, Write};
90
91use crate::error::{QRError, Result};
92use crate::fields::{AdditionalData, SchemeConfig};
93
94#[cfg(feature = "qr-image")]
95use image::{DynamicImage, ImageBuffer, Luma};
96use qrcode::QrCode;
97
98pub mod constants {
99    pub const PAYLOAD_FORMAT_INDICATOR: &str = "01";
100    pub const ETB_CURRENCY_CODE: &str = "230";
101    pub const ETHIOPIA_COUNTRY_CODE: &str = "ET";
102    pub const MAX_QR_LENGTH: usize = 512;
103    pub const STATIC_QR_POI: &str = "11";
104    pub const DYNAMIC_QR_POI: &str = "12";
105    pub const MAX_MERCHANT_NAME_LEN: usize = 25;
106    pub const MAX_MERCHANT_CITY_LEN: usize = 15;
107
108    pub const DEFAULT_QRIMAGE_SIZE: u32 = 10;
109}
110
111pub mod tags {
112    pub const PAYLOAD_FORMAT_INDICATOR: &str = "00";
113    pub const POINT_OF_INITIATION: &str = "01";
114    pub const MERCHANT_CATEGORY_CODE: &str = "52";
115    pub const TRANSACTION_CURRENCY: &str = "53";
116    pub const TRANSACTION_AMOUNT: &str = "54";
117    pub const COUNTRY_CODE: &str = "58";
118    pub const MERCHANT_NAME: &str = "59";
119    pub const MERCHANT_CITY: &str = "60";
120    pub const ADDITIONAL_DATA: &str = "62";
121    pub const CRC: &str = "63";
122    pub const ALTERNATE_LANGUAGE: &str = "64";
123    pub const TRANSACTION_CONTEXT: &str = "80";
124
125    // Scheme allocations
126    pub const VISA: &str = "02";
127    pub const MASTERCARD: &str = "04";
128    pub const UNIONPAY: &str = "15";
129    pub const IPS_ET: &str = "28";
130}
131
132/// Represents an EMV tag with ID, length, and value
133#[derive(Debug, Clone, PartialEq)]
134pub struct EMVTag {
135    pub id: String,
136    pub value: String,
137}
138
139impl EMVTag {
140    pub fn new(id: impl Into<String>, value: impl Into<String>) -> Self {
141        Self {
142            id: id.into(),
143            value: value.into(),
144        }
145    }
146
147    /// Get the length of the value
148    pub fn length(&self) -> usize {
149        self.value.len()
150    }
151
152    /// Encode as TLV string
153    pub fn encode(&self) -> String {
154        format!("{}{:02}{}", self.id, self.length(), self.value)
155    }
156}
157
158/// Builder for constructing QR codes
159#[derive(Default, Clone)]
160pub struct QRBuilder {
161    payload_format_indicator: String,
162    merchant_name: String,
163    merchant_city: String,
164    merchant_category_code: String,
165    schemes: Vec<SchemeConfig>,
166    transaction_amount: Option<String>,
167    transaction_currency: String,
168    additional_data: Option<AdditionalData>,
169    transaction_context: Option<String>,
170}
171
172impl QRBuilder {
173    #[must_use]
174    pub fn new() -> Self {
175        Self {
176            payload_format_indicator: constants::PAYLOAD_FORMAT_INDICATOR.to_string(),
177            transaction_currency: constants::ETB_CURRENCY_CODE.to_string(),
178            ..Self::default()
179        }
180    }
181
182    /// Set merchant name
183    pub fn merchant_name(mut self, name: impl Into<String>) -> Self {
184        self.merchant_name = name.into();
185        self
186    }
187
188    /// Set merchant city
189    pub fn merchant_city(mut self, city: impl Into<String>) -> Self {
190        self.merchant_city = city.into();
191        self
192    }
193
194    /// Set merchant category code (ISO 4217)
195    pub fn merchant_category_code(mut self, code: impl Into<String>) -> Self {
196        self.merchant_category_code = code.into();
197        self
198    }
199
200    /// Add a payment scheme
201    pub fn add_scheme(mut self, scheme: SchemeConfig) -> Self {
202        self.schemes.push(scheme);
203        self
204    }
205
206    /// Set transaction amount (for dynamic QR)
207    pub fn transaction_amount(mut self, amount: impl Into<String>) -> Self {
208        self.transaction_amount = Some(amount.into());
209        self
210    }
211
212    /// Set additional data
213    pub fn additional_data(mut self, data: AdditionalData) -> Self {
214        self.additional_data = Some(data);
215        self
216    }
217
218    /// Set transaction context
219    pub fn transaction_context(mut self, context: impl Into<String>) -> Self {
220        self.transaction_context = Some(context.into());
221        self
222    }
223
224    fn validate(&self) -> Result<()> {
225        // Validate merchant information
226        if self.merchant_name.len() > constants::MAX_MERCHANT_NAME_LEN {
227            return Err(QRError::ValueTooLong {
228                field: "name".to_string(),
229                length: self.merchant_name.len(),
230                max_length: constants::MAX_MERCHANT_NAME_LEN,
231            });
232        }
233
234        if self.merchant_city.len() > constants::MAX_MERCHANT_CITY_LEN {
235            return Err(QRError::ValueTooLong {
236                field: "city".to_string(),
237                length: self.merchant_city.len(),
238                max_length: constants::MAX_MERCHANT_CITY_LEN,
239            });
240        }
241
242        // Validate category code format (4 digits)
243        if self.merchant_category_code.len() != 4
244            || !self
245                .merchant_category_code
246                .chars()
247                .all(|c| c.is_ascii_digit())
248        {
249            return Err(QRError::InvalidValue {
250                field: "category_code".to_string(),
251                value: self.merchant_category_code.clone(),
252            });
253        }
254
255        if self.schemes.is_empty() {
256            return Err(QRError::MissingField {
257                field: "schemes".to_string(),
258            });
259        }
260
261        Ok(())
262    }
263
264    fn build_payload(&self) -> Result<String> {
265        self.validate()?;
266
267        let point_of_initiation = if self.transaction_amount.is_some() {
268            Some(constants::DYNAMIC_QR_POI.to_string())
269        } else {
270            Some(constants::STATIC_QR_POI.to_string())
271        };
272
273        let mut tags = Vec::new();
274
275        // Payload Format Indicator (mandatory)
276        tags.push(EMVTag::new(
277            tags::PAYLOAD_FORMAT_INDICATOR,
278            &self.payload_format_indicator,
279        ));
280
281        // Point of Initiation (optional)
282        if let Some(ref poi) = point_of_initiation {
283            tags.push(EMVTag::new(tags::POINT_OF_INITIATION, poi));
284        }
285
286        // Merchant Account Information (schemes)
287        for scheme in &self.schemes {
288            tags.push(scheme.encode()?);
289        }
290
291        // Merchant Category Code (mandatory)
292        tags.push(EMVTag::new(
293            tags::MERCHANT_CATEGORY_CODE,
294            &self.merchant_category_code,
295        ));
296
297        // Transaction Currency (mandatory)
298        tags.push(EMVTag::new(
299            tags::TRANSACTION_CURRENCY,
300            &self.transaction_currency,
301        ));
302
303        // Transaction Amount (optional)
304        if let Some(ref amount) = self.transaction_amount {
305            tags.push(EMVTag::new(tags::TRANSACTION_AMOUNT, amount));
306        }
307
308        // Country Code (mandatory)
309        tags.push(EMVTag::new(
310            tags::COUNTRY_CODE,
311            constants::ETHIOPIA_COUNTRY_CODE,
312        ));
313
314        // Merchant Name (mandatory)
315        tags.push(EMVTag::new(tags::MERCHANT_NAME, &self.merchant_name));
316
317        // Merchant City (mandatory)
318        tags.push(EMVTag::new(tags::MERCHANT_CITY, &self.merchant_city));
319
320        // Additional Data (optional)
321        if let Some(ref additional_data) = self.additional_data
322            && let Some(tag) = additional_data.encode()
323        {
324            tags.push(tag);
325        }
326
327        // Transaction Context (optional)
328        if let Some(ref context) = self.transaction_context {
329            tags.push(EMVTag::new(tags::TRANSACTION_CONTEXT, context));
330        }
331
332        // Build payload without CRC
333        let mut payload = tags.iter().map(EMVTag::encode).collect::<String>();
334
335        // Calculate and append CRC
336        let crc = crc::calculate_crc16(&format!("{payload}6304"));
337        write!(&mut payload, "63{:02}{}", crc.len(), crc).map_err(|e| QRError::BuilderError {
338            message: format!("Failed to build QR code: {e}"),
339        })?;
340
341        // Validate length
342        if payload.len() > constants::MAX_QR_LENGTH {
343            return Err(QRError::PayloadTooLong {
344                length: payload.len(),
345            });
346        }
347
348        Ok(payload)
349    }
350
351    /// Build the QR code and return a QR code object
352    ///
353    /// # Errors
354    pub fn build(&self) -> Result<QrCode> {
355        let payload = self.build_payload()?;
356
357        Ok(QrCode::new(&payload)?)
358    }
359
360    /// Build QR code as an image
361    #[cfg(feature = "qr-image")]
362    pub fn build_image(&mut self) -> Result<DynamicImage> {
363        let image = self
364            .build()?
365            .render::<Luma<u8>>()
366            .module_dimensions(
367                constants::DEFAULT_QRIMAGE_SIZE,
368                constants::DEFAULT_QRIMAGE_SIZE,
369            )
370            .build();
371
372        Ok(DynamicImage::ImageLuma8(image))
373    }
374
375    /// Build QR code as an image with custom module size
376    #[cfg(feature = "qr-image")]
377    pub fn build_image_with_size(&mut self, module_size: u32) -> Result<DynamicImage> {
378        let qr_code = self.build()?;
379
380        let image = qr_code
381            .render::<Luma<u8>>()
382            .module_dimensions(module_size, module_size)
383            .build();
384
385        Ok(DynamicImage::ImageLuma8(image))
386    }
387
388    /// Build QR code as a raw image buffer
389    #[cfg(feature = "qr-image")]
390    pub fn build_raw_image(&mut self) -> Result<ImageBuffer<Luma<u8>, Vec<u8>>> {
391        let qr_code = self.build()?;
392
393        let image = qr_code
394            .render::<Luma<u8>>()
395            .module_dimensions(10, 10)
396            .build();
397
398        Ok(image)
399    }
400
401    /// Build QR code as a raw image buffer with custom module size
402    #[cfg(feature = "qr-image")]
403    pub fn build_raw_image_with_size(
404        &mut self,
405        module_size: u32,
406    ) -> Result<ImageBuffer<Luma<u8>, Vec<u8>>> {
407        let qr_code = self.build()?;
408
409        let image = qr_code
410            .render::<Luma<u8>>()
411            .module_dimensions(module_size, module_size)
412            .build();
413
414        Ok(image)
415    }
416}
417
418impl fmt::Display for QRBuilder {
419    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420        match self.build_payload() {
421            Ok(payload) => write!(f, "{payload}"),
422            Err(e) => write!(f, "QR Error: {e}"),
423        }
424    }
425}