diff --git a/Cargo.lock b/Cargo.lock index ecbe1df..f024920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1810,6 +1810,7 @@ dependencies = [ "paginator-utils", "rand 0.9.2", "regex", + "reqwest", "sea-orm", "serde", "serde_json", diff --git a/imphnen-dimentorin/Cargo.toml b/imphnen-dimentorin/Cargo.toml index 0136fff..e560fea 100644 --- a/imphnen-dimentorin/Cargo.toml +++ b/imphnen-dimentorin/Cargo.toml @@ -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 diff --git a/imphnen-dimentorin/src/payments/application/midtrans_provider.rs b/imphnen-dimentorin/src/payments/application/midtrans_provider.rs new file mode 100644 index 0000000..3adae12 --- /dev/null +++ b/imphnen-dimentorin/src/payments/application/midtrans_provider.rs @@ -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 { + 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 { + 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())) +} diff --git a/imphnen-dimentorin/src/payments/application/mod.rs b/imphnen-dimentorin/src/payments/application/mod.rs index d59bb60..6927a28 100644 --- a/imphnen-dimentorin/src/payments/application/mod.rs +++ b/imphnen-dimentorin/src/payments/application/mod.rs @@ -1,3 +1,4 @@ +pub mod midtrans_provider; pub mod payment_service; pub use payment_service::PaymentServiceImpl; \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/application/payment_service.rs b/imphnen-dimentorin/src/payments/application/payment_service.rs index b8171b8..e4e4b7d 100644 --- a/imphnen-dimentorin/src/payments/application/payment_service.rs +++ b/imphnen-dimentorin/src/payments/application/payment_service.rs @@ -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, diff --git a/imphnen-libs/src/environment/mod.rs b/imphnen-libs/src/environment/mod.rs index 10a80cf..9da88d3 100644 --- a/imphnen-libs/src/environment/mod.rs +++ b/imphnen-libs/src/environment/mod.rs @@ -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, } @@ -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 = 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",