From fdaae8d977434da04528e405036d710442b1bf5f Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 9 Aug 2025 14:00:46 -0400 Subject: [PATCH] add: transfer refunds --- crates/app/src/public/html/components.lisp | 4 +- .../app/src/public/html/economy/wallet.lisp | 46 ++++- crates/app/src/routes/api/v1/mod.rs | 28 +++ crates/app/src/routes/api/v1/products.rs | 174 +++++++++++++++++- crates/app/src/routes/api/v1/transfers.rs | 45 ++++- .../database/drivers/sql/create_products.sql | 3 +- .../drivers/sql/version_migrations.sql | 6 +- crates/core/src/database/products.rs | 6 +- crates/core/src/database/transfers.rs | 21 ++- crates/core/src/model/economy.rs | 33 +++- 10 files changed, 340 insertions(+), 26 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index b1e4599..706a594 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -87,9 +87,9 @@ (span (text "{{ dislikes }}")) (text "{%- endif %}")) -(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}") +(text "{%- endif %} {%- endmacro %} {% macro full_username(user, wrap=true) -%} {% if user and user.username -%}") (div - ("class" "flex flex_wrap items_center") + ("class" "flex {% if wrap -%} flex_wrap {%- endif %} items_center") (a ("href" "/@{{ user.username }}") ("class" "flush flex gap_1") diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp index 5458233..2ffd755 100644 --- a/crates/app/src/public/html/economy/wallet.lisp +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -49,24 +49,36 @@ (th (text "Sender")) (th (text "Receiver")) (th (text "Amount")) - (th (text "Product"))) + (th (text "Product")) + (th (text "Source")) + (th (text "Actions"))) (tbody (text "{% for transfer in list -%}") (tr - (td (span ("class" "date short") (text "{{ transfer[1] }}"))) - (td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}")) - (td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}")) + (td (span ("class" "date short") (text "{{ transfer[3].created }}"))) + (td ("class" "w_content") (text "{{ components::full_username(user=transfer[0], wrap=false) }}")) + (td ("class" "w_content") (text "{{ components::full_username(user=transfer[1], wrap=false) }}")) (td ("class" "flex items_center gap_1") - (text "{{ transfer[2] }}") - (text "{% if transfer[6] -%}") + (text "{{ transfer[3].amount }}") + (text "{% if transfer[3].is_pending -%}") (span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock"))) (text "{%- endif %}")) (td - (text "{% if transfer[5] -%}") + (text "{% if transfer[2] -%}") (a - ("href" "/product/{{ transfer[5].id }}") + ("href" "/product/{{ transfer[2].id }}") (icon (text "external-link"))) + (text "{%- endif %}")) + (td (text "{{ transfer[3].source }}")) + (td + (text "{% if user.id == transfer[1].id -%}") + ; we're the receiver + (button + ("class" "small tiny square raised camo big_icon") + ("onclick" "issue_refund('{{ transfer[3].id }}')") + ("title" "Issue refund") + (icon (text "undo"))) (text "{%- endif %}"))) (text "{%- endfor %}"))))))) @@ -118,5 +130,23 @@ window.location.href = res.payload; } }); + } + + globalThis.issue_refund = async (transfer) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/transfers/${transfer}/refund`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]); + }); }")) (text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 7ce8444..4a64b2f 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -727,6 +727,10 @@ pub fn routes() -> Router { // transfers .route("/transfers", post(transfers::create_request)) .route("/transfers/ask", post(transfers::ask_request)) + .route( + "/transfers/{id}/refund", + post(transfers::create_refund_request), + ) // products .route("/products", post(products::create_request)) .route("/products/{id}", delete(products::delete_request)) @@ -751,6 +755,14 @@ pub fn routes() -> Router { post(products::update_method_request), ) .route("/products/{id}/stock", post(products::update_stock_request)) + .route( + "/products/{id}/uploads", + post(products::update_uploads_request), + ) + .route( + "/products/{id}/uploads/thumbnails", + delete(products::remove_thumbnail_request), + ) } pub fn lw_routes() -> Router { @@ -1323,3 +1335,19 @@ pub struct AddAppliedConfiguration { pub struct RemoveAppliedConfiguration { pub id: String, } + +#[derive(Deserialize, PartialEq, Eq)] +pub enum ProductUploadTarget { + Thumbnails, + Reward, +} + +#[derive(Deserialize)] +pub struct UpdateProductUploads { + pub target: ProductUploadTarget, +} + +#[derive(Deserialize)] +pub struct RemoveProductThumbnail { + pub idx: usize, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index b255de6..03e319c 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,15 +1,21 @@ -use crate::{get_user_from_token, State, cookie::CookieJar}; +use crate::{ + cookie::CookieJar, + get_user_from_token, + image::{save_webp_buffer, JsonMultipart}, + State, +}; use axum::{extract::Path, response::IntoResponse, Extension, Json}; use tetratto_core::model::{ economy::{Product, ProductFulfillmentMethod}, oauth, permissions::FinePermission, + uploads::{MediaType, MediaUpload}, ApiReturn, Error, }; use super::{ - CreateProduct, UpdateProductData, UpdateProductDescription, UpdateProductMethod, - UpdateProductOnSale, UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, - UpdateProductTitle, + CreateProduct, ProductUploadTarget, RemoveProductThumbnail, UpdateProductData, + UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice, + UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads, }; pub async fn create_request( @@ -296,3 +302,163 @@ pub async fn buy_request( Err(e) => Json(e.into()), } } + +const MAXIMUM_THUMBNAIL_FILE_SIZE: usize = 2_097_152; +const MAXIMUM_REWARD_FILE_SIZE: usize = 4_194_304; + +/// Update the product's uploads. Only reads one multipart file entry. +pub async fn update_uploads_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + JsonMultipart(bytes_parts, req): JsonMultipart, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut product = match data.get_product_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + // apply to target + match req.target { + ProductUploadTarget::Thumbnails => { + if product.uploads.thumbnails.len() == 4 { + return Json( + Error::MiscError("Too many thumbnails exist. Please remove one".to_string()) + .into(), + ); + } + + // create upload + let file = match bytes_parts.get(0) { + Some(x) => x, + None => return Json(Error::Unknown.into()), + }; + + if file.len() > MAXIMUM_THUMBNAIL_FILE_SIZE { + return Json(Error::FileTooLarge.into()); + } + + let upload = match data + .create_upload(MediaUpload::new(MediaType::Webp, user.id)) + .await + { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + product.uploads.thumbnails.push(upload.id); + + // write image + if let Err(e) = + save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None) + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + ProductUploadTarget::Reward => { + // remove old + if product.uploads.reward != 0 { + if let Err(e) = data.delete_upload(product.uploads.reward).await { + return Json(e.into()); + } + } + + // create upload + let file = match bytes_parts.get(0) { + Some(x) => x, + None => return Json(Error::Unknown.into()), + }; + + if file.len() > MAXIMUM_REWARD_FILE_SIZE { + return Json(Error::FileTooLarge.into()); + } + + let upload = match data + .create_upload(MediaUpload::new(MediaType::Webp, user.id)) + .await + { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + product.uploads.reward = upload.id; + + // write image + if let Err(e) = + save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None) + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + } + + // ... + match data + .update_product_uploads(id, &user, product.uploads) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn remove_thumbnail_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut product = match data.get_product_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + // remove upload + let thumbnail = match product.uploads.thumbnails.get(req.idx) { + Some(x) => x, + None => return Json(Error::GeneralNotFound("thumbnail".to_string()).into()), + }; + + if let Err(e) = data.delete_upload(*thumbnail).await { + return Json(e.into()); + } + + product.uploads.thumbnails.remove(req.idx); + + // ... + match data + .update_product_uploads(id, &user, product.uploads) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/transfers.rs b/crates/app/src/routes/api/v1/transfers.rs index 0995006..9547c50 100644 --- a/crates/app/src/routes/api/v1/transfers.rs +++ b/crates/app/src/routes/api/v1/transfers.rs @@ -1,5 +1,5 @@ use crate::{get_user_from_token, State, cookie::CookieJar}; -use axum::{response::IntoResponse, Extension, Json}; +use axum::{response::IntoResponse, Extension, Json, extract::Path}; use tetratto_core::model::{ economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource}, oauth, @@ -75,3 +75,46 @@ pub async fn ask_request( Err(e) => Json(e.into()), } } + +pub async fn create_refund_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let other_transfer = match data.get_transfer_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if user.id != other_transfer.receiver { + // only the receiver of the funds can issue a refund (atm) + return Json(Error::NotAllowed.into()); + } + + match data + .create_transfer( + &mut CoinTransfer::new( + other_transfer.receiver, + other_transfer.sender, + other_transfer.amount, + CoinTransferMethod::Transfer, + CoinTransferSource::Refund, + ), + true, + ) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Transfer created".to_string(), + payload: s.to_string(), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index 5d3afe8..2f27dcd 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -9,5 +9,6 @@ CREATE TABLE IF NOT EXISTS products ( price INT NOT NULL, stock INT NOT NULL, single_use INT NOT NULL, - data TEXT NOT NULL + data TEXT NOT NULL, + uploads TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index f1e95a3..fb70e6b 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -54,10 +54,14 @@ ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1; ALTER TABLE transfers ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"'; --- products single_use +-- products data ALTER TABLE products ADD COLUMN IF NOT EXISTS data TEXT DEFAULT ''; -- users applied_configurations ALTER TABLE users ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]'; + +-- products uploads +ALTER TABLE products +ADD COLUMN IF NOT EXISTS uploads TEXT DEFAULT '{}'; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index de18074..24a2405 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -2,6 +2,7 @@ use crate::model::{ auth::User, economy::{ CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod, + ProductUploads, }, mail::Letter, permissions::FinePermission, @@ -25,6 +26,7 @@ impl DataManager { stock: get!(x->8(i32)), single_use: get!(x->9(i32)) as i8 == 1, data: get!(x->10(String)), + uploads: serde_json::from_str(&get!(x->11(String))).unwrap(), } } @@ -107,7 +109,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", params![ &(data.id as i64), &(data.created as i64), @@ -120,6 +122,7 @@ impl DataManager { &(data.stock as i32), &{ if data.single_use { 1 } else { 0 } }, &data.data, + &serde_json::to_string(&data.uploads).unwrap(), ] ); @@ -271,6 +274,7 @@ If your product is a purchase of goods or services, please be sure to fulfill th auto_method!(update_product_method(ProductFulfillmentMethod)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET method = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_single_use(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET single_use = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_data(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET data = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_uploads(ProductUploads)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET uploads = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_stock(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET stock = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr); diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs index ecbde84..4653166 100644 --- a/crates/core/src/database/transfers.rs +++ b/crates/core/src/database/transfers.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use crate::model::{ - Error, Result, - economy::{CoinTransferMethod, Product, CoinTransfer}, auth::{Notification, User}, + economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, Product}, + Error, Result, }; use crate::{auto_method, DataManager}; use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; @@ -28,16 +28,13 @@ impl DataManager { pub async fn fill_transfers( &self, list: Vec, - ) -> Result, bool)>> { + ) -> Result, CoinTransfer)>> { let mut out = Vec::new(); let mut seen_users: HashMap = HashMap::new(); let mut seen_products: HashMap = HashMap::new(); for transfer in list { out.push(( - transfer.id, - transfer.created, - transfer.amount, if let Some(user) = seen_users.get(&transfer.sender) { user.to_owned() } else { @@ -68,7 +65,7 @@ impl DataManager { } } }, - transfer.is_pending, + transfer, )); } @@ -163,6 +160,16 @@ impl DataManager { } self.update_user_coins(receiver.id, receiver.coins).await?; + + // handle refund notification + if data.source == CoinTransferSource::Refund { + self.create_notification(Notification::new( + "A coin refund has been issued to your account!".to_string(), + "You've been issued a refund for a prior purchase. The product will remain in your account, but your coins have been returned.".to_string(), + receiver.id, + )) + .await?; + } } else { // we haven't applied the transfer, so this must be pending data.is_pending = true; diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs index 4b768b2..20ee0b2 100644 --- a/crates/core/src/model/economy.rs +++ b/crates/core/src/model/economy.rs @@ -16,6 +16,29 @@ pub enum ProductFulfillmentMethod { ProfileStyle, } +#[derive(Clone, Serialize, Deserialize)] +pub struct ProductUploads { + /// Promotional thumbnails shown on the product page. + /// + /// Maximum of 4 with a maximum upload size of 2 MiB. + #[serde(default)] + pub thumbnails: Vec, + /// Reward given to users through active configurations after they purchase the product. + // + // Maximum upload size of 4 MiB. + #[serde(default)] + pub reward: usize, +} + +impl Default for ProductUploads { + fn default() -> Self { + Self { + thumbnails: Vec::new(), + reward: 0, + } + } +} + #[derive(Clone, Serialize, Deserialize)] pub struct Product { pub id: usize, @@ -34,9 +57,14 @@ pub struct Product { /// A negative stock means the product has unlimited stock. pub stock: i32, /// If this product is limited to one purchase per person. + #[serde(default)] pub single_use: bool, /// Data for this product. Only used by snippets. + #[serde(default)] pub data: String, + /// Uploads for this product. + #[serde(default)] + pub uploads: ProductUploads, } impl Product { @@ -54,6 +82,7 @@ impl Product { stock: 0, single_use: true, data: String::new(), + uploads: ProductUploads::default(), } } } @@ -65,7 +94,7 @@ pub enum CoinTransferMethod { Purchase(usize), } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum CoinTransferSource { /// An unknown source, such as a transfer request. General, @@ -73,6 +102,8 @@ pub enum CoinTransferSource { Sale, /// A purchase of coins through Stripe. Purchase, + /// A refund of coins. + Refund, } #[derive(Serialize, Deserialize)]