pispas_modules/
paytef.rs

1use crate::service::{Service, WebSocketWrite};
2use async_trait::async_trait;
3use futures_util::SinkExt;
4use serde_json::{json, Value};
5use std::any::Any;
6use tokio_tungstenite::tungstenite::protocol::Message;
7use easy_trace::prelude::{error, info};
8
9const PAYTEF_VERSION: &str = "1.0.0";
10const POLL_INTERVAL_MS: u64 = 1000;
11
12pub struct PaytefService;
13
14impl PaytefService {
15    pub fn new() -> Self {
16        info!("PaytefService initialized");
17        Self
18    }
19
20    fn base_url(host: &str, port: u16) -> String {
21        format!("http://{}:{}", host, port)
22    }
23
24    async fn send_ws_message(write: &WebSocketWrite, message: Value) {
25        if let Some(ws_lock) = write {
26            let serialized = Message::Text(message.to_string());
27            let mut ws = ws_lock.write().await;
28            if let Err(e) = ws.send(serialized).await {
29                error!("Failed to send WebSocket message: {}", e);
30            }
31        }
32    }
33
34    async fn http_post(url: &str, body: &Value) -> Result<Value, String> {
35        let client = reqwest::Client::new();
36        let response = client
37            .post(url)
38            .json(body)
39            .timeout(std::time::Duration::from_secs(10))
40            .send()
41            .await
42            .map_err(|e| format!("HTTP request failed: {}", e))?;
43
44        let status = response.status();
45        let text = response
46            .text()
47            .await
48            .unwrap_or_else(|_| "{}".to_string());
49
50        if !status.is_success() {
51            return Err(format!("HTTP {}: {}", status, text));
52        }
53
54        serde_json::from_str(&text)
55            .map_err(|e| format!("JSON parse error: {} - body: {}", e, text))
56    }
57
58    async fn sale(
59        host: &str,
60        port: u16,
61        amount: i64,
62        pinpad: &str,
63        reference: &str,
64        write: &WebSocketWrite,
65    ) -> Result<Value, String> {
66        let base = Self::base_url(host, port);
67
68        // 1. Start transaction
69        info!(host = host, port = port, amount = amount, "Starting Paytef sale");
70        let start_body = json!({
71            "language": "es",
72            "pinpad": pinpad,
73            "executeOptions": { "method": "polling" },
74            "opType": "sale",
75            "requestedAmount": amount,
76            "createReceipt": false,
77            "showResultSeconds": 5,
78            "transactionReference": reference
79        });
80
81        let start_resp = Self::http_post(
82            &format!("{}/transaction/start", base),
83            &start_body,
84        ).await?;
85
86        let started = start_resp
87            .get("info")
88            .and_then(|i| i.get("started"))
89            .and_then(|s| s.as_bool())
90            .unwrap_or(false);
91
92        if !started {
93            let msg = start_resp
94                .get("info")
95                .and_then(|i| i.get("message"))
96                .and_then(|m| m.as_str())
97                .unwrap_or("Unknown error");
98            return Err(format!("Failed to start transaction: {}", msg));
99        }
100
101        let session_id = start_resp
102            .get("info")
103            .and_then(|i| i.get("sessionID"))
104            .and_then(|s| s.as_str())
105            .unwrap_or("")
106            .to_string();
107
108        info!(session_id = %session_id, "Paytef transaction started");
109
110        // Send initial progress
111        Self::send_ws_message(write, json!({
112            "status": "started",
113            "sessionID": session_id
114        })).await;
115
116        // 2. Poll loop
117        let poll_body = json!({ "pinpad": pinpad });
118        loop {
119            tokio::time::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)).await;
120
121            let poll_resp = Self::http_post(
122                &format!("{}/transaction/poll", base),
123                &poll_body,
124            ).await?;
125
126            let poll_info = poll_resp.get("info").cloned().unwrap_or(json!({}));
127            let tx_status = poll_info.get("transactionStatus")
128                .and_then(|s| s.as_str())
129                .unwrap_or("");
130            let card_status = poll_info.get("cardStatus")
131                .and_then(|s| s.as_str())
132                .unwrap_or("");
133
134            // Send progress update
135            Self::send_ws_message(write, json!({
136                "status": "progress",
137                "transactionStatus": tx_status,
138                "cardStatus": card_status,
139                "sessionID": session_id
140            })).await;
141
142            // Check if result is available in poll response
143            if let Some(result) = poll_resp.get("result") {
144                if !result.is_null() {
145                    break;
146                }
147            }
148
149            // Terminal states
150            if tx_status == "finished" || tx_status == "cancelled" || tx_status == "error" {
151                break;
152            }
153        }
154
155        // 3. Get result
156        let result_resp = Self::http_post(
157            &format!("{}/transaction/result", base),
158            &poll_body,
159        ).await?;
160
161        let result = result_resp.get("result").cloned().unwrap_or(Value::Null);
162
163        if result.is_null() {
164            return Err("Transaction cancelled or no result".to_string());
165        }
166
167        let approved = result.get("approved")
168            .and_then(|a| a.as_bool())
169            .unwrap_or(false);
170
171        let response = json!({
172            "status": if approved { "approved" } else { "denied" },
173            "approved": approved,
174            "resultCode": result.get("resultCode"),
175            "resultText": result.get("resultText"),
176            "authorisationCode": result.get("authorisationCode"),
177            "cardInformation": result.get("cardInformation"),
178            "requestedAmount": result.get("requestedAmount"),
179            "sessionID": session_id
180        });
181
182        info!(
183            approved = approved,
184            session_id = %session_id,
185            "Paytef transaction completed"
186        );
187
188        Ok(response)
189    }
190
191    async fn cancel(host: &str, port: u16, pinpad: &str) -> Result<Value, String> {
192        let base = Self::base_url(host, port);
193        let body = json!({ "pinpad": pinpad });
194        let resp = Self::http_post(&format!("{}/pinpad/cancel", base), &body).await?;
195        Ok(resp)
196    }
197
198    async fn status(host: &str, port: u16) -> Result<Value, String> {
199        let base = Self::base_url(host, port);
200        let body = json!({ "pinpad": "*" });
201        let resp = Self::http_post(&format!("{}/pinpad/status", base), &body).await?;
202        Ok(resp)
203    }
204
205    async fn print(host: &str, port: u16, content: &str, pinpad: &str) -> Result<Value, String> {
206        let base = Self::base_url(host, port);
207        let body = json!({
208            "pinpad": pinpad,
209            "content": content,
210            "contentType": "html",
211            "usePrinter": true,
212            "generateImage": false
213        });
214        let resp = Self::http_post(&format!("{}/printer/print", base), &body).await?;
215        Ok(resp)
216    }
217}
218
219#[async_trait]
220impl Service for PaytefService {
221    async fn run(&self, action: Value, write: WebSocketWrite) -> (i32, String) {
222        let action_data = match action.get("ACTION").and_then(|a| a.as_object()) {
223            Some(data) => data,
224            None => {
225                error!("Invalid action format: missing 'ACTION'");
226                return (1, "Invalid action format: missing 'ACTION'".to_string());
227            }
228        };
229
230        let command = action_data
231            .get("command")
232            .and_then(|c| c.as_str())
233            .unwrap_or("");
234
235        let host = action_data
236            .get("host")
237            .and_then(|h| h.as_str())
238            .unwrap_or("127.0.0.1");
239
240        let port = action_data
241            .get("port")
242            .and_then(|p| p.as_u64())
243            .unwrap_or(8887) as u16;
244
245        let pinpad = action_data
246            .get("pinpad")
247            .and_then(|p| p.as_str())
248            .unwrap_or("*");
249
250        match command {
251            "SALE" => {
252                let amount = action_data
253                    .get("amount")
254                    .and_then(|a| a.as_i64())
255                    .unwrap_or(0);
256
257                let reference = action_data
258                    .get("reference")
259                    .and_then(|r| r.as_str())
260                    .unwrap_or("");
261
262                if amount <= 0 {
263                    return (1, "Invalid amount: must be > 0".to_string());
264                }
265
266                match Self::sale(host, port, amount, pinpad, reference, &write).await {
267                    Ok(result) => (0, result.to_string()),
268                    Err(e) => {
269                        error!(error = %e, "Paytef SALE failed");
270                        (1, json!({ "status": "error", "error": e }).to_string())
271                    }
272                }
273            }
274
275            "CANCEL" => {
276                match Self::cancel(host, port, pinpad).await {
277                    Ok(result) => (0, result.to_string()),
278                    Err(e) => {
279                        error!(error = %e, "Paytef CANCEL failed");
280                        (1, json!({ "status": "error", "error": e }).to_string())
281                    }
282                }
283            }
284
285            "STATUS" => {
286                match Self::status(host, port).await {
287                    Ok(result) => (0, result.to_string()),
288                    Err(e) => {
289                        error!(error = %e, "Paytef STATUS failed");
290                        (1, json!({ "status": "error", "error": e }).to_string())
291                    }
292                }
293            }
294
295            "PRINT" => {
296                let content = action_data
297                    .get("content")
298                    .and_then(|c| c.as_str())
299                    .unwrap_or("");
300
301                if content.is_empty() {
302                    return (1, "Missing print content".to_string());
303                }
304
305                match Self::print(host, port, content, pinpad).await {
306                    Ok(result) => (0, result.to_string()),
307                    Err(e) => {
308                        error!(error = %e, "Paytef PRINT failed");
309                        (1, json!({ "status": "error", "error": e }).to_string())
310                    }
311                }
312            }
313
314            _ => {
315                error!(command = command, "Unknown Paytef command");
316                (1, format!("Unknown command: {}", command))
317            }
318        }
319    }
320
321    fn as_any(&self) -> &dyn Any {
322        self
323    }
324
325    fn stop_service(&self) {
326        info!("PaytefService stopped.");
327    }
328
329    fn get_version(&self) -> String {
330        PAYTEF_VERSION.to_string()
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_base_url() {
340        assert_eq!(
341            PaytefService::base_url("192.168.1.178", 8887),
342            "http://192.168.1.178:8887"
343        );
344    }
345
346    #[test]
347    fn test_base_url_localhost() {
348        assert_eq!(
349            PaytefService::base_url("127.0.0.1", 8887),
350            "http://127.0.0.1:8887"
351        );
352    }
353
354    #[tokio::test]
355    async fn test_sale_invalid_amount() {
356        let service = PaytefService::new();
357        let action = json!({
358            "ACTION": {
359                "command": "SALE",
360                "host": "127.0.0.1",
361                "port": 8887,
362                "amount": 0,
363                "pinpad": "*"
364            }
365        });
366
367        let (status, msg) = service.run(action, None).await;
368        assert_eq!(status, 1);
369        assert!(msg.contains("Invalid amount"));
370    }
371
372    #[tokio::test]
373    async fn test_missing_action() {
374        let service = PaytefService::new();
375        let action = json!({ "no_action": true });
376
377        let (status, msg) = service.run(action, None).await;
378        assert_eq!(status, 1);
379        assert!(msg.contains("missing 'ACTION'"));
380    }
381
382    #[tokio::test]
383    async fn test_unknown_command() {
384        let service = PaytefService::new();
385        let action = json!({
386            "ACTION": {
387                "command": "UNKNOWN_CMD",
388                "host": "127.0.0.1",
389                "port": 8887
390            }
391        });
392
393        let (status, msg) = service.run(action, None).await;
394        assert_eq!(status, 1);
395        assert!(msg.contains("Unknown command"));
396    }
397}