feat(dimentorin): payment status refresh via Midtrans + auto-paid auto-confirm
- store provider_order_id (Midtrans order id) on payment create
- POST /payments/{id}/refresh: query Midtrans v2/{order}/status (settlement/capture → paid + confirm session)
- fix: status URL must end with /status; force Accept-Encoding: identity (istio gzip mangling)
- e2e verified: simulated VA payment in sandbox → refresh → paid + session confirmed
This commit is contained in:
@@ -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<String, AppError> {
|
||||
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"
|
||||
|
||||
@@ -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<Vec<PaymentEntity>, AppError> {
|
||||
self.payment_repo.find_by_mentee(mentee_id).await
|
||||
}
|
||||
|
||||
async fn refresh_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub struct PaymentEntity {
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
pub external_ref: Option<String>,
|
||||
pub provider_order_id: Option<String>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -33,4 +33,13 @@ pub trait PaymentService: Send + Sync {
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, 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<PaymentEntity, AppError>;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ pub struct PaymentResponseDto {
|
||||
pub provider: String,
|
||||
pub status: String,
|
||||
pub external_ref: Option<String>,
|
||||
pub provider_order_id: Option<String>,
|
||||
pub expires_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -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<Arc<dyn PaymentService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
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)))
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
+2
@@ -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),
|
||||
|
||||
@@ -43,6 +43,10 @@ pub struct Model {
|
||||
#[sea_orm(nullable)]
|
||||
pub external_ref: Option<String>,
|
||||
|
||||
// provider order id (Midtrans order_id, e.g. "DM-<uuid>") used to query status
|
||||
#[sea_orm(nullable)]
|
||||
pub provider_order_id: Option<String>,
|
||||
|
||||
#[sea_orm(nullable)]
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user