- store provider_order_id (Midtrans order id) on payment create
- POST /payments/{id}/refresh: query Midtrans v2/{order}/status (settlement/capture → paid + confirm session)
- fix: status URL must end with /status; force Accept-Encoding: identity (istio gzip mangling)
- e2e verified: simulated VA payment in sandbox → refresh → paid + session confirmed
180 lines
4.8 KiB
Rust
180 lines
4.8 KiB
Rust
//! Midtrans payment provider — Core API (v2/charge).
|
|
//!
|
|
//! Gateway-agnostic design: the payments service calls into this module when
|
|
//! Midtrans credentials are configured (`MIDTRANS_*` env). It returns the
|
|
//! provider-specific reference that gets persisted into `app_payments.external_ref`:
|
|
//! a VA number for `va`, or the QR string payload for `qris`.
|
|
|
|
use imphnen_utils::AppError;
|
|
use serde_json::json;
|
|
|
|
/// Query transaction status for an order via Midtrans Core API.
|
|
///
|
|
/// Returns the raw `transaction_status` string (e.g. "capture", "settlement",
|
|
/// "pending", "expire", ...).
|
|
pub async fn get_status(
|
|
order_id: &str,
|
|
server_key: &str,
|
|
) -> Result<String, AppError> {
|
|
let client = reqwest::Client::new();
|
|
let url = format!("{}/{}/status", status_base(), order_id);
|
|
let resp = client
|
|
.get(&url)
|
|
.basic_auth(server_key, Some(""))
|
|
// Midtrans/istio compresses with gzip even when the client cannot
|
|
// decompress; reqwest auto-decompress can return an empty body here,
|
|
// so ask for identity explicitly.
|
|
.header(reqwest::header::ACCEPT_ENCODING, "identity")
|
|
.send()
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(format!("Midtrans status request failed: {e}")))?;
|
|
|
|
let status = resp.status();
|
|
let hdrs = format!("{:?}", resp.headers());
|
|
let text = resp
|
|
.text()
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(format!("Midtrans status read failed: {e} (http {}, hdrs {})", status, hdrs)))?;
|
|
let payload: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
|
|
AppError::InternalServerError(format!(
|
|
"Midtrans status parse failed: {e} (http {}, hdrs {}, body-len {})",
|
|
status,
|
|
hdrs,
|
|
text.len()
|
|
))
|
|
})?;
|
|
|
|
if !status.is_success() {
|
|
return Err(AppError::InternalServerError(format!(
|
|
"Midtrans status error ({}): {}",
|
|
status,
|
|
payload["status_message"].as_str().unwrap_or("unknown")
|
|
)));
|
|
}
|
|
|
|
Ok(
|
|
payload["transaction_status"]
|
|
.as_str()
|
|
.unwrap_or("unknown")
|
|
.to_string(),
|
|
)
|
|
}
|
|
|
|
fn status_base() -> &'static str {
|
|
if std::env::var("RUST_ENV").as_deref() == Ok("production") {
|
|
"https://api.midtrans.com/v2"
|
|
} else {
|
|
"https://api.sandbox.midtrans.com/v2"
|
|
}
|
|
}
|
|
|
|
/// Sandbox vs production endpoint for charge. Sandbox is the default and safe for demo.
|
|
fn charge_url() -> &'static str {
|
|
if std::env::var("RUST_ENV").as_deref() == Ok("production") {
|
|
"https://api.midtrans.com/v2/charge"
|
|
} else {
|
|
"https://api.sandbox.midtrans.com/v2/charge"
|
|
}
|
|
}
|
|
|
|
/// Create a bank-transfer (Virtual Account) charge via Midtrans Core API.
|
|
///
|
|
/// Returns the VA number to display to the mentee.
|
|
pub async fn create_va_charge(
|
|
order_id: &str,
|
|
gross_amount: i64,
|
|
bank: &str,
|
|
server_key: &str,
|
|
) -> Result<String, AppError> {
|
|
let client = reqwest::Client::new();
|
|
let body = json!({
|
|
"payment_type": "bank_transfer",
|
|
"transaction_details": {
|
|
"order_id": order_id,
|
|
"gross_amount": gross_amount,
|
|
},
|
|
"bank_transfer": {
|
|
"bank": bank,
|
|
}
|
|
});
|
|
|
|
let resp = client
|
|
.post(charge_url())
|
|
.basic_auth(server_key, Some(""))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(format!("Midtrans request failed: {e}")))?;
|
|
|
|
let status = resp.status();
|
|
let payload: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(format!("Midtrans response parse failed: {e}")))?;
|
|
|
|
if !status.is_success() {
|
|
return Err(AppError::InternalServerError(format!(
|
|
"Midtrans charge error ({}): {}",
|
|
status,
|
|
payload["status_message"]
|
|
.as_str()
|
|
.unwrap_or("unknown error")
|
|
)));
|
|
}
|
|
|
|
payload["va_numbers"][0]["va_number"]
|
|
.as_str()
|
|
.map(|s| s.to_string())
|
|
.ok_or_else(|| AppError::InternalServerError("Midtrans response missing va_number".into()))
|
|
}
|
|
|
|
/// Create a QRIS charge via Midtrans Core API.
|
|
///
|
|
/// Returns the QR string payload (renderable as a QR code).
|
|
pub async fn create_qris_charge(
|
|
order_id: &str,
|
|
gross_amount: i64,
|
|
server_key: &str,
|
|
) -> Result<String, AppError> {
|
|
let client = reqwest::Client::new();
|
|
let body = json!({
|
|
"payment_type": "qris",
|
|
"transaction_details": {
|
|
"order_id": order_id,
|
|
"gross_amount": gross_amount,
|
|
},
|
|
"qris": {
|
|
"acquirer": "gopay",
|
|
}
|
|
});
|
|
|
|
let resp = client
|
|
.post(charge_url())
|
|
.basic_auth(server_key, Some(""))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(format!("Midtrans request failed: {e}")))?;
|
|
|
|
let status = resp.status();
|
|
let payload: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(format!("Midtrans response parse failed: {e}")))?;
|
|
|
|
if !status.is_success() {
|
|
return Err(AppError::InternalServerError(format!(
|
|
"Midtrans charge error ({}): {}",
|
|
status,
|
|
payload["status_message"]
|
|
.as_str()
|
|
.unwrap_or("unknown error")
|
|
)));
|
|
}
|
|
|
|
payload["qr_string"]
|
|
.as_str()
|
|
.map(|s| s.to_string())
|
|
.ok_or_else(|| AppError::InternalServerError("Midtrans response missing qr_string".into()))
|
|
}
|