feat(dimentorin): payment module - gateway-agnostic VA/QRIS with admin confirm
- app_payments table + PaymentEntity (amount from mentor mentoring_rate + service fee 2000)
- PaymentRepository (postgres) + PaymentServiceImpl (create/get/confirm/list)
- Routes: POST /payments/sessions/{id}/create, GET /payments/me, GET /payments/{id}, POST /payments/{id}/confirm
- confirm guarded by Admin/Admin Pembayaran role; ownership guard mentee-only view
- provider=manual default (swap midtrans/xendit later), e2e verified: VA+QRIS create, confirm 200, re-confirm 409, non-admin 403, foreign payment 403
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
use super::super::domain::{
|
||||
CreatePaymentCommand, PaymentEntity, PaymentRepository, PaymentService, SERVICE_FEE,
|
||||
};
|
||||
use crate::sessions::domain::SessionRepository;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use imphnen_entities::seaorm::auth::mentors::Entity as MentorsEntity;
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
use imphnen_utils::AppError;
|
||||
use sea_orm::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct PaymentServiceImpl {
|
||||
payment_repo: Arc<dyn PaymentRepository>,
|
||||
session_repo: Arc<dyn SessionRepository>,
|
||||
db: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl PaymentServiceImpl {
|
||||
pub fn new(
|
||||
payment_repo: Arc<dyn PaymentRepository>,
|
||||
session_repo: Arc<dyn SessionRepository>,
|
||||
db: Arc<DatabaseConnection>,
|
||||
) -> Self {
|
||||
Self {
|
||||
payment_repo,
|
||||
session_repo,
|
||||
db,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_external_ref(method: &str, session_id: Uuid) -> String {
|
||||
match method {
|
||||
"va" => format!("VA-{}-{}", session_id.to_string().split('-').next().unwrap_or("X"), Utc::now().format("%Y%m%d%H%M%S")),
|
||||
"qris" => format!("QR-{}", session_id.to_string().replace('-', "").chars().take(16).collect::<String>()),
|
||||
_ => format!("MANUAL-{}", Utc::now().format("%Y%m%d%H%M%S")),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentService for PaymentServiceImpl {
|
||||
async fn create_payment(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
mentee_id: Uuid,
|
||||
cmd: CreatePaymentCommand,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
// Only a valid session can be paid for.
|
||||
let session = self
|
||||
.session_repo
|
||||
.find_by_id(session_id)
|
||||
.await
|
||||
.map_err(|_| AppError::NotFoundError("Session not found".into()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Session not found".into()))?;
|
||||
|
||||
// The mentee paying must be the session's mentee.
|
||||
if session.mentee_id != mentee_id {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"You can only pay for your own sessions".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load mentor rate from the mentors table (mentors.user_id = the session's
|
||||
// mentor user id).
|
||||
let mentor_uuid = session.mentor_id;
|
||||
let mentor = MentorsEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::mentors::Column::UserId.eq(mentor_uuid))
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Mentor not found".into()))?;
|
||||
|
||||
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
|
||||
let expires_at = Utc::now() + Duration::hours(24);
|
||||
|
||||
let payment = PaymentEntity {
|
||||
id: Uuid::new_v4(),
|
||||
session_id,
|
||||
mentee_id,
|
||||
mentor_id: mentor_uuid,
|
||||
amount: rate,
|
||||
service_fee: SERVICE_FEE,
|
||||
total,
|
||||
method: method.clone(),
|
||||
provider,
|
||||
status: "pending".into(),
|
||||
external_ref: Some(generate_external_ref(&method, session_id)),
|
||||
expires_at,
|
||||
created_at: Utc::now(),
|
||||
paid_at: None,
|
||||
};
|
||||
self.payment_repo.create(payment).await
|
||||
}
|
||||
|
||||
async fn get_payment_by_id(
|
||||
&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 view your own payments".into(),
|
||||
));
|
||||
}
|
||||
Ok(payment)
|
||||
}
|
||||
|
||||
async fn confirm_payment(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
// Admin / "Admin Pembayaran" only — check role via users table.
|
||||
let user = UsersEntity::find_by_id(actor_id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("Actor not found".into()))?;
|
||||
let role_id = user.role_id.ok_or_else(|| {
|
||||
AppError::ForbiddenError("User has no role assigned".into())
|
||||
})?;
|
||||
let roles = imphnen_entities::seaorm::auth::roles::Entity::find_by_id(role_id)
|
||||
.one(self.db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::ForbiddenError("Role not found".into()))?;
|
||||
if roles.name != "Admin" && roles.name != "Admin Pembayaran" {
|
||||
return Err(AppError::ForbiddenError(
|
||||
"Only payment admin can confirm payments".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let payment = self.payment_repo.find_by_id(id).await?;
|
||||
if payment.status != "pending" {
|
||||
return Err(AppError::ConflictError(
|
||||
"Payment is not pending".into(),
|
||||
));
|
||||
}
|
||||
self.payment_repo
|
||||
.update_status(id, "paid", Some(payment.external_ref.clone().unwrap_or_default()))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_mentee_payments(
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError> {
|
||||
self.payment_repo.find_by_mentee(mentee_id).await
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user