fix payments runtime and jwt backend

This commit is contained in:
Tracewebstudio Dev 2026-06-09 22:52:30 +02:00
parent d48983ee21
commit 52e30a1b4b
5 changed files with 355 additions and 188 deletions

196
Cargo.lock generated
View file

@ -599,6 +599,12 @@ dependencies = [
"fastrand", "fastrand",
] ]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -958,6 +964,18 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@ -986,6 +1004,33 @@ dependencies = [
"cmov", "cmov",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "customers" name = "customers"
version = "0.1.0" version = "0.1.0"
@ -1116,6 +1161,44 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
@ -1125,6 +1208,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest 0.10.7",
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "email" name = "email"
version = "0.1.0" version = "0.1.0"
@ -1228,6 +1332,22 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -1434,6 +1554,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@ -1495,6 +1616,17 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.27" version = "0.3.27"
@ -2048,11 +2180,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [ dependencies = [
"base64", "base64",
"ed25519-dalek",
"getrandom 0.2.17", "getrandom 0.2.17",
"hmac 0.12.1",
"js-sys", "js-sys",
"p256",
"p384",
"pem", "pem",
"rand 0.8.6",
"rsa",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9",
"signature", "signature",
"simple_asn1", "simple_asn1",
"zeroize", "zeroize",
@ -2451,6 +2590,30 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -2632,6 +2795,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -2909,6 +3081,16 @@ dependencies = [
"webpki-roots 1.0.7", "webpki-roots 1.0.7",
] ]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac 0.12.1",
"subtle",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@ -3080,6 +3262,20 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"

View file

@ -133,7 +133,7 @@ async fn create_order(
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO payments (user_id, package_id, razorpay_order_id, amount, tracecoins_credited, status) INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status)
VALUES ($1, $2, $3, $4, $5, 'PENDING') VALUES ($1, $2, $3, $4, $5, 'PENDING')
"#, "#,
) )
@ -248,8 +248,8 @@ async fn verify_payment(
{ {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, balance_after, reference_type, reference_id, description) INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'CREDIT', $2, $2, 'PAYMENT', $3, 'Package purchase') VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
"#, "#,
) )
.bind(wallet_id) .bind(wallet_id)
@ -262,7 +262,7 @@ async fn verify_payment(
let _ = sqlx::query( let _ = sqlx::query(
r#" r#"
INSERT INTO notifications (user_id, title, body, notification_type, reference_id) INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
"#, "#,
) )

View file

@ -14,6 +14,7 @@ use uuid::Uuid;
pub struct PackageTypeQuery { pub struct PackageTypeQuery {
pub package_type: Option<String>, pub package_type: Option<String>,
pub applicable_role: Option<String>, pub applicable_role: Option<String>,
pub role: Option<String>,
pub active_only: Option<bool>, pub active_only: Option<bool>,
} }
@ -29,48 +30,37 @@ pub struct CreatePackageRequest {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub package_type: String, pub package_type: String,
pub applicable_roles: Vec<String>, pub role_key: Option<String>,
pub applicable_roles: Option<Vec<String>>,
pub tracecoins_amount: i32, pub tracecoins_amount: i32,
pub price: i32, pub price: Option<i32>,
pub duration_days: Option<i32>, pub price_inr: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: Option<bool>,
pub is_active: Option<bool>, pub is_active: Option<bool>,
pub features: Option<serde_json::Value>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdatePackageRequest { pub struct UpdatePackageRequest {
pub name: Option<String>, pub name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub role_key: Option<String>,
pub applicable_roles: Option<Vec<String>>,
pub tracecoins_amount: Option<i32>, pub tracecoins_amount: Option<i32>,
pub price: Option<i32>, pub price: Option<i32>,
pub duration_days: Option<i32>, pub price_inr: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: Option<bool>,
pub is_active: Option<bool>, pub is_active: Option<bool>,
pub features: Option<serde_json::Value>,
} }
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
pub struct PricingPackageRow { pub struct PricingPackageRow {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub description: Option<String>, pub role_key: String,
pub package_type: String, pub package_type: String,
pub applicable_roles: Vec<String>,
pub tracecoins_amount: i32, pub tracecoins_amount: i32,
pub price: i32, pub price_inr: i32,
pub duration_days: Option<i32>, pub description: Option<String>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: bool,
pub is_active: bool, pub is_active: bool,
pub features: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -78,10 +68,12 @@ pub struct PricingPackageResponse {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub package_type: String, pub role_key: String,
pub applicable_roles: Vec<String>, pub applicable_roles: Vec<String>,
pub package_type: String,
pub tracecoins_amount: i32, pub tracecoins_amount: i32,
pub price: i32, pub price: i32,
pub price_inr: i32,
pub duration_days: Option<i32>, pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>, pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>, pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
@ -96,33 +88,58 @@ pub struct PricingPackageResponse {
impl From<PricingPackageRow> for PricingPackageResponse { impl From<PricingPackageRow> for PricingPackageResponse {
fn from(row: PricingPackageRow) -> Self { fn from(row: PricingPackageRow) -> Self {
let now = chrono::Utc::now(); Self {
let is_expired = row.valid_until.map(|v| v < now).unwrap_or(false);
let is_not_started = row.valid_from.map(|v| v > now).unwrap_or(false);
let is_available = row.is_active && !is_expired && !is_not_started;
PricingPackageResponse {
id: row.id, id: row.id,
name: row.name, name: row.name,
description: row.description, description: row.description,
role_key: row.role_key.clone(),
applicable_roles: vec![row.role_key],
package_type: row.package_type, package_type: row.package_type,
applicable_roles: row.applicable_roles,
tracecoins_amount: row.tracecoins_amount, tracecoins_amount: row.tracecoins_amount,
price: row.price, price: row.price_inr,
duration_days: row.duration_days, price_inr: row.price_inr,
valid_from: row.valid_from, duration_days: None,
valid_until: row.valid_until, valid_from: None,
is_promotional: row.is_promotional, valid_until: None,
is_promotional: false,
is_active: row.is_active, is_active: row.is_active,
features: row.features, features: None,
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.created_at,
is_available, is_available: row.is_active,
is_expired, is_expired: false,
} }
} }
} }
fn normalize_role_key(role_key: Option<String>, applicable_roles: Option<Vec<String>>) -> Result<String, String> {
if let Some(role) = role_key {
let cleaned = role.trim().to_uppercase();
if !cleaned.is_empty() {
return Ok(cleaned);
}
}
if let Some(roles) = applicable_roles {
if let Some(role) = roles.into_iter().map(|role| role.trim().to_uppercase()).find(|role| !role.is_empty()) {
return Ok(role);
}
}
Err("role_key is required".to_string())
}
fn package_query(base_where: &str, order_by: &str) -> String {
format!(
r#"
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
FROM pricing_packages
{base_where}
{order_by}
"#
)
}
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_packages)) .route("/", get(list_packages))
@ -138,83 +155,67 @@ async fn list_packages(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let page = q.page.unwrap_or(1); let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).min(100); let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit; let offset = (page - 1) * limit;
let search = q.search.unwrap_or_default().trim().to_string();
let search_filter = q.search let rows = sqlx::query_as::<_, PricingPackageRow>(
.as_ref()
.map(|s| format!("AND (name ILIKE '%{}%' OR description ILIKE '%{}%')", s.replace('\'', "''"), s.replace('\'', "''")))
.unwrap_or_default();
let packages = sqlx::query_as::<_, PricingPackageRow>(
&format!( &format!(
r#" "{} LIMIT $2 OFFSET $3",
SELECT id, name, description, package_type, applicable_roles, package_query(
tracecoins_amount, price, duration_days, valid_from, valid_until, "WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')",
is_promotional, is_active, features, created_at, updated_at "ORDER BY created_at DESC"
FROM pricing_packages )
WHERE 1=1 {} ),
ORDER BY created_at DESC
LIMIT {} OFFSET {}
"#,
search_filter, limit, offset
)
) )
.bind(&search)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await; .await;
let packages = match packages { let rows = match rows {
Ok(p) => p, Ok(rows) => rows,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}; };
let total: (i64,) = match sqlx::query_as( let total: i64 = match sqlx::query_scalar(
&format!( "SELECT COUNT(*) FROM pricing_packages WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')",
"SELECT COUNT(*) FROM pricing_packages WHERE 1=1 {}",
search_filter
)
) )
.bind(&search)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
{ {
Ok(t) => t, Ok(total) => total,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}; };
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect(); let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"data": packages, "data": packages,
"packages": packages,
"pagination": { "pagination": {
"page": page, "page": page,
"limit": limit, "limit": limit,
"total": total.0, "total": total,
"pages": (total.0 as f64 / limit as f64).ceil() as i64 "pages": (total as f64 / limit as f64).ceil() as i64
} }
}))).into_response() })))
.into_response()
} }
async fn get_package( async fn get_package(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match sqlx::query_as::<_, PricingPackageRow>( match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", ""))
r#" .bind(id)
SELECT id, name, description, package_type, applicable_roles, .fetch_optional(&state.pool)
tracecoins_amount, price, duration_days, valid_from, valid_until, .await
is_promotional, is_active, features, created_at, updated_at
FROM pricing_packages WHERE id = $1
"#
)
.bind(id)
.fetch_optional(&state.pool)
.await
{ {
Ok(Some(pkg)) => { Ok(Some(pkg)) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(),
let response: PricingPackageResponse = pkg.into();
(StatusCode::OK, Json(response)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "Package not found").into_response(), Ok(None) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
@ -224,37 +225,31 @@ async fn create_package(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<CreatePackageRequest>, Json(payload): Json<CreatePackageRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) {
Ok(role_key) => role_key,
Err(message) => return (StatusCode::BAD_REQUEST, message).into_response(),
};
let price_inr = payload.price_inr.or(payload.price).unwrap_or(0);
let result = sqlx::query_as::<_, PricingPackageRow>( let result = sqlx::query_as::<_, PricingPackageRow>(
r#" r#"
INSERT INTO pricing_packages (name, description, package_type, applicable_roles, INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active)
tracecoins_amount, price, duration_days, valid_from, valid_until, VALUES ($1, $2, $3, $4, $5, $6, $7)
is_promotional, is_active, features) RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) "#,
RETURNING id, name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features, created_at, updated_at
"#
) )
.bind(&payload.name) .bind(&payload.name)
.bind(&payload.description) .bind(&role_key)
.bind(&payload.package_type) .bind(&payload.package_type)
.bind(&payload.applicable_roles)
.bind(payload.tracecoins_amount) .bind(payload.tracecoins_amount)
.bind(payload.price) .bind(price_inr)
.bind(payload.duration_days) .bind(&payload.description)
.bind(payload.valid_from)
.bind(payload.valid_until)
.bind(payload.is_promotional.unwrap_or(false))
.bind(payload.is_active.unwrap_or(true)) .bind(payload.is_active.unwrap_or(true))
.bind(payload.features)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await; .await;
match result { match result {
Ok(pkg) => { Ok(pkg) => (StatusCode::CREATED, Json(PricingPackageResponse::from(pkg))).into_response(),
let response: PricingPackageResponse = pkg.into();
(StatusCode::CREATED, Json(response)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
@ -264,58 +259,47 @@ async fn update_package(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdatePackageRequest>, Json(payload): Json<UpdatePackageRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let existing = sqlx::query_as::<_, PricingPackageRow>( let current = match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", ""))
"SELECT * FROM pricing_packages WHERE id = $1" .bind(id)
) .fetch_optional(&state.pool)
.bind(id) .await
.fetch_optional(&state.pool) {
.await; Ok(Some(pkg)) => pkg,
let _existing = match existing {
Ok(Some(e)) => e,
Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(), Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}; };
let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) {
Ok(role_key) => role_key,
Err(_) => current.role_key.clone(),
};
let price_inr = payload.price_inr.or(payload.price).unwrap_or(current.price_inr);
let updated = sqlx::query_as::<_, PricingPackageRow>( let updated = sqlx::query_as::<_, PricingPackageRow>(
r#" r#"
UPDATE pricing_packages SET UPDATE pricing_packages SET
name = COALESCE($2, name), name = COALESCE($2, name),
description = COALESCE($3, description), role_key = $3,
tracecoins_amount = COALESCE($4, tracecoins_amount), description = COALESCE($4, description),
price = COALESCE($5, price), tracecoins_amount = COALESCE($5, tracecoins_amount),
duration_days = COALESCE($6, duration_days), price_inr = $6,
valid_from = COALESCE($7, valid_from), is_active = COALESCE($7, is_active)
valid_until = COALESCE($8, valid_until),
is_promotional = COALESCE($9, is_promotional),
is_active = COALESCE($10, is_active),
features = COALESCE($11, features),
updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, name, description, package_type, applicable_roles, RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
tracecoins_amount, price, duration_days, valid_from, valid_until, "#,
is_promotional, is_active, features, created_at, updated_at
"#
) )
.bind(id) .bind(id)
.bind(&payload.name) .bind(&payload.name)
.bind(&role_key)
.bind(&payload.description) .bind(&payload.description)
.bind(payload.tracecoins_amount) .bind(payload.tracecoins_amount)
.bind(payload.price) .bind(price_inr)
.bind(payload.duration_days)
.bind(payload.valid_from)
.bind(payload.valid_until)
.bind(payload.is_promotional)
.bind(payload.is_active) .bind(payload.is_active)
.bind(payload.features)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await; .await;
match updated { match updated {
Ok(pkg) => { Ok(pkg) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(),
let response: PricingPackageResponse = pkg.into();
(StatusCode::OK, Json(response)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
@ -329,7 +313,7 @@ async fn delete_package(
.execute(&state.pool) .execute(&state.pool)
.await .await
{ {
Ok(r) if r.rows_affected() > 0 => { Ok(result) if result.rows_affected() > 0 => {
(StatusCode::OK, Json(serde_json::json!({"message": "Package deleted"}))).into_response() (StatusCode::OK, Json(serde_json::json!({"message": "Package deleted"}))).into_response()
} }
Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(), Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
@ -341,78 +325,65 @@ async fn get_packages_by_type(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<PackageTypeQuery>, Query(q): Query<PackageTypeQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let package_type = q.package_type.as_deref().unwrap_or("TRACECOIN_BUNDLE"); let package_type = q.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string());
let now = chrono::Utc::now();
let packages = sqlx::query_as::<_, PricingPackageRow>( let rows = sqlx::query_as::<_, PricingPackageRow>(
r#" &package_query(
SELECT id, name, description, package_type, applicable_roles, "WHERE package_type = $1 AND is_active = true",
tracecoins_amount, price, duration_days, valid_from, valid_until, "ORDER BY price_inr ASC, created_at DESC",
is_promotional, is_active, features, created_at, updated_at ),
FROM pricing_packages
WHERE package_type = $1
AND is_active = true
AND (valid_from IS NULL OR valid_from <= $2)
AND (valid_until IS NULL OR valid_until > $2)
ORDER BY is_promotional DESC, price ASC
"#
) )
.bind(package_type) .bind(&package_type)
.bind(now)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await; .await;
let packages = match packages { let rows = match rows {
Ok(p) => p, Ok(rows) => rows,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}; };
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect(); let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"data": packages, "data": packages,
"package_type": package_type "package_type": package_type
}))).into_response() })))
.into_response()
} }
async fn get_packages_for_role( async fn get_packages_for_role(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<PackageTypeQuery>, Query(q): Query<PackageTypeQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let applicable_role = q.applicable_role.as_deref().unwrap_or(""); let role = q
.applicable_role
.or(q.role)
.unwrap_or_default()
.trim()
.to_uppercase();
let active_only = q.active_only.unwrap_or(true); let active_only = q.active_only.unwrap_or(true);
let now = chrono::Utc::now();
let packages = sqlx::query_as::<_, PricingPackageRow>( let rows = sqlx::query_as::<_, PricingPackageRow>(
&format!( &package_query(
r#" "WHERE ($1 = '' OR role_key = $1) AND ($2 = false OR is_active = true)",
SELECT id, name, description, package_type, applicable_roles, "ORDER BY price_inr ASC, created_at DESC",
tracecoins_amount, price, duration_days, valid_from, valid_until, ),
is_promotional, is_active, features, created_at, updated_at
FROM pricing_packages
WHERE ($1 = '' OR $1 = ANY(applicable_roles))
AND (is_active = true OR {} = false)
AND (valid_from IS NULL OR valid_from <= $2)
AND (valid_until IS NULL OR valid_until > $2)
ORDER BY is_promotional DESC, price ASC
"#,
if active_only { "true" } else { "false" }
)
) )
.bind(applicable_role) .bind(&role)
.bind(now) .bind(active_only)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await; .await;
let packages = match packages { let rows = match rows {
Ok(p) => p, Ok(rows) => rows,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}; };
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect(); let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"data": packages, "data": packages,
"applicable_role": applicable_role "applicable_role": role
}))).into_response() })))
.into_response()
} }

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
jsonwebtoken = "10.3" jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
argon2 = "0.5" argon2 = "0.5"
rand_core = { version = "0.6", features = ["std"] } rand_core = { version = "0.6", features = ["std"] }
serde = { workspace = true } serde = { workspace = true }

View file

@ -13,7 +13,7 @@ chrono = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
jsonwebtoken = "10.3" jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
db = { path = "../db" } db = { path = "../db" }
cache = { path = "../cache" } cache = { path = "../cache" }
storage = { path = "../storage" } storage = { path = "../storage" }