Files
imphnen-backend-service/imphnen-dimentorin/src/payments/application/midtrans_provider.rs
T
asepharyana 390f46b0e7 feat(dimentorin): payment status refresh via Midtrans + auto-paid auto-confirm
- 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
2026-08-05 15:15:43 +07:00

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()))
}