1use reqwest::StatusCode;
27use std::error::Error as StdError;
28use thiserror::Error;
29
30use crate::i18n::{self, Language, MessageKey};
31
32pub type BoxError = Box<dyn StdError + Send + Sync>;
34pub type Result<T> = std::result::Result<T, Error>;
36
37#[derive(Debug, Error)]
39pub enum Error {
40 #[error("{message}")]
43 InvalidProject { message: String },
44 #[error("{message}")]
47 InvalidApiKey { message: String },
48 #[error("{message}")]
50 InvalidAmount { message: String },
51 #[error("{message}")]
53 InvalidOrderId { message: String },
54 #[error("{message}")]
59 InvalidPaymentMethod { message: String },
60 #[error("{message}: {source}")]
62 EncodeJson {
63 message: String,
64 #[source]
65 source: serde_json::Error,
66 },
67 #[error("{message}: {source}")]
69 DecodeJson {
70 message: String,
71 #[source]
72 source: serde_json::Error,
73 },
74 #[error("client: failed to create request: {source}")]
76 BuildRequest {
77 #[source]
78 source: url::ParseError,
79 },
80 #[error("pakasir api error: status {status}: {body}")]
86 Api { status: StatusCode, body: String },
87 #[error("{message}: {source}")]
90 RequestFailed {
91 message: String,
92 #[source]
93 source: BoxError,
94 },
95 #[error("{message}: {source}")]
98 RequestFailedAfterRetries {
99 message: String,
100 #[source]
101 source: BoxError,
102 },
103 #[error("response body too large: exceeds {limit} bytes")]
106 ResponseTooLarge { limit: usize },
107}
108
109impl Error {
110 pub(crate) fn invalid_project(lang: Language) -> Self {
112 Self::InvalidProject {
113 message: i18n::get(lang, MessageKey::InvalidProject).to_owned(),
114 }
115 }
116
117 pub(crate) fn invalid_api_key(lang: Language) -> Self {
119 Self::InvalidApiKey {
120 message: i18n::get(lang, MessageKey::InvalidApiKey).to_owned(),
121 }
122 }
123
124 pub(crate) fn invalid_amount(lang: Language) -> Self {
126 Self::InvalidAmount {
127 message: i18n::get(lang, MessageKey::InvalidAmount).to_owned(),
128 }
129 }
130
131 pub(crate) fn invalid_order_id(lang: Language) -> Self {
133 Self::InvalidOrderId {
134 message: i18n::get(lang, MessageKey::InvalidOrderId).to_owned(),
135 }
136 }
137
138 pub(crate) fn encode_json(lang: Language, source: serde_json::Error) -> Self {
140 Self::EncodeJson {
141 message: i18n::get(lang, MessageKey::FailedToEncode).to_owned(),
142 source,
143 }
144 }
145
146 pub(crate) fn decode_json(lang: Language, source: serde_json::Error) -> Self {
148 Self::DecodeJson {
149 message: i18n::get(lang, MessageKey::FailedToDecode).to_owned(),
150 source,
151 }
152 }
153
154 pub(crate) fn request_failed(lang: Language, source: BoxError) -> Self {
156 Self::RequestFailed {
157 message: i18n::get(lang, MessageKey::RequestFailedPermanent).to_owned(),
158 source,
159 }
160 }
161
162 pub(crate) fn request_failed_after_retries(
167 lang: Language,
168 retries: usize,
169 source: BoxError,
170 ) -> Self {
171 let template = i18n::get(lang, MessageKey::RequestFailedAfterRetries);
172 let message = template.replacen("%d", &retries.to_string(), 1);
173 Self::RequestFailedAfterRetries { message, source }
174 }
175
176 pub fn api_status(&self) -> Option<StatusCode> {
180 match self {
181 Self::Api { status, .. } => Some(*status),
182 _ => None,
183 }
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn make_serde_error() -> serde_json::Error {
192 serde_json::from_slice::<serde_json::Value>(b"not json").unwrap_err()
193 }
194
195 #[test]
196 fn invalid_project_uses_localized_message() {
197 let err = Error::invalid_project(Language::English);
198 assert_eq!(err.to_string(), "project slug is required");
199
200 let err = Error::invalid_project(Language::Indonesian);
201 assert_eq!(err.to_string(), "slug proyek wajib diisi");
202 }
203
204 #[test]
205 fn invalid_api_key_uses_localized_message() {
206 let err = Error::invalid_api_key(Language::English);
207 assert_eq!(err.to_string(), "API key is required");
208
209 let err = Error::invalid_api_key(Language::Indonesian);
210 assert_eq!(err.to_string(), "API key wajib diisi");
211 }
212
213 #[test]
214 fn invalid_amount_uses_localized_message() {
215 let err = Error::invalid_amount(Language::English);
216 assert_eq!(err.to_string(), "amount must be greater than 0");
217
218 let err = Error::invalid_amount(Language::Indonesian);
219 assert_eq!(err.to_string(), "jumlah harus lebih dari 0");
220 }
221
222 #[test]
223 fn invalid_order_id_uses_localized_message() {
224 let err = Error::invalid_order_id(Language::English);
225 assert_eq!(err.to_string(), "order ID is required");
226
227 let err = Error::invalid_order_id(Language::Indonesian);
228 assert_eq!(err.to_string(), "ID pesanan wajib diisi");
229 }
230
231 #[test]
232 fn encode_json_wraps_source_error_with_localized_prefix() {
233 let err = Error::encode_json(Language::English, make_serde_error());
234 let text = err.to_string();
235 assert!(
236 text.starts_with("failed to encode request as JSON: "),
237 "unexpected: {text}"
238 );
239 assert!(err.source().is_some());
240
241 let err = Error::encode_json(Language::Indonesian, make_serde_error());
242 assert!(
243 err.to_string()
244 .starts_with("gagal mengenkode permintaan sebagai JSON: ")
245 );
246 }
247
248 #[test]
249 fn decode_json_wraps_source_error_with_localized_prefix() {
250 let err = Error::decode_json(Language::English, make_serde_error());
251 let text = err.to_string();
252 assert!(
253 text.starts_with("failed to decode response: "),
254 "unexpected: {text}"
255 );
256 assert!(err.source().is_some());
257
258 let err = Error::decode_json(Language::Indonesian, make_serde_error());
259 assert!(err.to_string().starts_with("gagal mendekode respons: "));
260 }
261
262 #[test]
263 fn request_failed_uses_permanent_template() {
264 let err = Error::request_failed(Language::English, Box::new(std::io::Error::other("boom")));
265 assert!(
266 err.to_string()
267 .starts_with("request failed due to permanent error: ")
268 );
269 assert!(err.source().is_some());
270 }
271
272 #[test]
273 fn request_failed_after_retries_substitutes_count() {
274 let err = Error::request_failed_after_retries(
275 Language::English,
276 3,
277 Box::new(std::io::Error::other("flaky")),
278 );
279 assert!(err.to_string().contains("after 3 retries"));
280
281 let err = Error::request_failed_after_retries(
282 Language::Indonesian,
283 5,
284 Box::new(std::io::Error::other("flaky")),
285 );
286 assert!(err.to_string().contains("setelah 5 percobaan ulang"));
287 }
288
289 #[test]
290 fn api_status_returns_status_for_api_variant() {
291 let err = Error::Api {
292 status: StatusCode::BAD_REQUEST,
293 body: "bad".into(),
294 };
295 assert_eq!(err.api_status(), Some(StatusCode::BAD_REQUEST));
296 assert!(err.to_string().contains("status 400"));
297 }
298
299 #[test]
300 fn api_status_returns_none_for_other_variants() {
301 let err = Error::invalid_project(Language::English);
302 assert_eq!(err.api_status(), None);
303
304 let err = Error::ResponseTooLarge { limit: 1024 };
305 assert_eq!(err.api_status(), None);
306 assert!(err.to_string().contains("1024"));
307 }
308
309 #[test]
310 fn build_request_display_includes_source() {
311 let parse_err = url::Url::parse("not a url").unwrap_err();
312 let err = Error::BuildRequest { source: parse_err };
313 assert!(err.to_string().contains("failed to create request"));
314 assert!(err.source().is_some());
315 }
316
317 #[test]
318 fn invalid_payment_method_display_is_message() {
319 let err = Error::InvalidPaymentMethod {
322 message: "bogus".into(),
323 };
324 assert_eq!(err.to_string(), "bogus");
325 }
326}