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:
Generated
+1
@@ -1810,6 +1810,7 @@ dependencies = [
|
||||
"paginator-utils",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user