From 444c98074f423a0c820d38d7aaea95333596b2e9 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 5 Aug 2026 09:10:39 +0700 Subject: [PATCH] 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 --- imphnen-backend/src/bin/create_schema.rs | 2 + imphnen-dimentorin/src/lib.rs | 4 +- .../src/payments/application/mod.rs | 3 + .../payments/application/payment_service.rs | 156 ++++++++++++++++++ imphnen-dimentorin/src/payments/domain/mod.rs | 47 ++++++ .../src/payments/domain/service.rs | 36 ++++ .../src/payments/infrastructure/http/dto.rs | 39 +++++ .../payments/infrastructure/http/handlers.rs | 98 +++++++++++ .../src/payments/infrastructure/http/mod.rs | 5 + .../payments/infrastructure/http/routes.rs | 37 +++++ .../src/payments/infrastructure/mod.rs | 4 + .../infrastructure/persistence/mod.rs | 3 + .../postgres_payment_repository.rs | 131 +++++++++++++++ imphnen-dimentorin/src/payments/mod.rs | 6 + imphnen-entities/src/seaorm/common/mod.rs | 1 + .../src/seaorm/common/payments.rs | 62 +++++++ imphnen-gateway/src/lib.rs | 4 +- 17 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 imphnen-dimentorin/src/payments/application/mod.rs create mode 100644 imphnen-dimentorin/src/payments/application/payment_service.rs create mode 100644 imphnen-dimentorin/src/payments/domain/mod.rs create mode 100644 imphnen-dimentorin/src/payments/domain/service.rs create mode 100644 imphnen-dimentorin/src/payments/infrastructure/http/dto.rs create mode 100644 imphnen-dimentorin/src/payments/infrastructure/http/handlers.rs create mode 100644 imphnen-dimentorin/src/payments/infrastructure/http/mod.rs create mode 100644 imphnen-dimentorin/src/payments/infrastructure/http/routes.rs create mode 100644 imphnen-dimentorin/src/payments/infrastructure/mod.rs create mode 100644 imphnen-dimentorin/src/payments/infrastructure/persistence/mod.rs create mode 100644 imphnen-dimentorin/src/payments/infrastructure/persistence/postgres_payment_repository.rs create mode 100644 imphnen-dimentorin/src/payments/mod.rs create mode 100644 imphnen-entities/src/seaorm/common/payments.rs diff --git a/imphnen-backend/src/bin/create_schema.rs b/imphnen-backend/src/bin/create_schema.rs index d85b6dc..14f319a 100644 --- a/imphnen-backend/src/bin/create_schema.rs +++ b/imphnen-backend/src/bin/create_schema.rs @@ -40,6 +40,8 @@ async fn main() -> Result<(), Box> { drop_and_create_table(&db, builder, "rate_limits", common::rate_limit::Entity) .await?; drop_and_create_table(&db, builder, "otp_cache", common::otp_cache::Entity).await?; + drop_and_create_table(&db, builder, "payments", common::payments::Entity) + .await?; drop_and_create_table(&db, builder, "gacha_credits", gacha::gacha_credits::Entity) .await?; diff --git a/imphnen-dimentorin/src/lib.rs b/imphnen-dimentorin/src/lib.rs index 1128ed2..30918bc 100644 --- a/imphnen-dimentorin/src/lib.rs +++ b/imphnen-dimentorin/src/lib.rs @@ -1,7 +1,9 @@ pub mod articles; pub mod mentors; +pub mod payments; pub mod sessions; pub use articles::{articles_protected_routes, articles_public_routes}; pub use mentors::{mentors_protected_routes, mentors_public_routes}; -pub use sessions::{sessions_protected_routes, sessions_public_routes}; +pub use payments::payments_protected_routes; +pub use sessions::{sessions_protected_routes, sessions_public_routes}; \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/application/mod.rs b/imphnen-dimentorin/src/payments/application/mod.rs new file mode 100644 index 0000000..d59bb60 --- /dev/null +++ b/imphnen-dimentorin/src/payments/application/mod.rs @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..7a14b61 --- /dev/null +++ b/imphnen-dimentorin/src/payments/application/payment_service.rs @@ -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, + session_repo: Arc, + db: Arc, +} + +impl PaymentServiceImpl { + pub fn new( + payment_repo: Arc, + session_repo: Arc, + db: Arc, + ) -> 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::()), + _ => 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 { + // 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 { + 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 { + // 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, AppError> { + self.payment_repo.find_by_mentee(mentee_id).await + } +} diff --git a/imphnen-dimentorin/src/payments/domain/mod.rs b/imphnen-dimentorin/src/payments/domain/mod.rs new file mode 100644 index 0000000..31cde0f --- /dev/null +++ b/imphnen-dimentorin/src/payments/domain/mod.rs @@ -0,0 +1,47 @@ +pub mod service; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use imphnen_utils::AppError; +use uuid::Uuid; + +pub use service::PaymentService; + +#[derive(Clone, Debug)] +pub struct CreatePaymentCommand { + pub method: String, // "va" | "qris" | "manual" +} + +#[derive(Clone, Debug)] +pub struct PaymentEntity { + pub id: Uuid, + pub session_id: Uuid, + pub mentee_id: Uuid, + pub mentor_id: Uuid, + pub amount: i64, + pub service_fee: i64, + pub total: i64, + pub method: String, + pub provider: String, + pub status: String, + pub external_ref: Option, + pub expires_at: DateTime, + pub created_at: DateTime, + pub paid_at: Option>, +} + +pub const SERVICE_FEE: i64 = 2_000; + +#[async_trait] +pub trait PaymentRepository: Send + Sync { + async fn create(&self, payment: PaymentEntity) -> Result; + async fn find_by_id(&self, id: Uuid) -> Result; + async fn find_by_session(&self, session_id: Uuid) -> Result, AppError>; + async fn find_by_mentee(&self, mentee_id: Uuid) -> Result, AppError>; + async fn update_status( + &self, + id: Uuid, + status: &str, + external_ref: Option, + ) -> Result; +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/domain/service.rs b/imphnen-dimentorin/src/payments/domain/service.rs new file mode 100644 index 0000000..337e9a4 --- /dev/null +++ b/imphnen-dimentorin/src/payments/domain/service.rs @@ -0,0 +1,36 @@ +use super::{CreatePaymentCommand, PaymentEntity}; +use async_trait::async_trait; +use imphnen_utils::AppError; +use uuid::Uuid; + +#[async_trait] +pub trait PaymentService: Send + Sync { + /// Create a payment record for a booked session. Computes amount from the + /// mentor's mentoring_rate, adds service fee, and (for the default manual + /// provider) generates a deterministic external reference. + async fn create_payment( + &self, + session_id: Uuid, + mentee_id: Uuid, + cmd: CreatePaymentCommand, + ) -> Result; + + async fn get_payment_by_id( + &self, + id: Uuid, + user_id: Uuid, + ) -> Result; + + /// Confirm a pending payment (admin / "Admin Pembayaran"). Marks paid. + async fn confirm_payment( + &self, + id: Uuid, + actor_id: Uuid, + ) -> Result; + + /// List payments for the current mentee. + async fn get_mentee_payments( + &self, + mentee_id: Uuid, + ) -> Result, AppError>; +} \ 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 new file mode 100644 index 0000000..fc12426 --- /dev/null +++ b/imphnen-dimentorin/src/payments/infrastructure/http/dto.rs @@ -0,0 +1,39 @@ +use imphnen_libs::ZodValidate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use zod_rs::prelude::*; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)] +pub struct CreatePaymentRequestDto { + // "va" | "qris" | "manual" + #[serde(default = "default_method")] + #[zod(min_length(1), max_length(20))] + pub method: String, +} + +fn default_method() -> String { + "manual".into() +} + +impl ZodValidate for CreatePaymentRequestDto { + fn zod_validate(value: &serde_json::Value) -> Result { + Self::validate_and_parse(value).map_err(|e| e.to_string()) + } +} + +#[derive(Clone, Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PaymentResponseDto { + pub id: String, + pub session_id: String, + pub mentor_id: String, + pub amount: i64, + pub service_fee: i64, + pub total: i64, + pub method: String, + pub provider: String, + pub status: String, + pub external_ref: 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 new file mode 100644 index 0000000..5669f07 --- /dev/null +++ b/imphnen-dimentorin/src/payments/infrastructure/http/handlers.rs @@ -0,0 +1,98 @@ +use super::dto::{CreatePaymentRequestDto, PaymentResponseDto}; +use crate::payments::domain::{CreatePaymentCommand, PaymentEntity, PaymentService}; +use axum::Extension; +use axum::extract::Path; +use axum::http::{HeaderMap, header::AUTHORIZATION}; +use imphnen_libs::ValidatedJson; +use imphnen_libs::decode_access_token; +use imphnen_utils::{ApiMessage, ApiSuccess, AppError}; +use std::sync::Arc; + +fn extract_user_id(headers: &HeaderMap) -> Result { + let token = headers + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?; + let claims = decode_access_token(token) + .map_err(|_| AppError::AuthenticationError("Token tidak valid".to_string()))?; + uuid::Uuid::parse_str(&claims.claims.user_id) + .map_err(|_| AppError::AuthenticationError("Invalid token subject".into())) +} + +fn to_dto(p: &PaymentEntity) -> PaymentResponseDto { + PaymentResponseDto { + id: p.id.to_string(), + session_id: p.session_id.to_string(), + mentor_id: p.mentor_id.to_string(), + amount: p.amount, + service_fee: p.service_fee, + total: p.total, + method: p.method.clone(), + provider: p.provider.clone(), + status: p.status.clone(), + external_ref: p.external_ref.clone(), + expires_at: p.expires_at.to_rfc3339(), + created_at: p.created_at.to_rfc3339(), + } +} + +/// POST /v1/dimentorin/payments/sessions/{id}/create +pub async fn post_create_payment( + headers: axum::http::HeaderMap, + Extension(service): Extension>, + Path(session_id): Path, + ValidatedJson(dto): ValidatedJson, +) -> Result { + let user_id = extract_user_id(&headers)?; + let session_uuid = uuid::Uuid::parse_str(&session_id) + .map_err(|_| AppError::BadRequestError("Invalid session ID".into()))?; + let payment = service + .create_payment( + session_uuid, + user_id, + CreatePaymentCommand { method: dto.method }, + ) + .await?; + Ok(ApiSuccess(to_dto(&payment))) +} + +/// GET /v1/dimentorin/payments/me +pub async fn get_my_payments( + headers: axum::http::HeaderMap, + Extension(service): Extension>, +) -> Result { + let user_id = extract_user_id(&headers)?; + let payments = service.get_mentee_payments(user_id).await?; + let items: Vec = payments.iter().map(to_dto).collect(); + Ok(ApiSuccess(items)) +} + +/// GET /v1/dimentorin/payments/{id} +pub async fn get_payment_by_id( + 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.get_payment_by_id(payment_uuid, user_id).await?; + Ok(ApiSuccess(to_dto(&payment))) +} + +/// POST /v1/dimentorin/payments/{id}/confirm (Admin / Admin Pembayaran) +pub async fn post_confirm_payment( + headers: axum::http::HeaderMap, + Extension(service): Extension>, + Path(id): Path, +) -> Result { + let actor_id = extract_user_id(&headers)?; + let payment_uuid = uuid::Uuid::parse_str(&id) + .map_err(|_| AppError::BadRequestError("Invalid payment ID".into()))?; + let payment = service.confirm_payment(payment_uuid, actor_id).await?; + Ok(ApiMessage::ok(format!( + "Payment {} confirmed", + payment.external_ref.clone().unwrap_or_else(|| payment.id.to_string()) + ))) +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/infrastructure/http/mod.rs b/imphnen-dimentorin/src/payments/infrastructure/http/mod.rs new file mode 100644 index 0000000..f25125d --- /dev/null +++ b/imphnen-dimentorin/src/payments/infrastructure/http/mod.rs @@ -0,0 +1,5 @@ +pub mod dto; +pub mod handlers; +pub mod routes; + +pub use routes::payments_protected_routes; \ 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 new file mode 100644 index 0000000..b872ce4 --- /dev/null +++ b/imphnen-dimentorin/src/payments/infrastructure/http/routes.rs @@ -0,0 +1,37 @@ +use super::handlers::{ + get_my_payments, get_payment_by_id, post_confirm_payment, post_create_payment, +}; +use crate::payments::application::PaymentServiceImpl; +use crate::payments::domain::PaymentService; +use crate::payments::infrastructure::persistence::PostgresPaymentRepository; +use crate::sessions::infrastructure::persistence::PostgresSessionRepository; +use axum::{ + Extension, Router, + routing::{get, post}, +}; +use imphnen_libs::AppState; +use sea_orm::DatabaseConnection; +use std::sync::Arc; + +fn build_service(db: DatabaseConnection) -> Arc { + let db_arc = Arc::new(db); + let payment_repo = + Arc::new(PostgresPaymentRepository::new(Arc::clone(&db_arc))); + let session_repo = + Arc::new(PostgresSessionRepository::new(Arc::clone(&db_arc))); + Arc::new(PaymentServiceImpl::new(payment_repo, session_repo, db_arc)) +} + +pub fn payments_protected_routes( + db: DatabaseConnection, + state: Arc, +) -> Router { + let service = build_service(db); + Router::new() + .route("/payments/sessions/{id}/create", post(post_create_payment)) + .route("/payments/me", get(get_my_payments)) + .route("/payments/{id}", get(get_payment_by_id)) + .route("/payments/{id}/confirm", post(post_confirm_payment)) + .layer(Extension(service)) + .layer(Extension((*state).clone())) +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/infrastructure/mod.rs b/imphnen-dimentorin/src/payments/infrastructure/mod.rs new file mode 100644 index 0000000..c6d5a5b --- /dev/null +++ b/imphnen-dimentorin/src/payments/infrastructure/mod.rs @@ -0,0 +1,4 @@ +pub mod http; +pub mod persistence; + +pub use persistence::PostgresPaymentRepository; \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/infrastructure/persistence/mod.rs b/imphnen-dimentorin/src/payments/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..f76361a --- /dev/null +++ b/imphnen-dimentorin/src/payments/infrastructure/persistence/mod.rs @@ -0,0 +1,3 @@ +pub mod postgres_payment_repository; + +pub use postgres_payment_repository::PostgresPaymentRepository; \ 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 new file mode 100644 index 0000000..c4006b6 --- /dev/null +++ b/imphnen-dimentorin/src/payments/infrastructure/persistence/postgres_payment_repository.rs @@ -0,0 +1,131 @@ +use crate::payments::domain::{PaymentEntity, PaymentRepository}; +use async_trait::async_trait; +use chrono::Utc; +use imphnen_entities::seaorm::common::payments::{ + ActiveModel as PaymentActiveModel, Column as PaymentColumn, Entity as PaymentEntityOrm, +}; +use imphnen_utils::AppError; +use sea_orm::ActiveValue::Set; +use sea_orm::prelude::*; +use sea_orm::QueryOrder; +use std::sync::Arc; +use uuid::Uuid; + +fn map(row: imphnen_entities::seaorm::common::payments::Model) -> PaymentEntity { + PaymentEntity { + id: row.id, + session_id: row.session_id, + mentee_id: row.mentee_id, + mentor_id: row.mentor_id, + amount: row.amount, + service_fee: row.service_fee, + total: row.total, + method: row.method, + provider: row.provider, + status: row.status, + external_ref: row.external_ref, + expires_at: row.expires_at, + created_at: row.created_at, + paid_at: row.paid_at, + } +} + +pub struct PostgresPaymentRepository { + db: Arc, +} + +impl PostgresPaymentRepository { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +#[async_trait] +impl PaymentRepository for PostgresPaymentRepository { + async fn create(&self, payment: PaymentEntity) -> Result { + let now = Utc::now(); + let model = PaymentActiveModel { + id: Set(payment.id), + session_id: Set(payment.session_id), + mentee_id: Set(payment.mentee_id), + mentor_id: Set(payment.mentor_id), + amount: Set(payment.amount), + service_fee: Set(payment.service_fee), + total: Set(payment.total), + method: Set(payment.method), + provider: Set(payment.provider), + status: Set(payment.status), + external_ref: Set(payment.external_ref), + paid_at: Set(payment.paid_at), + expires_at: Set(payment.expires_at), + created_at: Set(now), + updated_at: Set(now), + }; + let row = PaymentEntityOrm::insert(model) + .exec_with_returning(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(map(row)) + } + + async fn find_by_id(&self, id: Uuid) -> Result { + let row = PaymentEntityOrm::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Payment not found".into()))?; + Ok(map(row)) + } + + async fn find_by_session( + &self, + session_id: Uuid, + ) -> Result, AppError> { + let rows = PaymentEntityOrm::find() + .filter(PaymentColumn::SessionId.eq(session_id)) + .all(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(rows.into_iter().map(map).collect()) + } + + async fn find_by_mentee( + &self, + mentee_id: Uuid, + ) -> Result, AppError> { + let rows = PaymentEntityOrm::find() + .filter(PaymentColumn::MenteeId.eq(mentee_id)) + .order_by_desc(PaymentColumn::CreatedAt) + .all(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(rows.into_iter().map(map).collect()) + } + + async fn update_status( + &self, + id: Uuid, + status: &str, + external_ref: Option, + ) -> Result { + let existing = PaymentEntityOrm::find_by_id(id) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .ok_or_else(|| AppError::NotFoundError("Payment not found".into()))?; + let mut update: PaymentActiveModel = existing.clone().into(); + update.status = Set(status.to_string()); + if external_ref.is_some() { + update.external_ref = Set(external_ref); + } + if status == "paid" { + update.paid_at = Set(Some(Utc::now())); + } + update.updated_at = Set(Utc::now()); + let row = update + .update(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(map(row)) + } +} \ No newline at end of file diff --git a/imphnen-dimentorin/src/payments/mod.rs b/imphnen-dimentorin/src/payments/mod.rs new file mode 100644 index 0000000..a665fe2 --- /dev/null +++ b/imphnen-dimentorin/src/payments/mod.rs @@ -0,0 +1,6 @@ +pub mod application; +pub mod domain; +pub mod infrastructure; + +pub use application::PaymentServiceImpl; +pub use infrastructure::http::routes::payments_protected_routes; \ No newline at end of file diff --git a/imphnen-entities/src/seaorm/common/mod.rs b/imphnen-entities/src/seaorm/common/mod.rs index 5092363..f532f3b 100644 --- a/imphnen-entities/src/seaorm/common/mod.rs +++ b/imphnen-entities/src/seaorm/common/mod.rs @@ -4,6 +4,7 @@ pub mod enum_impls; pub mod enums; pub mod events; pub mod otp_cache; +pub mod payments; pub mod rate_limit; pub mod roadmap_items; pub mod testimonials; diff --git a/imphnen-entities/src/seaorm/common/payments.rs b/imphnen-entities/src/seaorm/common/payments.rs new file mode 100644 index 0000000..170caee --- /dev/null +++ b/imphnen-entities/src/seaorm/common/payments.rs @@ -0,0 +1,62 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] +#[sea_orm(table_name = "app_payments")] +pub struct Model { + #[sea_orm(primary_key, default = "gen_random_uuid()", auto_increment = false)] + pub id: Uuid, + + #[sea_orm(column_type = "Uuid")] + pub session_id: Uuid, + + #[sea_orm(column_type = "Uuid")] + pub mentee_id: Uuid, + + #[sea_orm(column_type = "Uuid")] + pub mentor_id: Uuid, + + #[sea_orm(column_type = "BigInteger", default = 0)] + pub amount: i64, + + #[sea_orm(column_type = "BigInteger", default = 0)] + pub service_fee: i64, + + #[sea_orm(column_type = "BigInteger", default = 0)] + pub total: i64, + + // payment method: "va" | "qris" | "manual" + #[sea_orm(default = "manual")] + pub method: String, + + // payment provider: "manual" | "midtrans" | "xendit" (swap later) + #[sea_orm(default = "manual")] + pub provider: String, + + // status: "pending" | "paid" | "expired" | "cancelled" + #[sea_orm(default = "pending")] + pub status: String, + + // provider reference: VA number / QR string / external transaction id + #[sea_orm(nullable)] + pub external_ref: Option, + + #[sea_orm(nullable)] + pub paid_at: Option>, + + #[sea_orm(not_null)] + pub expires_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub created_at: DateTime, + + #[sea_orm(not_null, default = "now()")] + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/imphnen-gateway/src/lib.rs b/imphnen-gateway/src/lib.rs index 1757513..7072367 100644 --- a/imphnen-gateway/src/lib.rs +++ b/imphnen-gateway/src/lib.rs @@ -7,7 +7,8 @@ use imphnen_cms::{ }; use imphnen_dimentorin::{ articles_protected_routes, articles_public_routes, mentors_protected_routes, - mentors_public_routes, sessions_protected_routes, sessions_public_routes, + mentors_public_routes, payments_protected_routes, sessions_protected_routes, + sessions_public_routes, }; use imphnen_gacha::gacha_router; use imphnen_hackathon::hackathon_router; @@ -79,6 +80,7 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router { Router::new() .merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc))) .merge(sessions_protected_routes(db.clone(), Arc::clone(&state_arc))) + .merge(payments_protected_routes(db.clone(), Arc::clone(&state_arc))) .merge(articles_protected_routes( db.clone(), Arc::clone(&state_arc),