Skip to main content

pakasir_sdk/
simulation.rs

1// Copyright 2026 H0llyW00dzZ
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Sandbox payment simulation service.
16//!
17//! Wraps the `/api/paymentsimulation` endpoint. In a sandbox project this
18//! marks an existing pending transaction as paid, which is useful for
19//! end-to-end testing of webhook handlers without involving a real bank.
20//!
21//! Validation rules match the rest of the SDK: empty `order_id` ->
22//! [`crate::Error::InvalidOrderId`], non-positive `amount` ->
23//! [`crate::Error::InvalidAmount`].
24
25use reqwest::Method;
26use serde::Serialize;
27
28use crate::client::Client;
29use crate::constants::PATH_PAYMENT_SIMULATION;
30use crate::error::{Error, Result};
31
32/// Service handle wrapping a [`Client`].
33///
34/// Cheap to clone; the inner [`Client`] is already reference counted.
35#[derive(Debug, Clone)]
36pub struct SimulationService {
37    client: Client,
38}
39
40/// Input for [`SimulationService::pay`].
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct PayRequest {
43    /// Order identifier to mark as paid.
44    pub order_id: String,
45    /// Amount, must match the existing transaction and be greater than 0.
46    pub amount: i64,
47}
48
49/// Wire-format body. Built from the request plus the credentials stored on
50/// the client so callers never have to repeat them.
51#[derive(Debug, Serialize)]
52struct RequestBody<'a> {
53    project: &'a str,
54    order_id: &'a str,
55    amount: i64,
56    api_key: &'a str,
57}
58
59impl SimulationService {
60    /// Wrap an existing [`Client`].
61    pub fn new(client: Client) -> Self {
62        Self { client }
63    }
64
65    /// Mark `order_id` as paid in the sandbox.
66    ///
67    /// The endpoint returns 200 with no useful body, so this just returns
68    /// `()` on success. Any non-2xx response surfaces as
69    /// [`crate::Error::Api`].
70    pub async fn pay(&self, request: &PayRequest) -> Result<()> {
71        if request.order_id.is_empty() {
72            return Err(Error::invalid_order_id(self.client.language()));
73        }
74        if request.amount <= 0 {
75            return Err(Error::invalid_amount(self.client.language()));
76        }
77
78        let body = serde_json::to_vec(&RequestBody {
79            project: self.client.project(),
80            order_id: &request.order_id,
81            amount: request.amount,
82            api_key: self.client.api_key(),
83        })
84        .map_err(|err| Error::encode_json(self.client.language(), err))?;
85
86        self.client
87            .do_request(Method::POST, PATH_PAYMENT_SIMULATION, Some(body))
88            .await
89            .map(|_| ())
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::Language;
97
98    /// Validation runs before any network call, so a `Client` pointed at an
99    /// unused base URL is enough to exercise the early-return error paths.
100    fn dummy_client(language: Language) -> Client {
101        Client::builder("project", "key")
102            .base_url("http://127.0.0.1:1")
103            .retries(0)
104            .language(language)
105            .build()
106    }
107
108    #[tokio::test]
109    async fn pay_rejects_empty_order_id_with_localized_message() {
110        let service = SimulationService::new(dummy_client(Language::English));
111        let err = service
112            .pay(&PayRequest {
113                order_id: String::new(),
114                amount: 1,
115            })
116            .await
117            .unwrap_err();
118        assert!(matches!(err, Error::InvalidOrderId { .. }));
119        assert_eq!(err.to_string(), "order ID is required");
120
121        let service = SimulationService::new(dummy_client(Language::Indonesian));
122        let err = service
123            .pay(&PayRequest {
124                order_id: String::new(),
125                amount: 1,
126            })
127            .await
128            .unwrap_err();
129        assert_eq!(err.to_string(), "ID pesanan wajib diisi");
130    }
131
132    #[tokio::test]
133    async fn pay_rejects_non_positive_amount() {
134        let service = SimulationService::new(dummy_client(Language::English));
135        for amount in [0_i64, -1, i64::MIN] {
136            let err = service
137                .pay(&PayRequest {
138                    order_id: "x".into(),
139                    amount,
140                })
141                .await
142                .unwrap_err();
143            assert!(
144                matches!(err, Error::InvalidAmount { .. }),
145                "amount={amount}"
146            );
147        }
148    }
149
150    #[test]
151    fn simulation_service_is_clone() {
152        // Cheap to clone — the inner Client is reference counted.
153        let service = SimulationService::new(dummy_client(Language::English));
154        let _ = service.clone();
155    }
156}