add: developer pass
This commit is contained in:
parent
636ecce9f4
commit
02f3d08926
14 changed files with 355 additions and 101 deletions
|
@ -15,7 +15,7 @@ use tetratto_core::{
|
||||||
config::Config,
|
config::Config,
|
||||||
model::{
|
model::{
|
||||||
auth::{DefaultTimelineChoice, User},
|
auth::{DefaultTimelineChoice, User},
|
||||||
permissions::FinePermission,
|
permissions::{FinePermission, SecondaryPermission},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use tetratto_l10n::LangFile;
|
use tetratto_l10n::LangFile;
|
||||||
|
@ -516,6 +516,11 @@ pub(crate) async fn initial_context(
|
||||||
"is_supporter",
|
"is_supporter",
|
||||||
&ua.permissions.check(FinePermission::SUPPORTER),
|
&ua.permissions.check(FinePermission::SUPPORTER),
|
||||||
);
|
);
|
||||||
|
ctx.insert(
|
||||||
|
"has_developer_pass",
|
||||||
|
&ua.secondary_permissions
|
||||||
|
.check(SecondaryPermission::DEVELOPER_PASS),
|
||||||
|
);
|
||||||
ctx.insert("home", &ua.settings.default_timeline.relative_url());
|
ctx.insert("home", &ua.settings.default_timeline.relative_url());
|
||||||
} else {
|
} else {
|
||||||
ctx.insert("is_helper", &false);
|
ctx.insert("is_helper", &false);
|
||||||
|
|
|
@ -9,7 +9,10 @@ mod sanitize;
|
||||||
|
|
||||||
use assets::{init_dirs, write_assets};
|
use assets::{init_dirs, write_assets};
|
||||||
use stripe::Client as StripeClient;
|
use stripe::Client as StripeClient;
|
||||||
use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji};
|
use tetratto_core::model::{
|
||||||
|
permissions::{FinePermission, SecondaryPermission},
|
||||||
|
uploads::CustomEmoji,
|
||||||
|
};
|
||||||
pub use tetratto_core::*;
|
pub use tetratto_core::*;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
@ -55,6 +58,15 @@ fn check_supporter(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Va
|
||||||
.into())
|
.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_dev_pass(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||||
|
Ok(
|
||||||
|
SecondaryPermission::from_bits(value.as_u64().unwrap() as u32)
|
||||||
|
.unwrap()
|
||||||
|
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn check_staff_badge(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
fn check_staff_badge(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||||
Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32)
|
Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
@ -109,6 +121,7 @@ async fn main() {
|
||||||
tera.register_filter("markdown", render_markdown);
|
tera.register_filter("markdown", render_markdown);
|
||||||
tera.register_filter("color", color_escape);
|
tera.register_filter("color", color_escape);
|
||||||
tera.register_filter("has_supporter", check_supporter);
|
tera.register_filter("has_supporter", check_supporter);
|
||||||
|
tera.register_filter("has_dev_pass", check_dev_pass);
|
||||||
tera.register_filter("has_staff_badge", check_staff_badge);
|
tera.register_filter("has_staff_badge", check_staff_badge);
|
||||||
tera.register_filter("has_banned", check_banned);
|
tera.register_filter("has_banned", check_banned);
|
||||||
tera.register_filter("remove_script_tags", remove_script_tags);
|
tera.register_filter("remove_script_tags", remove_script_tags);
|
||||||
|
|
|
@ -118,7 +118,7 @@
|
||||||
("class" "hidden lowered card w-full no_p_margin")
|
("class" "hidden lowered card w-full no_p_margin")
|
||||||
("ui_ident" "purchase_help")
|
("ui_ident" "purchase_help")
|
||||||
(b (text "What does \"Purchase account\" mean?"))
|
(b (text "What does \"Purchase account\" mean?"))
|
||||||
(p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}."))
|
(p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.price_texts.supporter }}."))
|
||||||
(p (text "Alternatively, you can provide an invite code to create your account for free.")))
|
(p (text "Alternatively, you can provide an invite code to create your account for free.")))
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
(button
|
(button
|
||||||
|
|
|
@ -1452,7 +1452,9 @@
|
||||||
});
|
});
|
||||||
})();"))
|
})();"))
|
||||||
|
|
||||||
(text "{%- endmacro %} {% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}")
|
(text "{%- endmacro %}")
|
||||||
|
|
||||||
|
(text "{% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}")
|
||||||
(div
|
(div
|
||||||
("class" "card w-full supporter_ad")
|
("class" "card w-full supporter_ad")
|
||||||
("ui_ident" "supporter_ad")
|
("ui_ident" "supporter_ad")
|
||||||
|
@ -1472,8 +1474,9 @@
|
||||||
(text "{{ icon \"heart\" }}")
|
(text "{{ icon \"heart\" }}")
|
||||||
(span
|
(span
|
||||||
(text "{{ text \"general:action.become_supporter\" }}")))))
|
(text "{{ text \"general:action.become_supporter\" }}")))))
|
||||||
|
(text "{%- endif %} {%- endmacro %}")
|
||||||
|
|
||||||
(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}")
|
(text "{% macro create_post_options() -%}")
|
||||||
(div
|
(div
|
||||||
("class" "flex gap-2 flex-wrap")
|
("class" "flex gap-2 flex-wrap")
|
||||||
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}")
|
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}")
|
||||||
|
@ -2358,10 +2361,6 @@
|
||||||
(text "Save infinite post drafts"))
|
(text "Save infinite post drafts"))
|
||||||
(li
|
(li
|
||||||
(text "Ability to search through all posts"))
|
(text "Ability to search through all posts"))
|
||||||
(li
|
|
||||||
(text "Ability to create forges"))
|
|
||||||
(li
|
|
||||||
(text "Create more than 1 app"))
|
|
||||||
(li
|
(li
|
||||||
(text "Create up to 10 stack blocks"))
|
(text "Create up to 10 stack blocks"))
|
||||||
(li
|
(li
|
||||||
|
@ -2388,15 +2387,13 @@
|
||||||
("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}")
|
("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}")
|
||||||
("class" "button")
|
("class" "button")
|
||||||
("target" "_blank")
|
("target" "_blank")
|
||||||
(text "Become a supporter ({{ config.stripe.supporter_price_text }})"))
|
(text "Become a supporter ({{ config.stripe.price_texts.supporter }})"))
|
||||||
(span
|
(span
|
||||||
("class" "fade")
|
("class" "fade")
|
||||||
(text "Please use your")
|
(text "Please use your")
|
||||||
(b
|
(b
|
||||||
(text " real email "))
|
(text " real email "))
|
||||||
(text "when
|
(text "when completing payment. It is required to manage your billing settings."))
|
||||||
completing payment. It is required to manage
|
|
||||||
your billing settings."))
|
|
||||||
|
|
||||||
(text "{% if config.security.enable_invite_codes -%}")
|
(text "{% if config.security.enable_invite_codes -%}")
|
||||||
(span
|
(span
|
||||||
|
@ -2405,3 +2402,46 @@
|
||||||
(b (text "1: ")) (text "After your account is at least 1 month old"))
|
(b (text "1: ")) (text "After your account is at least 1 month old"))
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
(text "{%- endmacro %}")
|
(text "{%- endmacro %}")
|
||||||
|
|
||||||
|
(text "{% macro get_developer_pass_button() -%}")
|
||||||
|
(p
|
||||||
|
(text "You currently do not hold a developer pass. With a developer pass, you'll get:"))
|
||||||
|
(ul
|
||||||
|
("style" "margin-bottom: var(--pad-4)")
|
||||||
|
(li
|
||||||
|
(text "Increased app storage limit (500 KB->5 MB)"))
|
||||||
|
(li
|
||||||
|
(text "Ability to create forges"))
|
||||||
|
(li
|
||||||
|
(text "Ability to create more than 1 app"))
|
||||||
|
(li
|
||||||
|
(text "Developer pass profile badge")))
|
||||||
|
(a
|
||||||
|
("href" "{{ config.stripe.payment_links.dev_pass }}?client_reference_id={{ user.id }}")
|
||||||
|
("class" "button")
|
||||||
|
("target" "_blank")
|
||||||
|
(text "Continue ({{ config.stripe.price_texts.dev_pass }})"))
|
||||||
|
(span
|
||||||
|
("class" "fade")
|
||||||
|
(text "Please use your")
|
||||||
|
(b
|
||||||
|
(text " real email "))
|
||||||
|
(text "when completing payment. It is required to manage your billing settings. If you're already a supporter, please use the same email you used there."))
|
||||||
|
(text "{%- endmacro %}")
|
||||||
|
|
||||||
|
(text "{% macro developer_pass_ad(body) -%} {% if config.stripe and not has_developer_pass %}")
|
||||||
|
(div
|
||||||
|
("class" "card w-full supporter_ad")
|
||||||
|
("ui_ident" "supporter_ad")
|
||||||
|
("onclick" "window.location.href = '/settings#/account/billing'")
|
||||||
|
(div
|
||||||
|
("class" "card w-full flex flex-wrap items-center gap-2 justify-between")
|
||||||
|
(b
|
||||||
|
(text "{{ body }}"))
|
||||||
|
(a
|
||||||
|
("href" "/settings#/account/billing")
|
||||||
|
("class" "button small")
|
||||||
|
(icon (text "arrow-right"))
|
||||||
|
(span
|
||||||
|
(str (text "dialog:action.continue"))))))
|
||||||
|
(text "{%- endif %} {%- endmacro %}")
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
(main
|
(main
|
||||||
("class" "flex flex-col gap-2")
|
("class" "flex flex-col gap-2")
|
||||||
; create new
|
; create new
|
||||||
(text "{% if user.permissions|has_supporter -%}")
|
(text "{% if user.secondary_permissions|has_dev_pass -%}")
|
||||||
(div
|
(div
|
||||||
("class" "card-nest")
|
("class" "card-nest")
|
||||||
(div
|
(div
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
(button
|
(button
|
||||||
(text "{{ text \"communities:action.create\" }}"))))
|
(text "{{ text \"communities:action.create\" }}"))))
|
||||||
(text "{% else %}")
|
(text "{% else %}")
|
||||||
(text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}")
|
(text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}")
|
||||||
(text "{%- endif %}")
|
(text "{%- endif %}")
|
||||||
|
|
||||||
; forge listing
|
; forge listing
|
||||||
|
|
|
@ -72,19 +72,25 @@
|
||||||
("style" "color: var(--color-primary);")
|
("style" "color: var(--color-primary);")
|
||||||
("class" "flex items-center")
|
("class" "flex items-center")
|
||||||
(text "{{ icon \"badge-check\" }}"))
|
(text "{{ icon \"badge-check\" }}"))
|
||||||
(text "{%- endif %} {% if profile.permissions|has_supporter -%}")
|
(text "{%- endif %} {% if profile.permissions|has_supporter -%}")
|
||||||
(span
|
(span
|
||||||
("title" "Supporter")
|
("title" "Supporter")
|
||||||
("style" "color: var(--color-primary);")
|
("style" "color: var(--color-primary);")
|
||||||
("class" "flex items-center")
|
("class" "flex items-center")
|
||||||
(text "{{ icon \"star\" }}"))
|
(text "{{ icon \"star\" }}"))
|
||||||
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
|
(text "{%- endif %} {% if profile.secondary_permissions|has_dev_pass -%}")
|
||||||
|
(span
|
||||||
|
("title" "Developer pass")
|
||||||
|
("style" "color: var(--color-primary);")
|
||||||
|
("class" "flex items-center")
|
||||||
|
(text "{{ icon \"id-card-lanyard\" }}"))
|
||||||
|
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
|
||||||
(span
|
(span
|
||||||
("title" "Staff")
|
("title" "Staff")
|
||||||
("style" "color: var(--color-primary);")
|
("style" "color: var(--color-primary);")
|
||||||
("class" "flex items-center")
|
("class" "flex items-center")
|
||||||
(text "{{ icon \"shield-user\" }}"))
|
(text "{{ icon \"shield-user\" }}"))
|
||||||
(text "{%- endif %} {% if profile.permissions|has_banned -%}")
|
(text "{%- endif %} {% if profile.permissions|has_banned -%}")
|
||||||
(span
|
(span
|
||||||
("title" "Banned")
|
("title" "Banned")
|
||||||
("style" "color: var(--color-primary);")
|
("style" "color: var(--color-primary);")
|
||||||
|
|
|
@ -823,6 +823,29 @@
|
||||||
(div
|
(div
|
||||||
("class" "card flex flex-col gap-2 secondary")
|
("class" "card flex flex-col gap-2 secondary")
|
||||||
(text "{% if config.stripe -%}")
|
(text "{% if config.stripe -%}")
|
||||||
|
(text "{% if has_developer_pass or is_supporter -%}")
|
||||||
|
(div
|
||||||
|
("class" "card-nest")
|
||||||
|
("ui_ident" "supporter_card")
|
||||||
|
(div
|
||||||
|
("class" "card small flex items-center gap-2")
|
||||||
|
(icon (text "credit-card"))
|
||||||
|
(b
|
||||||
|
(text "Manage billing")))
|
||||||
|
(div
|
||||||
|
("class" "card flex flex-col gap-2")
|
||||||
|
(p
|
||||||
|
(text "You currently have a subscription! You can manage your billing information below. ")
|
||||||
|
(b
|
||||||
|
(text "Please use your email address you supplied when paying to log into the billing portal."))
|
||||||
|
(text " You can manage all of your active subscriptions through this page."))
|
||||||
|
(a
|
||||||
|
("href" "{{ config.stripe.billing_portal_url }}")
|
||||||
|
("class" "button lowered")
|
||||||
|
("target" "_blank")
|
||||||
|
(text "Manage billing"))))
|
||||||
|
(text "{%- endif %}")
|
||||||
|
|
||||||
(div
|
(div
|
||||||
("class" "card-nest")
|
("class" "card-nest")
|
||||||
("ui_ident" "supporter_card")
|
("ui_ident" "supporter_card")
|
||||||
|
@ -832,28 +855,33 @@
|
||||||
(b
|
(b
|
||||||
(text "Supporter status")))
|
(text "Supporter status")))
|
||||||
(div
|
(div
|
||||||
("class" "card flex flex-col gap-2")
|
("class" "card flex flex-col gap-2 no_p_margin")
|
||||||
(text "{% if is_supporter -%}")
|
(text "{% if is_supporter -%}")
|
||||||
(p
|
(p
|
||||||
(text "You ")
|
(text "You ")
|
||||||
(b
|
(b (text "are "))
|
||||||
(text "are "))
|
(text "a supporter! Thank you for all that you do."))
|
||||||
(text "a supporter! Thank you for all
|
|
||||||
that you do. You can manage your billing
|
|
||||||
information below.")
|
|
||||||
(b
|
|
||||||
(text "Please use your email address you supplied
|
|
||||||
when paying to login to the billing
|
|
||||||
portal.")))
|
|
||||||
(a
|
|
||||||
("href" "{{ config.stripe.billing_portal_url }}")
|
|
||||||
("class" "button lowered")
|
|
||||||
("target" "_blank")
|
|
||||||
(text "Manage billing"))
|
|
||||||
(text "{% else %}")
|
(text "{% else %}")
|
||||||
(text "{{ components::become_supporter_button() }}")
|
(text "{{ components::become_supporter_button() }}")
|
||||||
(text "{%- endif %}")))
|
(text "{%- endif %}")))
|
||||||
|
|
||||||
|
(div
|
||||||
|
("class" "card-nest")
|
||||||
|
("ui_ident" "supporter_card")
|
||||||
|
(div
|
||||||
|
("class" "card small flex items-center gap-2")
|
||||||
|
(icon (text "id-card-lanyard"))
|
||||||
|
(b
|
||||||
|
(text "Developer pass status")))
|
||||||
|
(div
|
||||||
|
("class" "card flex flex-col gap-2 no_p_margin")
|
||||||
|
(text "{% if has_developer_pass -%}")
|
||||||
|
(p
|
||||||
|
(text "You currently have a developer pass!"))
|
||||||
|
(text "{% else %}")
|
||||||
|
(text "{{ components::get_developer_pass_button() }}")
|
||||||
|
(text "{%- endif %}")))
|
||||||
|
|
||||||
(text "{% if user.was_purchased and user.invite_code == 0 -%}")
|
(text "{% if user.was_purchased and user.invite_code == 0 -%}")
|
||||||
(form
|
(form
|
||||||
("class" "card w-full lowered flex flex-col gap-2")
|
("class" "card w-full lowered flex flex-col gap-2")
|
||||||
|
|
|
@ -3,9 +3,9 @@ use std::{str::FromStr, time::Duration};
|
||||||
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
|
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
use tetratto_core::model::{
|
use tetratto_core::model::{
|
||||||
auth::{User, Notification},
|
auth::{Notification, User},
|
||||||
moderation::AuditLogEntry,
|
moderation::AuditLogEntry,
|
||||||
permissions::FinePermission,
|
permissions::{FinePermission, SecondaryPermission},
|
||||||
ApiReturn, Error,
|
ApiReturn, Error,
|
||||||
};
|
};
|
||||||
use stripe::{EventObject, EventType};
|
use stripe::{EventObject, EventType};
|
||||||
|
@ -205,6 +205,43 @@ pub async fn stripe_webhook(
|
||||||
{
|
{
|
||||||
return Json(e.into());
|
return Json(e.into());
|
||||||
}
|
}
|
||||||
|
} else if product_id == stripe_cnf.product_ids.dev_pass {
|
||||||
|
// dev pass
|
||||||
|
tracing::info!("found subscription user in {retries} tries");
|
||||||
|
|
||||||
|
if user
|
||||||
|
.secondary_permissions
|
||||||
|
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||||
|
{
|
||||||
|
return Json(ApiReturn {
|
||||||
|
ok: true,
|
||||||
|
message: "Already applied".to_string(),
|
||||||
|
payload: (),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("invoice {} (stripe: {})", user.id, customer_id);
|
||||||
|
let new_user_permissions =
|
||||||
|
user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS;
|
||||||
|
|
||||||
|
if let Err(e) = data
|
||||||
|
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = data
|
||||||
|
.create_notification(Notification::new(
|
||||||
|
"Welcome new developer!".to_string(),
|
||||||
|
"Thank you for your support! Your account has been updated with your new role."
|
||||||
|
.to_string(),
|
||||||
|
user.id,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"received an invalid stripe product id, please check config.stripe.product_ids"
|
"received an invalid stripe product id, please check config.stripe.product_ids"
|
||||||
|
@ -220,34 +257,72 @@ pub async fn stripe_webhook(
|
||||||
};
|
};
|
||||||
|
|
||||||
let customer_id = subscription.customer.id();
|
let customer_id = subscription.customer.id();
|
||||||
|
let product_id = subscription
|
||||||
|
.items
|
||||||
|
.data
|
||||||
|
.get(0)
|
||||||
|
.as_ref()
|
||||||
|
.expect("cancelled nothing?")
|
||||||
|
.plan
|
||||||
|
.as_ref()
|
||||||
|
.expect("no subscription plan?")
|
||||||
|
.product
|
||||||
|
.as_ref()
|
||||||
|
.expect("plan with no product?")
|
||||||
|
.id()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
||||||
Ok(ua) => ua,
|
Ok(ua) => ua,
|
||||||
Err(e) => return Json(e.into()),
|
Err(e) => return Json(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
|
// handle each subscription item
|
||||||
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
if product_id == stripe_cnf.product_ids.supporter {
|
||||||
|
// supporter
|
||||||
|
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
|
||||||
|
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
||||||
|
|
||||||
if let Err(e) = data
|
|
||||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Json(e.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0
|
|
||||||
{
|
|
||||||
// user doesn't come from an invite code, and is a purchased account
|
|
||||||
// this means their account must be locked if they stop paying
|
|
||||||
if let Err(e) = data
|
if let Err(e) = data
|
||||||
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
|
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Json(e.into());
|
return Json(e.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.0.0.security.enable_invite_codes
|
||||||
|
&& user.was_purchased
|
||||||
|
&& user.invite_code == 0
|
||||||
|
{
|
||||||
|
// user doesn't come from an invite code, and is a purchased account
|
||||||
|
// this means their account must be locked if they stop paying
|
||||||
|
if let Err(e) = data
|
||||||
|
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if product_id == stripe_cnf.product_ids.dev_pass {
|
||||||
|
// dev pass
|
||||||
|
tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id);
|
||||||
|
let new_user_permissions =
|
||||||
|
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
|
||||||
|
|
||||||
|
if let Err(e) = data
|
||||||
|
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
"received an invalid stripe product id, please check config.stripe.product_ids"
|
||||||
|
);
|
||||||
|
return Json(Error::MiscError("Unknown product ID".to_string()).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// send notification
|
||||||
if let Err(e) = data
|
if let Err(e) = data
|
||||||
.create_notification(Notification::new(
|
.create_notification(Notification::new(
|
||||||
"Sorry to see you go... :(".to_string(),
|
"Sorry to see you go... :(".to_string(),
|
||||||
|
@ -269,46 +344,112 @@ pub async fn stripe_webhook(
|
||||||
|
|
||||||
let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id();
|
let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id();
|
||||||
|
|
||||||
|
let item = match invoice.lines.as_ref().expect("no line items?").data.get(0) {
|
||||||
|
Some(i) => i,
|
||||||
|
None => {
|
||||||
|
if let Err(e) = data
|
||||||
|
.create_audit_log_entry(AuditLogEntry::new(
|
||||||
|
0,
|
||||||
|
format!("too few invoice line items: stripe {customer_id}"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(Error::MiscError("Too few line items".to_string()).into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let product_id = item
|
||||||
|
.price
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.product
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.id()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
||||||
Ok(ua) => ua,
|
Ok(ua) => ua,
|
||||||
Err(e) => return Json(e.into()),
|
Err(e) => return Json(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !user.permissions.check(FinePermission::SUPPORTER) {
|
// handle each subscription item
|
||||||
// the user isn't currently a supporter, there's no reason to send this notification
|
if product_id == stripe_cnf.product_ids.supporter {
|
||||||
return Json(ApiReturn {
|
// supporter
|
||||||
ok: true,
|
if !user.permissions.check(FinePermission::SUPPORTER) {
|
||||||
message: "Acceptable".to_string(),
|
// the user isn't currently a supporter, there's no reason to send this notification
|
||||||
payload: (),
|
return Json(ApiReturn {
|
||||||
});
|
ok: true,
|
||||||
}
|
message: "Acceptable".to_string(),
|
||||||
|
payload: (),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"unsubscribe (pay fail) {} (stripe: {})",
|
"unsubscribe (pay fail) {} (stripe: {})",
|
||||||
user.id,
|
user.id,
|
||||||
customer_id
|
customer_id
|
||||||
);
|
);
|
||||||
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
||||||
|
|
||||||
if let Err(e) = data
|
|
||||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Json(e.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0
|
|
||||||
{
|
|
||||||
// user doesn't come from an invite code, and is a purchased account
|
|
||||||
// this means their account must be locked if they stop paying
|
|
||||||
if let Err(e) = data
|
if let Err(e) = data
|
||||||
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
|
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Json(e.into());
|
return Json(e.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.0.0.security.enable_invite_codes
|
||||||
|
&& user.was_purchased
|
||||||
|
&& user.invite_code == 0
|
||||||
|
{
|
||||||
|
// user doesn't come from an invite code, and is a purchased account
|
||||||
|
// this means their account must be locked if they stop paying
|
||||||
|
if let Err(e) = data
|
||||||
|
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if product_id == stripe_cnf.product_ids.dev_pass {
|
||||||
|
// dev pass
|
||||||
|
if !user
|
||||||
|
.secondary_permissions
|
||||||
|
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||||
|
{
|
||||||
|
return Json(ApiReturn {
|
||||||
|
ok: true,
|
||||||
|
message: "Acceptable".to_string(),
|
||||||
|
payload: (),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"unsubscribe (pay fail) {} (stripe: {})",
|
||||||
|
user.id,
|
||||||
|
customer_id
|
||||||
|
);
|
||||||
|
let new_user_permissions =
|
||||||
|
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
|
||||||
|
|
||||||
|
if let Err(e) = data
|
||||||
|
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Json(e.into());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
"received an invalid stripe product id, please check config.stripe.product_ids"
|
||||||
|
);
|
||||||
|
return Json(Error::MiscError("Unknown product ID".to_string()).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// send notification
|
||||||
if let Err(e) = data
|
if let Err(e) = data
|
||||||
.create_notification(Notification::new(
|
.create_notification(Notification::new(
|
||||||
"It seems your recent payment has failed :(".to_string(),
|
"It seems your recent payment has failed :(".to_string(),
|
||||||
|
|
|
@ -194,22 +194,30 @@ pub struct StripeConfig {
|
||||||
///
|
///
|
||||||
/// <https://docs.stripe.com/no-code/customer-portal>
|
/// <https://docs.stripe.com/no-code/customer-portal>
|
||||||
pub billing_portal_url: String,
|
pub billing_portal_url: String,
|
||||||
/// The text representation of the price of supporter. (like `$4 USD`)
|
/// The text representation of prices. (like `$4 USD`)
|
||||||
pub supporter_price_text: String,
|
pub price_texts: StripePriceTexts,
|
||||||
/// Product IDs from the Stripe dashboard.
|
/// Product IDs from the Stripe dashboard.
|
||||||
///
|
///
|
||||||
/// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
||||||
|
pub struct StripePriceTexts {
|
||||||
|
pub supporter: String,
|
||||||
|
pub dev_pass: 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manuals config (search help, etc)
|
/// Manuals config (search help, etc)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use oiseau::cache::Cache;
|
use oiseau::cache::Cache;
|
||||||
use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery};
|
use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode};
|
||||||
use crate::model::{apps::AppData, permissions::FinePermission, Error, Result};
|
use crate::model::{apps::AppData, permissions::FinePermission, Error, Result};
|
||||||
use crate::{auto_method, DataManager};
|
use crate::{auto_method, DataManager};
|
||||||
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params};
|
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params};
|
||||||
|
@ -51,13 +51,7 @@ impl DataManager {
|
||||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let query_str = query.to_string().replace(
|
let query_str = query.to_string().replace("%q%", &query.query.selector());
|
||||||
"%q%",
|
|
||||||
&match query.query {
|
|
||||||
AppDataSelectQuery::KeyIs(_) => format!("k = $1"),
|
|
||||||
AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let res = match query.mode {
|
let res = match query.mode {
|
||||||
AppDataSelectMode::One(_) => AppDataQueryResult::One(
|
AppDataSelectMode::One(_) => AppDataQueryResult::One(
|
||||||
|
@ -98,13 +92,7 @@ impl DataManager {
|
||||||
|
|
||||||
let query_str = query
|
let query_str = query
|
||||||
.to_string()
|
.to_string()
|
||||||
.replace(
|
.replace("%q%", &query.query.selector())
|
||||||
"%q%",
|
|
||||||
&match query.query {
|
|
||||||
AppDataSelectQuery::KeyIs(_) => format!("k = $1"),
|
|
||||||
AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.replace("SELECT * FROM", "SELECT id FROM");
|
.replace("SELECT * FROM", "SELECT id FROM");
|
||||||
|
|
||||||
if let Err(e) = execute!(
|
if let Err(e) = execute!(
|
||||||
|
|
|
@ -3,7 +3,7 @@ use crate::model::{
|
||||||
apps::{AppQuota, ThirdPartyApp},
|
apps::{AppQuota, ThirdPartyApp},
|
||||||
auth::User,
|
auth::User,
|
||||||
oauth::AppScope,
|
oauth::AppScope,
|
||||||
permissions::FinePermission,
|
permissions::{FinePermission, SecondaryPermission},
|
||||||
Error, Result,
|
Error, Result,
|
||||||
};
|
};
|
||||||
use crate::{auto_method, DataManager};
|
use crate::{auto_method, DataManager};
|
||||||
|
@ -72,10 +72,15 @@ impl DataManager {
|
||||||
// check number of apps
|
// check number of apps
|
||||||
let owner = self.get_user_by_id(data.owner).await?;
|
let owner = self.get_user_by_id(data.owner).await?;
|
||||||
|
|
||||||
if !owner.permissions.check(FinePermission::SUPPORTER) {
|
if !owner
|
||||||
let apps = self.get_apps_by_owner(data.owner).await?;
|
.secondary_permissions
|
||||||
|
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||||
|
{
|
||||||
|
let apps = self
|
||||||
|
.get_table_row_count_where("apps", &format!("owner = {}", owner.id))
|
||||||
|
.await? as usize;
|
||||||
|
|
||||||
if apps.len() >= Self::MAXIMUM_FREE_APPS {
|
if apps >= Self::MAXIMUM_FREE_APPS {
|
||||||
return Err(Error::MiscError(
|
return Err(Error::MiscError(
|
||||||
"You already have the maximum number of apps you can have".to_string(),
|
"You already have the maximum number of apps you can have".to_string(),
|
||||||
));
|
));
|
||||||
|
|
|
@ -85,7 +85,7 @@ impl DataManager {
|
||||||
|
|
||||||
let res = query_row!(
|
let res = query_row!(
|
||||||
&conn,
|
&conn,
|
||||||
&format!("SELECT COUNT(*)::int FROM {} {}", table, r#where),
|
&format!("SELECT COUNT(*)::int FROM {} WHERE {}", table, r#where),
|
||||||
params![],
|
params![],
|
||||||
|x| Ok(x.get::<usize, i32>(0))
|
|x| Ok(x.get::<usize, i32>(0))
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,6 +3,7 @@ use super::common::NAME_REGEX;
|
||||||
use oiseau::cache::Cache;
|
use oiseau::cache::Cache;
|
||||||
use crate::model::communities::{CommunityContext, CommunityJoinAccess, CommunityMembership};
|
use crate::model::communities::{CommunityContext, CommunityJoinAccess, CommunityMembership};
|
||||||
use crate::model::communities_permissions::CommunityPermission;
|
use crate::model::communities_permissions::CommunityPermission;
|
||||||
|
use crate::model::permissions::SecondaryPermission;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Error, Result,
|
Error, Result,
|
||||||
auth::User,
|
auth::User,
|
||||||
|
@ -255,7 +256,11 @@ impl DataManager {
|
||||||
|
|
||||||
// check is_forge
|
// check is_forge
|
||||||
// only supporters can CREATE forge communities... anybody can contribute to them
|
// only supporters can CREATE forge communities... anybody can contribute to them
|
||||||
if data.is_forge && !owner.permissions.check(FinePermission::SUPPORTER) {
|
if data.is_forge
|
||||||
|
&& !owner
|
||||||
|
.secondary_permissions
|
||||||
|
.check(SecondaryPermission::DEVELOPER_PASS)
|
||||||
|
{
|
||||||
return Err(Error::RequiresSupporter);
|
return Err(Error::RequiresSupporter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -147,6 +147,8 @@ impl AppData {
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub enum AppDataSelectQuery {
|
pub enum AppDataSelectQuery {
|
||||||
KeyIs(String),
|
KeyIs(String),
|
||||||
|
KeyLike(String),
|
||||||
|
ValueLike(String),
|
||||||
LikeJson(String, String),
|
LikeJson(String, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,11 +156,24 @@ impl Display for AppDataSelectQuery {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str(&match self {
|
f.write_str(&match self {
|
||||||
Self::KeyIs(k) => k.to_owned(),
|
Self::KeyIs(k) => k.to_owned(),
|
||||||
|
Self::KeyLike(k) => k.to_owned(),
|
||||||
|
Self::ValueLike(v) => v.to_owned(),
|
||||||
Self::LikeJson(k, v) => format!("%\"{k}\":\"{v}\"%"),
|
Self::LikeJson(k, v) => format!("%\"{k}\":\"{v}\"%"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AppDataSelectQuery {
|
||||||
|
pub fn selector(&self) -> String {
|
||||||
|
match self {
|
||||||
|
AppDataSelectQuery::KeyIs(_) => format!("k = $1"),
|
||||||
|
AppDataSelectQuery::KeyLike(_) => format!("k LIKE $1"),
|
||||||
|
AppDataSelectQuery::ValueLike(_) => format!("v LIKE $1"),
|
||||||
|
AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub enum AppDataSelectMode {
|
pub enum AppDataSelectMode {
|
||||||
/// Select a single row (with offset).
|
/// Select a single row (with offset).
|
||||||
|
@ -179,14 +194,14 @@ impl Display for AppDataSelectMode {
|
||||||
Self::One(offset) => format!("LIMIT 1 OFFSET {offset}"),
|
Self::One(offset) => format!("LIMIT 1 OFFSET {offset}"),
|
||||||
Self::Many(limit, offset) => {
|
Self::Many(limit, offset) => {
|
||||||
format!(
|
format!(
|
||||||
"LIMIT {} OFFSET {offset}",
|
"ORDER BY k DESC LIMIT {} OFFSET {offset}",
|
||||||
if *limit > 1024 { 1024 } else { *limit }
|
if *limit > 24 { 24 } else { *limit }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::ManyJson(order_by_top_level_key, limit, offset) => {
|
Self::ManyJson(order_by_top_level_key, limit, offset) => {
|
||||||
format!(
|
format!(
|
||||||
"ORDER BY v::jsonb->>'{order_by_top_level_key}' DESC LIMIT {} OFFSET {offset}",
|
"ORDER BY v::jsonb->>'{order_by_top_level_key}' DESC LIMIT {} OFFSET {offset}",
|
||||||
if *limit > 1024 { 1024 } else { *limit }
|
if *limit > 24 { 24 } else { *limit }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue