add: stripe integration
This commit is contained in:
parent
2fa5a4dc1f
commit
1d120555a0
31 changed files with 1137 additions and 122 deletions
|
@ -40,3 +40,10 @@ redis = { version = "0.30.0", features = [
|
|||
"tokio-comp",
|
||||
], optional = true }
|
||||
tower_governor = "0.7.0"
|
||||
async-stripe = { version = "0.41.0", features = [
|
||||
"events",
|
||||
"checkout",
|
||||
"webhook-events",
|
||||
"billing",
|
||||
"runtime-tokio-hyper",
|
||||
] }
|
||||
|
|
|
@ -8,7 +8,10 @@ use std::{
|
|||
use tera::Context;
|
||||
use tetratto_core::{
|
||||
config::Config,
|
||||
model::auth::{DefaultTimelineChoice, User},
|
||||
model::{
|
||||
auth::{DefaultTimelineChoice, User},
|
||||
permissions::FinePermission,
|
||||
},
|
||||
PUBSUB_ENABLED,
|
||||
};
|
||||
use tetratto_l10n::LangFile;
|
||||
|
@ -36,7 +39,6 @@ pub const ROOT: &str = include_str!("./public/html/root.html");
|
|||
pub const MACROS: &str = include_str!("./public/html/macros.html");
|
||||
pub const COMPONENTS: &str = include_str!("./public/html/components.html");
|
||||
|
||||
pub const MISC_INDEX: &str = include_str!("./public/html/misc/index.html");
|
||||
pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.html");
|
||||
pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.html");
|
||||
pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.html");
|
||||
|
@ -232,8 +234,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"macros.html"(crate::assets::MACROS) --config=config);
|
||||
write_template!(html_path->"components.html"(crate::assets::COMPONENTS) --config=config);
|
||||
|
||||
write_template!(html_path->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc" --config=config);
|
||||
write_template!(html_path->"misc/error.html"(crate::assets::MISC_ERROR) --config=config);
|
||||
write_template!(html_path->"misc/error.html"(crate::assets::MISC_ERROR) -d "misc" --config=config);
|
||||
write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config);
|
||||
write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config);
|
||||
write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config);
|
||||
|
@ -312,6 +313,9 @@ pub(crate) async fn init_dirs(config: &Config) {
|
|||
create_dir_if_not_exists!(
|
||||
&PathBufD::current().extend(&[config.dirs.media.as_str(), "community_banners"])
|
||||
);
|
||||
create_dir_if_not_exists!(
|
||||
&PathBufD::current().extend(&[config.dirs.media.as_str(), "uploads"])
|
||||
);
|
||||
|
||||
write_if_track!(images_path->"default-avatar.svg"(DEFAULT_AVATAR) --config=config);
|
||||
write_if_track!(images_path->"default-banner.svg"(DEFAULT_BANNER) --config=config);
|
||||
|
@ -346,6 +350,10 @@ pub(crate) async fn initial_context(
|
|||
if let Some(ua) = user {
|
||||
ctx.insert("is_helper", &ua.permissions.check_helper());
|
||||
ctx.insert("is_manager", &ua.permissions.check_manager());
|
||||
ctx.insert(
|
||||
"is_supporter",
|
||||
&ua.permissions.check(FinePermission::SUPPORTER),
|
||||
);
|
||||
ctx.insert("home", ua.settings.default_timeline.relative_url());
|
||||
} else {
|
||||
ctx.insert("is_helper", &false);
|
||||
|
|
|
@ -31,6 +31,7 @@ version = "1.0.0"
|
|||
"general:label.file_report" = "File report"
|
||||
"general:label.account_banned" = "Account banned"
|
||||
"general:label.account_banned_body" = "Your account has been banned for violating our policies."
|
||||
"general:label.better_with_account" = "It's better with an account! Login or sign up to explore more."
|
||||
|
||||
"dialog:action.okay" = "Ok"
|
||||
"dialog:action.continue" = "Continue"
|
||||
|
|
|
@ -559,6 +559,9 @@
|
|||
MANAGE_PINS: 1 << 7,
|
||||
MANAGE_COMMUNITY: 1 << 8,
|
||||
MANAGE_QUESTIONS: 1 << 9,
|
||||
MANAGE_CHANNELS: 1 << 10,
|
||||
MANAGE_MESSAGES: 1 << 11,
|
||||
MANAGE_EMOJIS: 1 << 12,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
|
|
@ -119,10 +119,17 @@
|
|||
</nav>
|
||||
{%- endmacro %} {% macro timelines_nav(selected="") -%}
|
||||
<div class="pillmenu w-full">
|
||||
{% if user %}
|
||||
<a href="/" class="{% if selected == 'home' %}active{% endif %}">
|
||||
{{ icon "newspaper" }}
|
||||
<span>{{ text "general:link.home" }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/" class="{% if selected == 'all' %}active{% endif %}">
|
||||
{{ icon "earth" }}
|
||||
<span>{{ text "general:link.all" }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="/popular" class="{% if selected == 'popular' %}active{% endif %}">
|
||||
{{ icon "trending-up" }}
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
{% extends "root.html" %} {% block head %}
|
||||
<title>{{ config.name }}</title>
|
||||
{% endblock %} {% block body %}
|
||||
<main class="flex justify-center" style="padding-top: 2rem">
|
||||
<div class="flex flex-col gap-2" style="width: 20rem; max-width: 100%">
|
||||
<h1 class="w-full text-center">{{ config.name }}</h1>
|
||||
|
||||
<div class="card flex flex-col gap-2">
|
||||
<a href="/auth/login" class="button w-full">
|
||||
{{ icon "log-in" }}
|
||||
<span>{{ text "auth:action.login" }}</span>
|
||||
</a>
|
||||
|
||||
<a href="/auth/register" class="button w-full secondary">
|
||||
{{ icon "heart" }}
|
||||
<span>{{ text "auth:action.register" }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="/popular" class="button w-full quaternary">
|
||||
{{ icon "trending-up" }}
|
||||
<span>Explore popular feed</span>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
|
@ -232,6 +232,10 @@
|
|||
SUPPORTER: 1 << 19,
|
||||
MANAGE_REQUESTS: 1 << 20,
|
||||
MANAGE_QUESTIONS: 1 << 21,
|
||||
MANAGE_CHANNELS: 1 << 22,
|
||||
MANAGE_MESSAGES: 1 << 23,
|
||||
MANAGE_UPLOADS: 1 << 24,
|
||||
MANAGE_EMOJIS: 1 << 25,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
|
|
@ -38,6 +38,34 @@
|
|||
|
||||
<div class="w-full flex flex-col gap-2" data-tab="account">
|
||||
<div class="card tertiary flex flex-col gap-2" id="account_settings">
|
||||
{% if config.stripe %}
|
||||
<div class="card-nest" ui_ident="supporter_card">
|
||||
<div class="card small flex items-center gap-2">
|
||||
{{ icon "star" }}
|
||||
<b>Supporter status</b>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if is_supporter %}
|
||||
<p>You <b>are</b> a supporter! Thank you for all that you do. You can manage your billing information below. <b>Please use your email address you supplied when paying to login to the billing portal.</b></p>
|
||||
<a href="{{ config.stripe.billing_portal_url }}" class="button quaternary" target="_blank">Manage billing</a>
|
||||
{% else %}
|
||||
<p>You're <b>not</b> currently a supporter! No pressure, but it helps us do some pretty cool things! As a supporter, you'll get:</p>
|
||||
|
||||
<ul style="margin-bottom: 1rem">
|
||||
<li>Vanity badge on profile</li>
|
||||
<li>Ability to upload gif avatars/banners</li>
|
||||
<li>Ability to use community emojis outside of their community <b>(soon)</b></li>
|
||||
<li>Ability to upload and use gif emojis <b>(soon)</b></li>
|
||||
</ul>
|
||||
|
||||
<a href="{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}" class="button" target="_blank">Become a supporter</a>
|
||||
<span class="fade">Please use your <b>real email</b> when completing payment. It is required to manage your billing settings.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-nest" ui_ident="home_timeline">
|
||||
<div class="card small">
|
||||
<b>Home timeline</b>
|
||||
|
@ -894,6 +922,7 @@
|
|||
const theme_settings = document.getElementById("theme_settings");
|
||||
|
||||
ui.refresh_container(account_settings, [
|
||||
"supporter_card",
|
||||
"home_timeline",
|
||||
"notifications",
|
||||
"change_password",
|
||||
|
|
|
@ -4,6 +4,26 @@
|
|||
<main class="flex flex-col gap-2">
|
||||
{{ macros::timelines_nav(selected="all") }} {{
|
||||
macros::timelines_secondary_nav(posts="/all", questions="/all/questions") }}
|
||||
{% if not user %}
|
||||
<div class="card-nest">
|
||||
<div class="card small flex items-center gap-2">
|
||||
{{ icon "heart" }}
|
||||
<b>{{ text "general:label.better_with_account" }}</b>
|
||||
</div>
|
||||
|
||||
<div class="card flex gap-2">
|
||||
<a href="/auth/login" class="button">
|
||||
{{ icon "log-in" }}
|
||||
<span>{{ text "auth:action.login" }}</span>
|
||||
</a>
|
||||
|
||||
<a href="/auth/register" class="button secondary">
|
||||
{{ icon "user-plus" }}
|
||||
<span>{{ text "auth:action.register" }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<div class="card w-full flex flex-col gap-2">
|
||||
|
|
|
@ -757,7 +757,14 @@ media_theme_pref();
|
|||
// link filter
|
||||
self.define("link_filter", (_) => {
|
||||
for (const anchor of Array.from(document.querySelectorAll("a"))) {
|
||||
if (anchor.href.length === 0) {
|
||||
if (
|
||||
anchor.href.length === 0 ||
|
||||
anchor.href.startsWith("https://github.com") ||
|
||||
anchor.href.startsWith("https://trisua.com") ||
|
||||
anchor.href.startsWith("https://buy.stripe.com") ||
|
||||
anchor.href.startsWith("https://billing.stripe.com") ||
|
||||
anchor.href.startsWith("https://last.fm")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
pub mod last_fm;
|
||||
pub mod spotify;
|
||||
pub mod stripe;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
148
crates/app/src/routes/api/v1/auth/connections/stripe.rs
Normal file
148
crates/app/src/routes/api/v1/auth/connections/stripe.rs
Normal file
|
@ -0,0 +1,148 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
|
||||
use tetratto_core::model::{auth::Notification, permissions::FinePermission, ApiReturn, Error};
|
||||
use stripe::{EventObject, EventType};
|
||||
use crate::State;
|
||||
|
||||
pub async fn stripe_webhook(
|
||||
Extension(data): Extension<State>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
||||
if data.0.stripe.is_none() {
|
||||
return Json(Error::MiscError("Disabled".to_string()).into());
|
||||
}
|
||||
|
||||
let sig = match headers.get("Stripe-Signature") {
|
||||
Some(s) => s,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let req = match stripe::Webhook::construct_event(
|
||||
&body,
|
||||
&sig.to_str().unwrap(),
|
||||
&data.0.stripe.as_ref().unwrap().webhook_signing_secret,
|
||||
) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
match req.type_ {
|
||||
EventType::CheckoutSessionCompleted => {
|
||||
// checkout session ended, store user customer id (from stripe) in their user data
|
||||
let session = match req.data.object {
|
||||
EventObject::CheckoutSession(c) => c,
|
||||
_ => unreachable!("cannot be this"),
|
||||
};
|
||||
|
||||
let user_id = session
|
||||
.client_reference_id
|
||||
.unwrap()
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
|
||||
let customer_id = session.customer.unwrap().id();
|
||||
|
||||
let user = match data.get_user_by_id(user_id).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
tracing::info!("subscribe {} (stripe: {})", user.id, customer_id);
|
||||
if let Err(e) = data
|
||||
.update_user_stripe_id(user.id, customer_id.as_str())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
EventType::InvoicePaymentSucceeded => {
|
||||
// payment finished and subscription created
|
||||
// we're doing this *instead* of CustomerSubscriptionDeleted because
|
||||
// the invoice happens *after* the checkout session ends... which is what we need
|
||||
let invoice = match req.data.object {
|
||||
EventObject::Invoice(c) => c,
|
||||
_ => unreachable!("cannot be this"),
|
||||
};
|
||||
|
||||
let customer_id = invoice.customer.unwrap().id();
|
||||
|
||||
// allow 10s for everything to finalize
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
|
||||
// pull user and update role
|
||||
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
tracing::info!("invoice {} (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 let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Welcome new supporter!".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());
|
||||
}
|
||||
}
|
||||
EventType::CustomerSubscriptionDeleted => {
|
||||
// payment failed and subscription deleted
|
||||
let subscription = match req.data.object {
|
||||
EventObject::Subscription(c) => c,
|
||||
_ => unreachable!("cannot be this"),
|
||||
};
|
||||
|
||||
let customer_id = subscription.customer.id();
|
||||
|
||||
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
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 let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Sorry to see you go... :(".to_string(),
|
||||
"Thank you for your past support! Please feel free to leave us feedback on why you decided to cancel."
|
||||
.to_string(),
|
||||
user.id,
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
_ => return Json(Error::Unknown.into()),
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
|
@ -247,7 +247,7 @@ pub async fn update_user_role_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_user_role(id, req.role, user).await {
|
||||
match data.update_user_role(id, req.role, user, false).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User updated".to_string(),
|
||||
|
|
|
@ -281,6 +281,10 @@ pub fn routes() -> Router {
|
|||
"/auth/user/connections/last_fm/api_proxy",
|
||||
post(auth::connections::last_fm::proxy_request),
|
||||
)
|
||||
.route(
|
||||
"/service_hooks/stripe",
|
||||
post(auth::connections::stripe::stripe_webhook),
|
||||
)
|
||||
// channels
|
||||
.route("/channels", post(channels::channels::create_request))
|
||||
.route(
|
||||
|
|
|
@ -35,9 +35,22 @@ pub async fn index_request(
|
|||
Some(ua) => ua,
|
||||
None => {
|
||||
return {
|
||||
// all timeline for unauthenticated users
|
||||
// i'm only changing this for stripe
|
||||
let list = match data.0.get_latest_posts(12, req.page).await {
|
||||
Ok(l) => match data.0.fill_posts_with_community(l, 0, &Vec::new()).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &None).await),
|
||||
},
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &None).await),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let context = initial_context(&data.0.0, lang, &None).await;
|
||||
Html(data.1.render("misc/index.html", &context).unwrap())
|
||||
let mut context = initial_context(&data.0.0, lang, &None).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
Html(data.1.render("timelines/all.html", &context).unwrap())
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue