feat(dimentorin): Midtrans payment provider (sandbox VA + QRIS)

- Env: MIDTRANS_MERCHANT_ID / CLIENT_KEY / SERVER_KEY (masked in Debug)
- midtrans_provider: Core API v2/charge for bank_transfer (VA BCA) and qris (gopay)
- PaymentServiceImpl: when MIDTRANS_MERCHANT_ID set -> provider=midtrans, external_ref = real VA number / QR string; falls back to manual refs otherwise
- SMTP: Google app password working (send-otp 200, OTP stored)
- E2E verified (sandbox): VA externalRef=47329093597744219189188, QRIS qr_string EMVCo, confirm->paid
- Credentials stored in BWS (dimentorin_midtrans_*, dimentorin_smtp_*)
This commit is contained in:
asepharyana
2026-08-05 13:34:59 +07:00
parent 2ea6cd3d17
commit 9214b32139
6 changed files with 163 additions and 2 deletions
Generated
+1
View File
@@ -1810,6 +1810,7 @@ dependencies = [
"paginator-utils",
"rand 0.9.2",
"regex",
"reqwest",
"sea-orm",
"serde",
"serde_json",
+1
View File
@@ -19,6 +19,7 @@ regex.workspace = true
zod-rs.workspace = true
zod-rs-util.workspace = true
axum-test.workspace = true
reqwest.workspace = true
rand.workspace = true
tokio.workspace = true
chrono.workspace = true
@@ -0,0 +1,119 @@
//! 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;
/// Sandbox vs production endpoint. 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()))
}
@@ -1,3 +1,4 @@
pub mod midtrans_provider;
pub mod payment_service;
pub use payment_service::PaymentServiceImpl;
@@ -75,7 +75,37 @@ impl PaymentService for PaymentServiceImpl {
let rate = mentor.mentoring_rate.unwrap_or(50_000.0).round() as i64;
let total = rate + SERVICE_FEE;
let method = cmd.method.clone();
let provider = "manual".to_string(); // swap to midtrans/xendit later
// Use Midtrans when credentials are configured; fall back to manual refs.
let midtrans = imphnen_libs::environment::ENV.midtrans_merchant_id.clone();
let (provider, external_ref) = if !midtrans.is_empty() {
let server_key = imphnen_libs::environment::ENV.midtrans_server_key.clone();
let order_id = format!("DM-{}", Uuid::new_v4());
match method.as_str() {
"va" => {
let va =
crate::payments::application::midtrans_provider::create_va_charge(
&order_id,
total,
"bca",
&server_key,
)
.await?;
("midtrans".to_string(), Some(va))
}
"qris" => {
let qr = crate::payments::application::midtrans_provider::create_qris_charge(
&order_id,
total,
&server_key,
)
.await?;
("midtrans".to_string(), Some(qr))
}
_ => ("manual".to_string(), Some(generate_external_ref(&method, session_id))),
}
} else {
("manual".to_string(), Some(generate_external_ref(&method, session_id)))
};
let expires_at = Utc::now() + Duration::hours(24);
let payment = PaymentEntity {
@@ -89,7 +119,7 @@ impl PaymentService for PaymentServiceImpl {
method: method.clone(),
provider,
status: "pending".into(),
external_ref: Some(generate_external_ref(&method, session_id)),
external_ref,
expires_at,
created_at: Utc::now(),
paid_at: None,
+9
View File
@@ -34,6 +34,9 @@ pub struct Env {
pub google_client_secret: String,
pub google_redirect_url: String,
pub cdn_url: String,
pub midtrans_merchant_id: String,
pub midtrans_client_key: String,
pub midtrans_server_key: String,
pub cors_allowed_origins: Vec<String>,
}
@@ -73,6 +76,9 @@ impl std::fmt::Debug for Env {
.field("google_client_secret", &"***")
.field("google_redirect_url", &self.google_redirect_url)
.field("cdn_url", &self.cdn_url)
.field("midtrans_merchant_id", &self.midtrans_merchant_id)
.field("midtrans_client_key", &"***")
.field("midtrans_server_key", &"***")
.field("cors_allowed_origins", &self.cors_allowed_origins)
.finish()
}
@@ -198,6 +204,9 @@ pub static ENV: Lazy<Env> = Lazy::new(|| {
"http://localhost:8000/api/v1/auth/google/callback",
),
cdn_url: get_env_with_warning("CDN_URL", "https://cdn.asepharyana.tech"),
midtrans_merchant_id: get_env_with_warning("MIDTRANS_MERCHANT_ID", ""),
midtrans_client_key: get_env_with_warning("MIDTRANS_CLIENT_KEY", ""),
midtrans_server_key: get_env_with_warning("MIDTRANS_SERVER_KEY", ""),
cors_allowed_origins: get_env_with_warning(
"CORS_ALLOWED_ORIGINS",
"https://gacha.imphnen.dev,https://imphnen.dev,https://dimentorin.imphnen.dev,https://backoffice.imphnen.dev,https://hackathon.imphnen.dev,https://qr.imphnen.dev,https://infra.imphnen.dev",