add: transfer refunds

This commit is contained in:
trisua 2025-08-09 14:00:46 -04:00
parent 95cb889080
commit fdaae8d977
10 changed files with 340 additions and 26 deletions

View file

@ -87,9 +87,9 @@
(span (span
(text "{{ dislikes }}")) (text "{{ dislikes }}"))
(text "{%- endif %}")) (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 (div
("class" "flex flex_wrap items_center") ("class" "flex {% if wrap -%} flex_wrap {%- endif %} items_center")
(a (a
("href" "/@{{ user.username }}") ("href" "/@{{ user.username }}")
("class" "flush flex gap_1") ("class" "flush flex gap_1")

View file

@ -49,24 +49,36 @@
(th (text "Sender")) (th (text "Sender"))
(th (text "Receiver")) (th (text "Receiver"))
(th (text "Amount")) (th (text "Amount"))
(th (text "Product"))) (th (text "Product"))
(th (text "Source"))
(th (text "Actions")))
(tbody (tbody
(text "{% for transfer in list -%}") (text "{% for transfer in list -%}")
(tr (tr
(td (span ("class" "date short") (text "{{ transfer[1] }}"))) (td (span ("class" "date short") (text "{{ transfer[3].created }}")))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}")) (td ("class" "w_content") (text "{{ components::full_username(user=transfer[0], wrap=false) }}"))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}")) (td ("class" "w_content") (text "{{ components::full_username(user=transfer[1], wrap=false) }}"))
(td (td
("class" "flex items_center gap_1") ("class" "flex items_center gap_1")
(text "{{ transfer[2] }}") (text "{{ transfer[3].amount }}")
(text "{% if transfer[6] -%}") (text "{% if transfer[3].is_pending -%}")
(span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock"))) (span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock")))
(text "{%- endif %}")) (text "{%- endif %}"))
(td (td
(text "{% if transfer[5] -%}") (text "{% if transfer[2] -%}")
(a (a
("href" "/product/{{ transfer[5].id }}") ("href" "/product/{{ transfer[2].id }}")
(icon (text "external-link"))) (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 "{%- endif %}")))
(text "{%- endfor %}"))))))) (text "{%- endfor %}")))))))
@ -118,5 +130,23 @@
window.location.href = res.payload; 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 %}") (text "{% endblock %}")

View file

@ -727,6 +727,10 @@ pub fn routes() -> Router {
// transfers // transfers
.route("/transfers", post(transfers::create_request)) .route("/transfers", post(transfers::create_request))
.route("/transfers/ask", post(transfers::ask_request)) .route("/transfers/ask", post(transfers::ask_request))
.route(
"/transfers/{id}/refund",
post(transfers::create_refund_request),
)
// products // products
.route("/products", post(products::create_request)) .route("/products", post(products::create_request))
.route("/products/{id}", delete(products::delete_request)) .route("/products/{id}", delete(products::delete_request))
@ -751,6 +755,14 @@ pub fn routes() -> Router {
post(products::update_method_request), post(products::update_method_request),
) )
.route("/products/{id}/stock", post(products::update_stock_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 { pub fn lw_routes() -> Router {
@ -1323,3 +1335,19 @@ pub struct AddAppliedConfiguration {
pub struct RemoveAppliedConfiguration { pub struct RemoveAppliedConfiguration {
pub id: String, 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,
}

View file

@ -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 axum::{extract::Path, response::IntoResponse, Extension, Json};
use tetratto_core::model::{ use tetratto_core::model::{
economy::{Product, ProductFulfillmentMethod}, economy::{Product, ProductFulfillmentMethod},
oauth, oauth,
permissions::FinePermission, permissions::FinePermission,
uploads::{MediaType, MediaUpload},
ApiReturn, Error, ApiReturn, Error,
}; };
use super::{ use super::{
CreateProduct, UpdateProductData, UpdateProductDescription, UpdateProductMethod, CreateProduct, ProductUploadTarget, RemoveProductThumbnail, UpdateProductData,
UpdateProductOnSale, UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice,
UpdateProductTitle, UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads,
}; };
pub async fn create_request( pub async fn create_request(
@ -296,3 +302,163 @@ pub async fn buy_request(
Err(e) => Json(e.into()), 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<State>,
Path(id): Path<usize>,
JsonMultipart(bytes_parts, req): JsonMultipart<UpdateProductUploads>,
) -> 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<State>,
Path(id): Path<usize>,
Json(req): Json<RemoveProductThumbnail>,
) -> 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()),
}
}

View file

@ -1,5 +1,5 @@
use crate::{get_user_from_token, State, cookie::CookieJar}; 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::{ use tetratto_core::model::{
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource}, economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource},
oauth, oauth,
@ -75,3 +75,46 @@ pub async fn ask_request(
Err(e) => Json(e.into()), Err(e) => Json(e.into()),
} }
} }
pub async fn create_refund_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> 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()),
}
}

View file

@ -9,5 +9,6 @@ CREATE TABLE IF NOT EXISTS products (
price INT NOT NULL, price INT NOT NULL,
stock INT NOT NULL, stock INT NOT NULL,
single_use INT NOT NULL, single_use INT NOT NULL,
data TEXT NOT NULL data TEXT NOT NULL,
uploads TEXT NOT NULL
) )

View file

@ -54,10 +54,14 @@ ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1;
ALTER TABLE transfers ALTER TABLE transfers
ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"'; ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"';
-- products single_use -- products data
ALTER TABLE products ALTER TABLE products
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT ''; ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '';
-- users applied_configurations -- users applied_configurations
ALTER TABLE users ALTER TABLE users
ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]'; ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]';
-- products uploads
ALTER TABLE products
ADD COLUMN IF NOT EXISTS uploads TEXT DEFAULT '{}';

View file

@ -2,6 +2,7 @@ use crate::model::{
auth::User, auth::User,
economy::{ economy::{
CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod, CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod,
ProductUploads,
}, },
mail::Letter, mail::Letter,
permissions::FinePermission, permissions::FinePermission,
@ -25,6 +26,7 @@ impl DataManager {
stock: get!(x->8(i32)), stock: get!(x->8(i32)),
single_use: get!(x->9(i32)) as i8 == 1, single_use: get!(x->9(i32)) as i8 == 1,
data: get!(x->10(String)), data: get!(x->10(String)),
uploads: serde_json::from_str(&get!(x->11(String))).unwrap(),
} }
} }
@ -107,7 +109,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &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![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -120,6 +122,7 @@ impl DataManager {
&(data.stock as i32), &(data.stock as i32),
&{ if data.single_use { 1 } else { 0 } }, &{ if data.single_use { 1 } else { 0 } },
&data.data, &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_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_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_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!(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); auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr);

View file

@ -1,8 +1,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::model::{ use crate::model::{
Error, Result,
economy::{CoinTransferMethod, Product, CoinTransfer},
auth::{Notification, User}, auth::{Notification, User},
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, Product},
Error, Result,
}; };
use crate::{auto_method, DataManager}; use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
@ -28,16 +28,13 @@ impl DataManager {
pub async fn fill_transfers( pub async fn fill_transfers(
&self, &self,
list: Vec<CoinTransfer>, list: Vec<CoinTransfer>,
) -> Result<Vec<(usize, usize, i32, User, User, Option<Product>, bool)>> { ) -> Result<Vec<(User, User, Option<Product>, CoinTransfer)>> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut seen_users: HashMap<usize, User> = HashMap::new(); let mut seen_users: HashMap<usize, User> = HashMap::new();
let mut seen_products: HashMap<usize, Product> = HashMap::new(); let mut seen_products: HashMap<usize, Product> = HashMap::new();
for transfer in list { for transfer in list {
out.push(( out.push((
transfer.id,
transfer.created,
transfer.amount,
if let Some(user) = seen_users.get(&transfer.sender) { if let Some(user) = seen_users.get(&transfer.sender) {
user.to_owned() user.to_owned()
} else { } 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?; 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 { } else {
// we haven't applied the transfer, so this must be pending // we haven't applied the transfer, so this must be pending
data.is_pending = true; data.is_pending = true;

View file

@ -16,6 +16,29 @@ pub enum ProductFulfillmentMethod {
ProfileStyle, 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<usize>,
/// 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)] #[derive(Clone, Serialize, Deserialize)]
pub struct Product { pub struct Product {
pub id: usize, pub id: usize,
@ -34,9 +57,14 @@ pub struct Product {
/// A negative stock means the product has unlimited stock. /// A negative stock means the product has unlimited stock.
pub stock: i32, pub stock: i32,
/// If this product is limited to one purchase per person. /// If this product is limited to one purchase per person.
#[serde(default)]
pub single_use: bool, pub single_use: bool,
/// Data for this product. Only used by snippets. /// Data for this product. Only used by snippets.
#[serde(default)]
pub data: String, pub data: String,
/// Uploads for this product.
#[serde(default)]
pub uploads: ProductUploads,
} }
impl Product { impl Product {
@ -54,6 +82,7 @@ impl Product {
stock: 0, stock: 0,
single_use: true, single_use: true,
data: String::new(), data: String::new(),
uploads: ProductUploads::default(),
} }
} }
} }
@ -65,7 +94,7 @@ pub enum CoinTransferMethod {
Purchase(usize), Purchase(usize),
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum CoinTransferSource { pub enum CoinTransferSource {
/// An unknown source, such as a transfer request. /// An unknown source, such as a transfer request.
General, General,
@ -73,6 +102,8 @@ pub enum CoinTransferSource {
Sale, Sale,
/// A purchase of coins through Stripe. /// A purchase of coins through Stripe.
Purchase, Purchase,
/// A refund of coins.
Refund,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]