diff --git a/imphnen-dimentorin/src/payments/application/midtrans_provider.rs b/imphnen-dimentorin/src/payments/application/midtrans_provider.rs index 3adae12..48ea720 100644 --- a/imphnen-dimentorin/src/payments/application/midtrans_provider.rs +++ b/imphnen-dimentorin/src/payments/application/midtrans_provider.rs @@ -8,7 +8,67 @@ use imphnen_utils::AppError; use serde_json::json; -/// Sandbox vs production endpoint. Sandbox is the default and safe for demo. +/// 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 { + 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" diff --git a/imphnen-dimentorin/src/payments/application/payment_service.rs b/imphnen-dimentorin/src/payments/application/payment_service.rs index e4e4b7d..2065864 100644 --- a/imphnen-dimentorin/src/payments/application/payment_service.rs +++ b/imphnen-dimentorin/src/payments/application/payment_service.rs @@ -77,9 +77,9 @@ impl PaymentService for PaymentServiceImpl { let method = cmd.method.clone(); // Use Midtrans when credentials are configured; fall back to manual refs. let midtrans = imphnen_libs::environment::ENV.midtrans_merchant_id.clone(); + let order_id = format!("DM-{}", Uuid::new_v4()); 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 = @@ -107,6 +107,11 @@ impl PaymentService for PaymentServiceImpl { ("manual".to_string(), Some(generate_external_ref(&method, session_id))) }; let expires_at = Utc::now() + Duration::hours(24); + let provider_order_id = if provider == "midtrans" { + Some(order_id) + } else { + None + }; let payment = PaymentEntity { id: Uuid::new_v4(), @@ -120,6 +125,7 @@ impl PaymentService for PaymentServiceImpl { provider, status: "pending".into(), external_ref, + provider_order_id, expires_at, created_at: Utc::now(), paid_at: None, @@ -190,4 +196,42 @@ impl PaymentService for PaymentServiceImpl { ) -> Result, AppError> { self.payment_repo.find_by_mentee(mentee_id).await } + + async fn refresh_status( + &self, + id: Uuid, + user_id: Uuid, + ) -> Result { + let payment = self.payment_repo.find_by_id(id).await?; + if payment.mentee_id != user_id { + return Err(AppError::ForbiddenError( + "You can only refresh your own payments".into(), + )); + } + if payment.status == "paid" { + return Ok(payment); + } + let Some(order_id) = payment.provider_order_id.clone() else { + // Manual payments have no provider to poll — nothing to do. + return Ok(payment); + }; + let server_key = imphnen_libs::environment::ENV.midtrans_server_key.clone(); + let midtrans_status = + crate::payments::application::midtrans_provider::get_status(&order_id, &server_key) + .await?; + + if midtrans_status == "capture" || midtrans_status == "settlement" { + let paid = self + .payment_repo + .update_status(id, "paid", Some(payment.external_ref.clone().unwrap_or_default())) + .await?; + if let Some(mut session) = self.session_repo.find_by_id(payment.session_id).await? { + session.status = "confirmed".to_string(); + session.updated_at = Utc::now(); + self.session_repo.update(payment.session_id, session).await?; + } + return Ok(paid); + } + Ok(payment) + } } diff --git a/imphnen-dimentorin/src/payments/domain/mod.rs b/imphnen-dimentorin/src/payments/domain/mod.rs index 31cde0f..cc57d04 100644 --- a/imphnen-dimentorin/src/payments/domain/mod.rs +++ b/imphnen-dimentorin/src/payments/domain/mod.rs @@ -25,6 +25,7 @@ pub struct PaymentEntity { pub provider: String, pub status: String, pub external_ref: Option, + pub provider_order_id: Option, pub expires_at: DateTime, pub created_at: DateTime, pub paid_at: Option>, diff --git a/imphnen-dimentorin/src/payments/domain/service.rs b/imphnen-dimentorin/src/payments/domain/service.rs index 337e9a4..83d2762 100644 --- a/imphnen-dimentorin/src/payments/domain/service.rs +++ b/imphnen-dimentorin/src/payments/domain/service.rs @@ -33,4 +33,13 @@ pub trait PaymentService: Send + Sync { &self, mentee_id: Uuid, ) -> Result, AppError>; + + /// Refresh a payment's status against the provider (Midtrans). + /// If the provider reports paid (capture/settlement), the payment is + /// marked paid and the linked session is auto-confirmed. + async fn refresh_status( + &self, + id: Uuid, + user_id: Uuid, + ) -> Result; } \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/infrastructure/http/dto.rs b/imphnen-dimentorin/src/payments/infrastructure/http/dto.rs index fc12426..eeeeb4f 100644 --- a/imphnen-dimentorin/src/payments/infrastructure/http/dto.rs +++ b/imphnen-dimentorin/src/payments/infrastructure/http/dto.rs @@ -34,6 +34,7 @@ pub struct PaymentResponseDto { pub provider: String, pub status: String, pub external_ref: Option, + pub provider_order_id: Option, pub expires_at: String, pub created_at: String, } \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/infrastructure/http/handlers.rs b/imphnen-dimentorin/src/payments/infrastructure/http/handlers.rs index 5669f07..72dc9b6 100644 --- a/imphnen-dimentorin/src/payments/infrastructure/http/handlers.rs +++ b/imphnen-dimentorin/src/payments/infrastructure/http/handlers.rs @@ -32,6 +32,7 @@ fn to_dto(p: &PaymentEntity) -> PaymentResponseDto { provider: p.provider.clone(), status: p.status.clone(), external_ref: p.external_ref.clone(), + provider_order_id: p.provider_order_id.clone(), expires_at: p.expires_at.to_rfc3339(), created_at: p.created_at.to_rfc3339(), } @@ -95,4 +96,19 @@ pub async fn post_confirm_payment( "Payment {} confirmed", payment.external_ref.clone().unwrap_or_else(|| payment.id.to_string()) ))) +} + +/// POST /v1/dimentorin/payments/{id}/refresh (mentee) +/// Polls the provider (Midtrans) and auto-marks the payment paid + session +/// confirmed when the transaction settles. +pub async fn post_refresh_payment( + headers: axum::http::HeaderMap, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + let user_id = extract_user_id(&headers)?; + let payment_uuid = uuid::Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid payment ID".into()))?; + let payment = service.refresh_status(payment_uuid, user_id).await?; + Ok(ApiSuccess(to_dto(&payment))) } \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/infrastructure/http/routes.rs b/imphnen-dimentorin/src/payments/infrastructure/http/routes.rs index b872ce4..67c7cbd 100644 --- a/imphnen-dimentorin/src/payments/infrastructure/http/routes.rs +++ b/imphnen-dimentorin/src/payments/infrastructure/http/routes.rs @@ -1,5 +1,6 @@ use super::handlers::{ get_my_payments, get_payment_by_id, post_confirm_payment, post_create_payment, + post_refresh_payment, }; use crate::payments::application::PaymentServiceImpl; use crate::payments::domain::PaymentService; @@ -32,6 +33,7 @@ pub fn payments_protected_routes( .route("/payments/me", get(get_my_payments)) .route("/payments/{id}", get(get_payment_by_id)) .route("/payments/{id}/confirm", post(post_confirm_payment)) + .route("/payments/{id}/refresh", post(post_refresh_payment)) .layer(Extension(service)) .layer(Extension((*state).clone())) } \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/infrastructure/persistence/postgres_payment_repository.rs b/imphnen-dimentorin/src/payments/infrastructure/persistence/postgres_payment_repository.rs index c4006b6..c41f74d 100644 --- a/imphnen-dimentorin/src/payments/infrastructure/persistence/postgres_payment_repository.rs +++ b/imphnen-dimentorin/src/payments/infrastructure/persistence/postgres_payment_repository.rs @@ -24,6 +24,7 @@ fn map(row: imphnen_entities::seaorm::common::payments::Model) -> PaymentEntity provider: row.provider, status: row.status, external_ref: row.external_ref, + provider_order_id: row.provider_order_id, expires_at: row.expires_at, created_at: row.created_at, paid_at: row.paid_at, @@ -56,6 +57,7 @@ impl PaymentRepository for PostgresPaymentRepository { provider: Set(payment.provider), status: Set(payment.status), external_ref: Set(payment.external_ref), + provider_order_id: Set(payment.provider_order_id), paid_at: Set(payment.paid_at), expires_at: Set(payment.expires_at), created_at: Set(now), diff --git a/imphnen-entities/src/seaorm/common/payments.rs b/imphnen-entities/src/seaorm/common/payments.rs index 170caee..44f0d23 100644 --- a/imphnen-entities/src/seaorm/common/payments.rs +++ b/imphnen-entities/src/seaorm/common/payments.rs @@ -43,6 +43,10 @@ pub struct Model { #[sea_orm(nullable)] pub external_ref: Option, + // provider order id (Midtrans order_id, e.g. "DM-") used to query status + #[sea_orm(nullable)] + pub provider_order_id: Option, + #[sea_orm(nullable)] pub paid_at: Option>,