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",
|
"paginator-utils",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"regex",
|
"regex",
|
||||||
|
"reqwest",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ regex.workspace = true
|
|||||||
zod-rs.workspace = true
|
zod-rs.workspace = true
|
||||||
zod-rs-util.workspace = true
|
zod-rs-util.workspace = true
|
||||||
axum-test.workspace = true
|
axum-test.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
chrono.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 mod payment_service;
|
||||||
|
|
||||||
pub use payment_service::PaymentServiceImpl;
|
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 rate = mentor.mentoring_rate.unwrap_or(50_000.0).round() as i64;
|
||||||
let total = rate + SERVICE_FEE;
|
let total = rate + SERVICE_FEE;
|
||||||
let method = cmd.method.clone();
|
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 expires_at = Utc::now() + Duration::hours(24);
|
||||||
|
|
||||||
let payment = PaymentEntity {
|
let payment = PaymentEntity {
|
||||||
@@ -89,7 +119,7 @@ impl PaymentService for PaymentServiceImpl {
|
|||||||
method: method.clone(),
|
method: method.clone(),
|
||||||
provider,
|
provider,
|
||||||
status: "pending".into(),
|
status: "pending".into(),
|
||||||
external_ref: Some(generate_external_ref(&method, session_id)),
|
external_ref,
|
||||||
expires_at,
|
expires_at,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
paid_at: None,
|
paid_at: None,
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ pub struct Env {
|
|||||||
pub google_client_secret: String,
|
pub google_client_secret: String,
|
||||||
pub google_redirect_url: String,
|
pub google_redirect_url: String,
|
||||||
pub cdn_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>,
|
pub cors_allowed_origins: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +76,9 @@ impl std::fmt::Debug for Env {
|
|||||||
.field("google_client_secret", &"***")
|
.field("google_client_secret", &"***")
|
||||||
.field("google_redirect_url", &self.google_redirect_url)
|
.field("google_redirect_url", &self.google_redirect_url)
|
||||||
.field("cdn_url", &self.cdn_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)
|
.field("cors_allowed_origins", &self.cors_allowed_origins)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -198,6 +204,9 @@ pub static ENV: Lazy<Env> = Lazy::new(|| {
|
|||||||
"http://localhost:8000/api/v1/auth/google/callback",
|
"http://localhost:8000/api/v1/auth/google/callback",
|
||||||
),
|
),
|
||||||
cdn_url: get_env_with_warning("CDN_URL", "https://cdn.asepharyana.tech"),
|
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: get_env_with_warning(
|
||||||
"CORS_ALLOWED_ORIGINS",
|
"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",
|
"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