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:
@@ -40,6 +40,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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?;
|
||||
|
||||
@@ -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};
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod payment_service;
|
||||
|
||||
pub use payment_service::PaymentServiceImpl;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub const SERVICE_FEE: i64 = 2_000;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PaymentRepository: Send + Sync {
|
||||
async fn create(&self, payment: PaymentEntity) -> Result<PaymentEntity, AppError>;
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<PaymentEntity, AppError>;
|
||||
async fn find_by_session(&self, session_id: Uuid) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
async fn find_by_mentee(&self, mentee_id: Uuid) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: Uuid,
|
||||
status: &str,
|
||||
external_ref: Option<String>,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
}
|
||||
@@ -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<PaymentEntity, AppError>;
|
||||
|
||||
async fn get_payment_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
/// Confirm a pending payment (admin / "Admin Pembayaran"). Marks paid.
|
||||
async fn confirm_payment(
|
||||
&self,
|
||||
id: Uuid,
|
||||
actor_id: Uuid,
|
||||
) -> Result<PaymentEntity, AppError>;
|
||||
|
||||
/// List payments for the current mentee.
|
||||
async fn get_mentee_payments(
|
||||
&self,
|
||||
mentee_id: Uuid,
|
||||
) -> Result<Vec<PaymentEntity>, AppError>;
|
||||
}
|
||||
@@ -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, String> {
|
||||
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<String>,
|
||||
pub expires_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -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<uuid::Uuid, AppError> {
|
||||
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<Arc<dyn PaymentService>>,
|
||||
Path(session_id): Path<String>,
|
||||
ValidatedJson(dto): ValidatedJson<CreatePaymentRequestDto>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
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<Arc<dyn PaymentService>>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
let user_id = extract_user_id(&headers)?;
|
||||
let payments = service.get_mentee_payments(user_id).await?;
|
||||
let items: Vec<PaymentResponseDto> = 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<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.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<Arc<dyn PaymentService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl axum::response::IntoResponse, AppError> {
|
||||
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())
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
|
||||
pub use routes::payments_protected_routes;
|
||||
@@ -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<dyn PaymentService> {
|
||||
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<AppState>,
|
||||
) -> 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()))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
|
||||
pub use persistence::PostgresPaymentRepository;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod postgres_payment_repository;
|
||||
|
||||
pub use postgres_payment_repository::PostgresPaymentRepository;
|
||||
+131
@@ -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<DatabaseConnection>,
|
||||
}
|
||||
|
||||
impl PostgresPaymentRepository {
|
||||
pub fn new(db: Arc<DatabaseConnection>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentRepository for PostgresPaymentRepository {
|
||||
async fn create(&self, payment: PaymentEntity) -> Result<PaymentEntity, AppError> {
|
||||
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<PaymentEntity, AppError> {
|
||||
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<Vec<PaymentEntity>, 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<Vec<PaymentEntity>, 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<String>,
|
||||
) -> Result<PaymentEntity, AppError> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
#[sea_orm(nullable)]
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
|
||||
#[sea_orm(not_null)]
|
||||
pub expires_at: DateTime<Utc>,
|
||||
|
||||
#[sea_orm(not_null, default = "now()")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
|
||||
#[sea_orm(not_null, default = "now()")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user