add: coin purchases + donator badge

This commit is contained in:
trisua 2025-08-08 13:25:47 -04:00
parent fd529d3847
commit 44f9edd67e
21 changed files with 345 additions and 38 deletions

View file

@ -35,7 +35,7 @@
justify-content: right; justify-content: right;
} }
.justify-start { .justify_start {
justify-content: flex-start !important; justify-content: flex-start !important;
} }

View file

@ -169,21 +169,20 @@
("id" "littleweb") ("id" "littleweb")
(div (div
("class" "inner flex flex_col gap_2") ("class" "inner flex flex_col gap_2")
(a (a
("class" "button w_full lowered justify-start") ("class" "button w_full lowered justify_start")
("href" "/net") ("href" "/net")
(icon (text "globe")) (icon (text "globe"))
(str (text "littleweb:label.browser"))) (str (text "littleweb:label.browser")))
(a (a
("class" "button w_full lowered justify-start") ("class" "button w_full lowered justify_start")
("href" "/services") ("href" "/services")
(icon (text "panel-top")) (icon (text "panel-top"))
(str (text "littleweb:label.my_services"))) (str (text "littleweb:label.my_services")))
(a (a
("class" "button w_full lowered justify-start") ("class" "button w_full lowered justify_start")
("href" "/domains") ("href" "/domains")
(icon (text "panel-top")) (icon (text "panel-top"))
(str (text "littleweb:label.my_domains"))) (str (text "littleweb:label.my_domains")))

View file

@ -73,7 +73,7 @@
(text "{%- endif %}")) (text "{%- endif %}"))
(text "{% if can_manage_channels -%}") (text "{% if can_manage_channels -%}")
(a (a
("class" "button w_full justify-start lowered") ("class" "button w_full justify_start lowered")
("href" "/community/{{ selected_community }}/manage#/channels") ("href" "/community/{{ selected_community }}/manage#/channels")
(text "{{ icon \"plus\" }}") (text "{{ icon \"plus\" }}")
(span (span

View file

@ -7,7 +7,7 @@
(div (div
("class" "flex flex_row gap_1") ("class" "flex flex_row gap_1")
(a (a
("class" "w_full justify-start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}") ("class" "w_full justify_start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}")
("href" "/chats/{{ selected_community }}/{{ channel.id }}") ("href" "/chats/{{ selected_community }}/{{ channel.id }}")
("data-turbo" "{{ selected_community == '0' }}") ("data-turbo" "{{ selected_community == '0' }}")
(text "{{ icon \"rss\" }}") (text "{{ icon \"rss\" }}")

View file

@ -1863,14 +1863,14 @@
; option a ; option a
(button (button
("class" "hover_left_bar raised justify-start w_full poll_option") ("class" "hover_left_bar raised justify_start w_full poll_option")
("onclick" "trigger('me::vote', ['{{ post.id }}', 'A'])") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'A'])")
(icon (text "tally-1")) (icon (text "tally-1"))
(text "{{ poll[0].option_a }}")) (text "{{ poll[0].option_a }}"))
; option b ; option b
(button (button
("class" "hover_left_bar raised justify-start w_full poll_option") ("class" "hover_left_bar raised justify_start w_full poll_option")
("onclick" "trigger('me::vote', ['{{ post.id }}', 'B'])") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'B'])")
(icon (text "tally-2")) (icon (text "tally-2"))
(text "{{ poll[0].option_b }}")) (text "{{ poll[0].option_b }}"))
@ -1878,7 +1878,7 @@
; option c ; option c
(text "{% if poll[0].option_c -%}") (text "{% if poll[0].option_c -%}")
(button (button
("class" "hover_left_bar raised justify-start w_full poll_option") ("class" "hover_left_bar raised justify_start w_full poll_option")
("onclick" "trigger('me::vote', ['{{ post.id }}', 'C'])") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'C'])")
(icon (text "tally-3")) (icon (text "tally-3"))
(text "{{ poll[0].option_c }}")) (text "{{ poll[0].option_c }}"))
@ -1887,7 +1887,7 @@
; option d ; option d
(text "{% if poll[0].option_d -%}") (text "{% if poll[0].option_d -%}")
(button (button
("class" "hover_left_bar raised justify-start w_full poll_option") ("class" "hover_left_bar raised justify_start w_full poll_option")
("onclick" "trigger('me::vote', ['{{ post.id }}', 'D'])") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'D'])")
(icon (text "tally-4")) (icon (text "tally-4"))
(text "{{ poll[0].option_d }}")) (text "{{ poll[0].option_d }}"))
@ -2181,7 +2181,7 @@
("class" "flex flex_row gap_1") ("class" "flex flex_row gap_1")
(a (a
("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}")
("class" "button justify-start lowered w_full") ("class" "button justify_start lowered w_full")
(icon (text "notebook")) (icon (text "notebook"))
(text "{{ journal.title }}")) (text "{{ journal.title }}"))
@ -2207,7 +2207,7 @@
(div (div
("class" "flex flex_row gap_1") ("class" "flex flex_row gap_1")
(button (button
("class" "justify-start lowered w_full") ("class" "justify_start lowered w_full")
(icon (text "arrow-down")) (icon (text "arrow-down"))
(text "{{ journal.title }}")) (text "{{ journal.title }}"))
@ -2257,7 +2257,7 @@
; create note ; create note
(text "{% if user and user.id == journal.owner -%}") (text "{% if user and user.id == journal.owner -%}")
(button (button
("class" "lowered justify-start w_full") ("class" "lowered justify_start w_full")
("onclick" "create_note()") ("onclick" "create_note()")
(icon (text "plus")) (icon (text "plus"))
(str (text "journals:action.create_note"))) (str (text "journals:action.create_note")))
@ -2271,7 +2271,7 @@
(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}") (text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}")
(details (details
(summary (summary
("class" "button w_full justify-start raised w_full") ("class" "button w_full justify_start raised w_full")
(icon (text "folder")) (icon (text "folder"))
(text "{{ dir[2] }}")) (text "{{ dir[2] }}"))
@ -2299,7 +2299,7 @@
("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}") ("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}")
(a (a
("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}") ("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}")
("class" "button justify-start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") ("class" "button justify_start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
(icon (text "file-text")) (icon (text "file-text"))
(text "{{ note.title }}")) (text "{{ note.title }}"))
@ -2380,7 +2380,7 @@
(div (div
("class" "flex flex_row gap_1") ("class" "flex flex_row gap_1")
(button (button
("class" "justify-start lowered w_full") ("class" "justify_start lowered w_full")
(icon (text "folder-open")) (icon (text "folder-open"))
(text "{{ dir[2] }}")) (text "{{ dir[2] }}"))
@ -2423,7 +2423,7 @@
(text "{% macro note_mover_dirs_listing(dir, dirs) -%}") (text "{% macro note_mover_dirs_listing(dir, dirs) -%}")
(button (button
("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()") ("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()")
("class" "justify-start lowered w_full") ("class" "justify_start lowered w_full")
(icon (text "folder-open")) (icon (text "folder-open"))
(text "{{ dir[2] }}")) (text "{{ dir[2] }}"))

View file

@ -14,9 +14,9 @@
(span (str (text "general:link.wallet"))))) (span (str (text "general:link.wallet")))))
(div (div
("class" "card lowered flex flex_col gap_4") ("class" "card lowered flex flex_col gap_4")
(a (button
("class" "card button raised") ("class" "card button raised")
("href" "/wallet/buy") ("onclick" "document.getElementById('buy_dialog').showModal()")
(b (text "Coin balance")) (b (text "Coin balance"))
(h3 (h3
("class" "flex gap_2 items_center") ("class" "flex gap_2 items_center")
@ -63,4 +63,54 @@
(icon (text "external-link"))) (icon (text "external-link")))
(text "{%- endif %}"))) (text "{%- endif %}")))
(text "{%- endfor %}"))))))) (text "{%- endfor %}")))))))
(dialog
("id" "buy_dialog")
(div
("class" "inner flex flex_col gap_2")
(p (text "All coin purchases are one-time and will not recur."))
(p (text "If you do not receive your coins within a minute of purchase, please contact support."))
(button
("class" "lowered w_full justify_start")
("onclick" "checkout('Coins100')")
(text "100 coins ({{ config.stripe.price_texts.coins_100 }})"))
(button
("class" "w_full justify_start")
("onclick" "checkout('Coins400')")
(text "400 coins ({{ config.stripe.price_texts.coins_400 }})"))
(hr ("class" "margin"))
(div
("class" "flex gap_2 justify_between")
(div null?)
(button
("class" "lowered red")
("type" "button")
("onclick", "document.getElementById('buy_dialog').close()")
(icon (text "x"))
(str (text "dialog:action.cancel"))))))
(script
(text "globalThis.checkout = (product) => {
document.getElementById('buy_dialog').close();
fetch(\"/api/v1/service_hooks/stripe/checkout\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
product,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
if (res.ok) {
window.location.href = res.payload;
}
});
}"))
(text "{% endblock %}") (text "{% endblock %}")

View file

@ -121,7 +121,7 @@
(div (div
("class" "flex flex_col gap_2 w_full") ("class" "flex flex_col gap_2 w_full")
(button (button
("class" "lowered justify-start w_full") ("class" "lowered justify_start w_full")
("onclick" "create_journal()") ("onclick" "create_journal()")
(icon (text "plus")) (icon (text "plus"))
(str (text "journals:action.create_journal"))) (str (text "journals:action.create_journal")))
@ -207,7 +207,7 @@
(details (details
("class" "w_full") ("class" "w_full")
(summary (summary
("class" "button lowered w_full justify-start") ("class" "button lowered w_full justify_start")
(icon (text "settings")) (icon (text "settings"))
(str (text "general:action.manage"))) (str (text "general:action.manage")))
@ -261,7 +261,7 @@
(details (details
("class" "w_full") ("class" "w_full")
(summary (summary
("class" "button lowered w_full justify-start") ("class" "button lowered w_full justify_start")
(icon (text "folders")) (icon (text "folders"))
(str (text "journals:label.directories"))) (str (text "journals:label.directories")))

View file

@ -64,7 +64,7 @@
("id" "username") ("id" "username")
("class" "username flex items_center gap_2 flex_wrap w_full") ("class" "username flex items_center gap_2 flex_wrap w_full")
(span (span
("class" "name shorter") ("class" "name")
(text "{{ components::username(user=profile) }}")) (text "{{ components::username(user=profile) }}"))
(text "{% if profile.is_verified -%}") (text "{% if profile.is_verified -%}")
(span (span
@ -84,6 +84,12 @@
("style" "color: var(--color-primary);") ("style" "color: var(--color-primary);")
("class" "flex items_center") ("class" "flex items_center")
(text "{{ icon \"id-card-lanyard\" }}")) (text "{{ icon \"id-card-lanyard\" }}"))
(text "{%- endif %} {% if profile.checkouts|length > 0 -%}")
(span
("title" "Donator")
("style" "color: var(--color-primary);")
("class" "flex items_center")
(text "{{ icon \"hand-heart\" }}"))
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
(span (span
("title" "Staff") ("title" "Staff")

View file

@ -32,7 +32,7 @@
("class" "card w_full flex flex_col gap_2") ("class" "card w_full flex flex_col gap_2")
("ui_ident" "io_data_load") ("ui_ident" "io_data_load")
; pinned ; pinned
(text "{% if pinned|length > 0 -%}") (text "{% if pinned and pinned|length > 0 -%}")
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")
(div ("class" "squig")) (div ("class" "squig"))
(text "{%- endif %}") (text "{%- endif %}")

View file

@ -32,7 +32,7 @@
("class" "card w_full flex flex_col gap_2") ("class" "card w_full flex flex_col gap_2")
("ui_ident" "io_data_load") ("ui_ident" "io_data_load")
; pinned ; pinned
(text "{% if pinned|length > 0 -%}") (text "{% if pinned and pinned|length > 0 -%}")
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")
(div ("class" "squig")) (div ("class" "squig"))
(text "{%- endif %}") (text "{%- endif %}")

View file

@ -16,7 +16,7 @@
(span (text "Select a stack to add this user to:")) (span (text "Select a stack to add this user to:"))
(text "{% for stack in stacks %}") (text "{% for stack in stacks %}")
(button (button
("class" "justify-start lowered w_full") ("class" "justify_start lowered w_full")
("onclick" "choose_stack('{{ stack.id }}')") ("onclick" "choose_stack('{{ stack.id }}')")
(icon (text "layers")) (icon (text "layers"))
(text "{{ stack.name }}")) (text "{{ stack.name }}"))

View file

@ -726,7 +726,7 @@
element.innerHTML = ""; element.innerHTML = "";
for (const token of Object.entries($.LOGIN_ACCOUNT_TOKENS)) { for (const token of Object.entries($.LOGIN_ACCOUNT_TOKENS)) {
element.innerHTML += `<div class="flex gap_2 flex_row"> element.innerHTML += `<div class="flex gap_2 flex_row">
<button class="lowered w_full justify-start" onclick="trigger('me::login', ['${token[0]}'])"> <button class="lowered w_full justify_start" onclick="trigger('me::login', ['${token[0]}'])">
<img <img
title="${token[0]}'s avatar" title="${token[0]}'s avatar"
src="/api/v1/auth/user/${token[0]}/avatar?selector_type=username" src="/api/v1/auth/user/${token[0]}/avatar?selector_type=username"

View file

@ -1,13 +1,20 @@
use std::time::Duration; use std::{str::FromStr, time::Duration};
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; use axum::{
extract::Query,
http::HeaderMap,
response::{IntoResponse, Redirect},
Extension, Json,
};
use tetratto_core::model::{ use tetratto_core::model::{
auth::{Notification, User}, auth::{Notification, User},
economy::{CoinTransfer, CoinTransferMethod},
moderation::AuditLogEntry, moderation::AuditLogEntry,
permissions::{FinePermission, SecondaryPermission}, permissions::{FinePermission, SecondaryPermission},
ApiReturn, Error, ApiReturn, Error,
}; };
use stripe::{EventObject, EventType}; use stripe::{EventObject, EventType};
use crate::State; use crate::{get_user_from_token, State, cookie::CookieJar};
use serde::Deserialize;
pub async fn stripe_webhook( pub async fn stripe_webhook(
Extension(data): Extension<State>, Extension(data): Extension<State>,
@ -131,7 +138,7 @@ pub async fn stripe_webhook(
if let Err(e) = data if let Err(e) = data
.create_audit_log_entry(AuditLogEntry::new( .create_audit_log_entry(AuditLogEntry::new(
0, 0,
format!("invoice tier update failed: stripe {customer_id}"), format!("invoice user update failed: stripe {customer_id}"),
)) ))
.await .await
{ {
@ -469,3 +476,202 @@ pub async fn stripe_webhook(
payload: (), payload: (),
}) })
} }
#[derive(Deserialize)]
pub enum ProductIDAlias {
Coins100,
Coins400,
}
#[derive(Deserialize)]
pub struct CreateCheckoutSessionProps {
pub product: ProductIDAlias,
}
pub async fn create_stupid_fucking_checkout_session(
jar: CookieJar,
Extension(data): Extension<State>,
Json(props): Json<CreateCheckoutSessionProps>,
) -> impl IntoResponse {
let data = &(data.read().await);
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
let stripe_cnf = match data.0.0.0.stripe {
Some(ref c) => c,
None => return Json(Error::MiscError("Disabled".to_string()).into()),
};
let stripe_client = match data.3 {
Some(ref x) => x,
None => return Json(Error::MiscError("Disabled".to_string()).into()),
};
let session = match stripe::CheckoutSession::create(
&stripe_client,
stripe::CreateCheckoutSession {
customer_creation: if user.stripe_id.is_empty() {
Some(stripe::CheckoutSessionCustomerCreation::Always)
} else {
None
},
customer: if user.stripe_id.is_empty() {
None
} else {
Some(stripe::CustomerId::from_str(&user.stripe_id).unwrap())
},
line_items: Some(vec![stripe::CreateCheckoutSessionLineItems {
quantity: Some(1),
adjustable_quantity: Some(
stripe::CreateCheckoutSessionLineItemsAdjustableQuantity {
enabled: false,
..Default::default()
},
),
price: Some(match props.product {
ProductIDAlias::Coins100 => stripe_cnf.price_ids.coins_100.clone(),
ProductIDAlias::Coins400 => stripe_cnf.price_ids.coins_400.clone(),
}),
..Default::default()
}]),
client_reference_id: Some(&user.id.to_string()),
mode: Some(stripe::CheckoutSessionMode::Payment),
ui_mode: Some(stripe::CheckoutSessionUiMode::Hosted),
success_url: Some(&format!(
"{}/api/v1/service_hooks/stripe/checkout/success?session_id={{CHECKOUT_SESSION_ID}}",
data.0.0.0.host
)),
..Default::default()
},
)
.await
{
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
Json(ApiReturn {
ok: true,
message: "Success".to_string(),
payload: session.url.unwrap(),
})
}
#[derive(Deserialize)]
pub struct CheckoutSessionSuccessProps {
pub session_id: String,
}
/// By this point, we can assume the customer has properly paid.
///
/// This endpoint will just read the purchase, apply the purchase, and then redirect home.
pub async fn handle_stupid_fucking_checkout_success_session(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<CheckoutSessionSuccessProps>,
) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> {
let data = &(data.read().await);
let mut user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => return Err(Json(Error::NotAllowed.into())),
};
if user.checkouts.contains(&props.session_id) {
return Err(Json(
Error::MiscError("You can only do this once".to_string()).into(),
));
}
let stripe_cnf = match data.0.0.0.stripe {
Some(ref c) => c,
None => return Err(Json(Error::MiscError("Disabled".to_string()).into())),
};
let stripe_client = match data.3 {
Some(ref x) => x,
None => return Err(Json(Error::MiscError("Disabled".to_string()).into())),
};
let session = match stripe::CheckoutSession::retrieve(
&stripe_client,
&match stripe::CheckoutSessionId::from_str(&props.session_id) {
Ok(x) => x,
Err(_) => {
return Err(Json(
Error::MiscError("Invalid session ID".to_string()).into(),
));
}
},
&[&"line_items"],
)
.await
{
Ok(x) => x,
Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
};
let price_id = session
.line_items
.as_ref()
.unwrap()
.data
.get(0)
.as_ref()
.unwrap()
.price
.as_ref()
.unwrap()
.id
.to_string();
if price_id == stripe_cnf.price_ids.coins_100 {
if let Err(e) = data
.0
.create_transfer(
&mut CoinTransfer::new(
data.0.0.0.system_user,
user.id,
100,
CoinTransferMethod::Transfer,
),
true,
)
.await
{
return Err(Json(e.into()));
}
} else if price_id == stripe_cnf.price_ids.coins_400 {
if let Err(e) = data
.0
.create_transfer(
&mut CoinTransfer::new(
data.0.0.0.system_user,
user.id,
400,
CoinTransferMethod::Transfer,
),
true,
)
.await
{
return Err(Json(e.into()));
}
} else {
tracing::error!(
"received an invalid stripe price id, please check config.stripe.price_ids"
);
return Err(Json(
Error::MiscError("Unknown price ID".to_string()).into(),
));
}
user.checkouts.push(props.session_id);
if let Err(e) = data.0.update_user_checkouts(user.id, user.checkouts).await {
return Err(Json(e.into()));
}
Ok(Redirect::to("/wallet"))
}

View file

@ -563,6 +563,14 @@ pub fn routes() -> Router {
"/service_hooks/stripe", "/service_hooks/stripe",
post(auth::connections::stripe::stripe_webhook), post(auth::connections::stripe::stripe_webhook),
) )
.route(
"/service_hooks/stripe/checkout",
post(auth::connections::stripe::create_stupid_fucking_checkout_session),
)
.route(
"/service_hooks/stripe/checkout/success",
get(auth::connections::stripe::handle_stupid_fucking_checkout_success_session),
)
// channels // channels
.route("/channels", post(channels::channels::create_request)) .route("/channels", post(channels::channels::create_request))
.route( .route(

View file

@ -152,7 +152,7 @@ pub async fn update_price_request(
if req.price < 25 { if req.price < 25 {
return Json( return Json(
Error::MiscError( Error::MiscError(
"Price is too low, please a price of use 25 coins or more".to_string(), "Price is too low, please use a price of 25 coins or more".to_string(),
) )
.into(), .into(),
); );

View file

@ -184,7 +184,7 @@ pub struct StripeConfig {
pub payment_links: StripePaymentLinks, pub payment_links: StripePaymentLinks,
/// To apply benefits to user accounts, you should then go into the Stripe developer /// To apply benefits to user accounts, you should then go into the Stripe developer
/// "workbench" and create a new webhook. The webhook needs the scopes: /// "workbench" and create a new webhook. The webhook needs the scopes:
/// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`. /// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`, `charge.succeeded`.
/// ///
/// The webhook's destination address should be `{your server origin}/api/v1/service_hooks/stripe`. /// The webhook's destination address should be `{your server origin}/api/v1/service_hooks/stripe`.
/// ///
@ -200,24 +200,38 @@ pub struct StripeConfig {
/// ///
/// These are checked when we receive a webhook to ensure we provide the correct product. /// These are checked when we receive a webhook to ensure we provide the correct product.
pub product_ids: StripeProductIds, pub product_ids: StripeProductIds,
/// The IDs of individual prices for products which require us to generate sessions ourselves.
pub price_ids: StripePriceIds,
} }
#[derive(Clone, Serialize, Deserialize, Debug, Default)] #[derive(Clone, Serialize, Deserialize, Debug, Default)]
pub struct StripePriceTexts { pub struct StripePriceTexts {
pub supporter: String, pub supporter: String,
pub dev_pass: String, pub dev_pass: String,
pub coins_100: String,
pub coins_400: String,
} }
#[derive(Clone, Serialize, Deserialize, Debug, Default)] #[derive(Clone, Serialize, Deserialize, Debug, Default)]
pub struct StripePaymentLinks { pub struct StripePaymentLinks {
pub supporter: String, pub supporter: String,
pub dev_pass: String, pub dev_pass: String,
pub coins_100: String,
pub coins_400: String,
} }
#[derive(Clone, Serialize, Deserialize, Debug, Default)] #[derive(Clone, Serialize, Deserialize, Debug, Default)]
pub struct StripeProductIds { pub struct StripeProductIds {
pub supporter: String, pub supporter: String,
pub dev_pass: String, pub dev_pass: String,
pub coins_100: String,
pub coins_400: String,
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
pub struct StripePriceIds {
pub coins_100: String,
pub coins_400: String,
} }
/// Manuals config (search help, etc) /// Manuals config (search help, etc)

View file

@ -129,6 +129,7 @@ impl DataManager {
is_deactivated: get!(x->29(i32)) as i8 == 1, is_deactivated: get!(x->29(i32)) as i8 == 1,
ban_expire: get!(x->30(i64)) as usize, ban_expire: get!(x->30(i64)) as usize,
coins: get!(x->31(i32)), coins: get!(x->31(i32)),
checkouts: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(),
} }
} }
@ -285,7 +286,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32)", "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -318,7 +319,8 @@ impl DataManager {
&serde_json::to_string(&data.channel_mutes).unwrap(), &serde_json::to_string(&data.channel_mutes).unwrap(),
&if data.is_deactivated { 1_i32 } else { 0_i32 }, &if data.is_deactivated { 1_i32 } else { 0_i32 },
&(data.ban_expire as i64), &(data.ban_expire as i64),
&(data.coins as i32) &(data.coins as i32),
&serde_json::to_string(&data.checkouts).unwrap(),
] ]
); );
@ -1061,6 +1063,7 @@ impl DataManager {
auto_method!(update_user_channel_mutes(Vec<usize>)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_channel_mutes(Vec<usize>)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_coins(i32)@get_user_by_id -> "UPDATE users SET coins = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_coins(i32)@get_user_by_id -> "UPDATE users SET coins = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_checkouts(Vec<String>)@get_user_by_id -> "UPDATE users SET checkouts = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);

View file

@ -30,5 +30,6 @@ CREATE TABLE IF NOT EXISTS users (
channel_mutes TEXT NOT NULL, channel_mutes TEXT NOT NULL,
is_deactivated INT NOT NULL, is_deactivated INT NOT NULL,
ban_expire BIGINT NOT NULL, ban_expire BIGINT NOT NULL,
coins INT NOT NULL coins INT NOT NULL,
checkouts TEXT NOT NULL
) )

View file

@ -41,3 +41,7 @@ ADD COLUMN IF NOT EXISTS coins INT DEFAULT 0;
-- requests data -- requests data
ALTER TABLE requests ALTER TABLE requests
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"'; ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"';
-- users checkouts
ALTER TABLE users
ADD COLUMN IF NOT EXISTS checkouts TEXT DEFAULT '[]';

View file

@ -109,6 +109,12 @@ impl DataManager {
// check values // check values
let mut sender = self.get_user_by_id(data.sender).await?; let mut sender = self.get_user_by_id(data.sender).await?;
let mut receiver = self.get_user_by_id(data.receiver).await?; let mut receiver = self.get_user_by_id(data.receiver).await?;
if sender.id == self.0.0.system_user {
// system user can create coins from the void
sender.coins = i32::MAX;
}
let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver); let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver);
if sender_bankrupt | receiver_bankrupt { if sender_bankrupt | receiver_bankrupt {
@ -118,7 +124,10 @@ impl DataManager {
} }
if apply { if apply {
self.update_user_coins(sender.id, sender.coins).await?; if sender.id != self.0.0.system_user {
self.update_user_coins(sender.id, sender.coins).await?;
}
self.update_user_coins(receiver.id, receiver.coins).await?; self.update_user_coins(receiver.id, receiver.coins).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

View file

@ -95,6 +95,12 @@ pub struct User {
/// The number of coins the user has. /// The number of coins the user has.
#[serde(default)] #[serde(default)]
pub coins: i32, pub coins: i32,
/// The IDs of Stripe checkout sessions that this user has successfully completed.
///
/// This should be checked BEFORE applying purchases to ensure that the user hasn't
/// already applied this purchase.
#[serde(default)]
pub checkouts: Vec<String>,
} }
pub type UserConnections = pub type UserConnections =
@ -407,6 +413,7 @@ impl User {
is_deactivated: false, is_deactivated: false,
ban_expire: 0, ban_expire: 0,
coins: 0, coins: 0,
checkouts: Vec::new(),
} }
} }