feat(admin): remove the unfinished billing APIs and panel UI (#2248)

This commit is contained in:
Hampus
2026-08-31 15:57:11 +02:00
committed by GitHub
parent e82e8529bf
commit 9025e03422
21 changed files with 6 additions and 4562 deletions
File diff suppressed because it is too large Load Diff
-6
View File
@@ -41,9 +41,6 @@ pub const BAN_AVATAR_HASH_REMOVE: &str = "ban:avatar_hash:remove";
pub const BAN_PROFILE_SUBSTRING_ADD: &str = "ban:profile_substring:add";
pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
pub const BILLING_MANAGE_SUBSCRIPTION: &str = "billing:manage_subscription";
pub const BILLING_REFUND: &str = "billing:refund";
pub const BILLING_VIEW: &str = "billing:view";
pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
@@ -155,9 +152,6 @@ pub const ALL_ACLS: &[&str] = &[
BAN_PROFILE_SUBSTRING_ADD,
BAN_PROFILE_SUBSTRING_CHECK,
BAN_PROFILE_SUBSTRING_REMOVE,
BILLING_MANAGE_SUBSCRIPTION,
BILLING_REFUND,
BILLING_VIEW,
BULK_ADD_GUILD_MEMBERS,
BULK_DELETE_USERS,
BULK_UPDATE_GUILD_FEATURES,
-154
View File
@@ -1,154 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
BillingOverview, InvoiceListResponse, PaymentListResponse, PaymentMethodListResponse,
RefundCancelResponse, SubscriptionResponse,
};
impl AdminApiClient {
pub async fn get_billing_overview(&self, user_id: &str) -> ApiResult<BillingOverview> {
let response = self
.generated()
.admin_billing_overview(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payments(&self, user_id: &str) -> ApiResult<PaymentListResponse> {
let response = self
.generated()
.admin_billing_list_payments(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_subscription(&self, user_id: &str) -> ApiResult<SubscriptionResponse> {
let response = self
.generated()
.admin_billing_get_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payment_methods(
&self,
user_id: &str,
) -> ApiResult<PaymentMethodListResponse> {
let response = self
.generated()
.admin_billing_list_payment_methods(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_invoices(
&self,
user_id: &str,
limit: u32,
starting_after: Option<&str>,
) -> ApiResult<InvoiceListResponse> {
let limit_str = limit.to_string();
let mut params: Vec<(&str, &str)> = vec![("limit", &limit_str)];
if let Some(sa) = starting_after {
params.push(("starting_after", sa));
}
self.get(
&format!("/admin/billing/users/{user_id}/invoices"),
Some(&params),
)
.await
}
pub async fn issue_refund(
&self,
user_id: &str,
payment_intent_id: &str,
amount_cents: Option<u64>,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingRefundRequest {
amount_cents: amount_cents
.map(|value| crate::api::generated::nonzero_u64(value, "amount_cents"))
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
payment_intent_id: payment_intent_id.to_owned(),
reason: reason
.map(generated_types::AdminBillingRefundRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_refund(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn refund_policy_cancel_now(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<RefundCancelResponse> {
let body = generated_types::AdminBillingRefundLatestInvoiceCancelRequest {
reason: reason
.map(generated_types::AdminBillingRefundLatestInvoiceCancelRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.admin_billing_refund_policy_cancel_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn cancel_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_cancel_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn cancel_subscription_immediately(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingCancelImmediatelyRequest {
reason: reason
.map(generated_types::AdminBillingCancelImmediatelyRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_cancel_subscription_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn reactivate_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_reactivate_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn end_premium_grace_period(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_end_premium_grace_period(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
-4
View File
@@ -32,10 +32,6 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
pub(crate) fn nonzero_u64(value: u64, field: &str) -> Result<std::num::NonZeroU64, String> {
std::num::NonZeroU64::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
#[cfg(test)]
mod tests {
use super::{number_to_u64, types::*};
-1
View File
@@ -8,7 +8,6 @@ pub mod archives;
pub mod assets;
pub mod audit;
pub mod bans;
pub mod billing;
pub mod bulk;
pub mod client;
pub mod codes;
-39
View File
@@ -1,39 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BillingOverview {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SubscriptionResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentMethodListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InvoiceListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RefundCancelResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
-2
View File
@@ -4,7 +4,6 @@ mod admin_api_keys;
mod applications;
mod archives;
mod audit;
mod billing;
mod bulk;
mod codes;
mod common;
@@ -24,7 +23,6 @@ pub use admin_api_keys::*;
pub use applications::*;
pub use archives::*;
pub use audit::*;
pub use billing::*;
pub use bulk::*;
pub use codes::*;
pub use common::*;
-16
View File
@@ -150,22 +150,6 @@ pub async fn render(
},
))
}
"billing" => {
if config.self_hosted || !acl::has_permission(admin_acls, acl::BILLING_VIEW) {
return None;
}
let billing = client
.get_billing_overview(guild_id)
.await
.log_error("load guild billing overview")
.map(|b| b.data);
Some(tabs::billing::billing_tab(
config,
guild_id,
billing.as_ref(),
csrf_token,
))
}
"applications" => {
if !acl::has_any_permission(
admin_acls,
-49
View File
@@ -399,55 +399,6 @@ pub async fn dispatch(
"Bulk message deletion cancelled successfully",
"Failed to cancel bulk message deletion",
),
"refund_payment" => {
let Some(pi) = form.clean("payment_intent_id") else {
return DispatchOutcome::error("Payment intent ID is required");
};
let amt = form.parse_u64("amount_cents");
let reason = get("reason");
DispatchOutcome::from_result(
client
.issue_refund(user_id, &pi, amt, reason.as_deref())
.await,
"Refund issued successfully",
"Failed to issue refund",
)
}
"refund_policy_cancel_now" => {
let reason = get("reason");
DispatchOutcome::from_result(
client
.refund_policy_cancel_now(user_id, reason.as_deref())
.await,
"Refund policy cancellation completed successfully",
"Failed to apply refund policy cancellation",
)
}
"cancel_subscription" => DispatchOutcome::from_result(
client.cancel_subscription(user_id).await,
"Subscription cancelled successfully",
"Failed to cancel subscription",
),
"cancel_subscription_now" => {
let reason = get("reason");
DispatchOutcome::from_result(
client
.cancel_subscription_immediately(user_id, reason.as_deref())
.await,
"Subscription cancelled immediately",
"Failed to cancel subscription immediately",
)
}
"reactivate_subscription" => DispatchOutcome::from_result(
client.reactivate_subscription(user_id).await,
"Subscription reactivated successfully",
"Failed to reactivate subscription",
),
"end_premium_grace_period" => DispatchOutcome::from_result(
client.end_premium_grace_period(user_id).await,
"Premium grace period ended successfully",
"Failed to end premium grace period",
),
"message_shred" => {
let csv = form.first("csv_data").unwrap_or_default();
match parse_message_shred_csv(csv) {
-38
View File
@@ -155,44 +155,6 @@ pub async fn render(
csrf_token,
))
}
"billing" => {
if config.self_hosted
|| !acl::has_any_permission(
admin_acls,
&[
acl::BILLING_VIEW,
acl::BILLING_REFUND,
acl::BILLING_MANAGE_SUBSCRIPTION,
],
)
{
return None;
}
let can_view_billing = acl::has_permission(admin_acls, acl::BILLING_VIEW);
let b = if can_view_billing {
client
.get_billing_overview(user_id)
.await
.log_error("load user billing overview")
} else {
None
};
let invoices = if can_view_billing {
client
.get_user_invoices(user_id, 25, None)
.await
.log_error("load user invoices")
} else {
None
};
Some(tabs::billing::billing_tab(
config,
user_id,
b.as_ref().map(|v| &v.data),
invoices.as_ref().map(|v| &v.data),
csrf_token,
))
}
"guilds" => {
let g = client
.get_user_guilds(user_id, Some(200), None, None, Some(true))
@@ -1,94 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
templates::components::{
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
};
use maud::{Markup, html};
pub fn billing_tab(
config: &AdminConfig,
guild_id: &str,
billing: Option<&serde_json::Value>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
div class="space-y-6" {
@if let Some(data) = billing {
(render_billing_summary(data))
} @else {
(card_with_header("Billing", html! {
p class="text-sm text-neutral-500" {
"No billing information available for this guild."
}
}))
}
(card_with_header("Billing Actions", html! {
div class="space-y-4" {
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=billing&action=refresh_billing"}
class="block" {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Refresh Billing Data"))
}))
}
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=billing&action=cancel_subscription"} {
(csrf_input(csrf_token))
div class="space-y-3" {
input type="text" name="reason" placeholder="Reason (optional)"
class="block w-full rounded-md border border-neutral-300 px-3 \
py-2 text-sm shadow-sm focus:border-brand-primary \
focus:outline-none focus:ring-1 focus:ring-brand-primary";
(form_actions(html! {
(danger_button("Cancel Subscription"))
}))
}
}
}
}))
}
}
}
fn render_billing_summary(data: &serde_json::Value) -> Markup {
let customer_id = data
.get("stripe_customer_id")
.and_then(|v| v.as_str())
.unwrap_or("\u{2014}");
let sub_status = data
.get("subscription")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("none");
let period_end = data
.get("subscription")
.and_then(|s| s.get("current_period_end"))
.and_then(|v| v.as_str());
html! {
(card_with_header("Summary", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Stripe Customer", html! {
span class="text-xs" { (customer_id) }
}))
(detail_row("Subscription Status", html! {
span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs \
font-medium bg-neutral-100 text-neutral-700" {
(sub_status)
}
}))
@if let Some(end) = period_end {
(detail_row("Current Period Ends", html! { (end) }))
}
}
}))
}
}
@@ -3,7 +3,6 @@
pub mod applications;
pub mod archives;
pub mod audit_log;
pub mod billing;
pub mod emojis;
pub mod features;
pub mod members;
@@ -22,7 +22,6 @@ use maud::{Markup, html};
pub const USER_TABS: &[(&str, &str)] = &[
("overview", "Overview"),
("account", "Account"),
("billing", "Billing"),
("guilds", "Guilds"),
("dm_history", "DM History"),
("group_dms", "Group DMs"),
@@ -164,21 +163,10 @@ fn render_user_detail(
}
}
fn user_tab_visible(config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
fn user_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
match tab_id {
"overview" | "account" | "guilds" | "dm_history" | "group_dms" | "reports"
| "moderation" => true,
"billing" => {
!config.self_hosted
&& acl::has_any_permission(
admin_acls,
&[
acl::BILLING_VIEW,
acl::BILLING_REFUND,
acl::BILLING_MANAGE_SUBSCRIPTION,
],
)
}
"relationships" => acl::has_permission(admin_acls, acl::USER_LIST_RELATIONSHIPS),
"applications" => acl::has_permission(admin_acls, acl::APPLICATION_LIST_BY_OWNER),
"archives" => acl::has_any_permission(
@@ -1,410 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
templates::components::{
badge::{BadgeVariant, badge},
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
};
use maud::{Markup, html};
const INPUT_CLS: &str = "block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
focus:ring-brand-primary";
pub fn billing_tab(
config: &AdminConfig,
user_id: &str,
billing: Option<&serde_json::Value>,
invoices: Option<&serde_json::Value>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
div class="space-y-6" {
@if let Some(data) = billing {
(render_billing_summary(data))
(render_subscription(data))
(render_payment_methods(data))
(render_payments(data))
} @else {
(card_with_header("Billing", html! {
p class="text-sm text-neutral-500" {
"No billing information available for this user."
}
}))
}
@if let Some(data) = invoices {
(render_invoices(data))
}
(render_actions(base, user_id, csrf_token))
}
}
}
fn subscription_badge_variant(status: &str) -> BadgeVariant {
match status {
"active" | "trialing" => BadgeVariant::Success,
"past_due" | "unpaid" | "incomplete" => BadgeVariant::Warning,
"canceled" | "incomplete_expired" => BadgeVariant::Danger,
_ => BadgeVariant::Default,
}
}
fn render_billing_summary(data: &serde_json::Value) -> Markup {
let customer_id = data
.get("stripe_customer_id")
.and_then(|v| v.as_str())
.unwrap_or("\u{2014}");
let sub_status = data
.get("subscription")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str());
let period_end = data
.get("subscription")
.and_then(|s| s.get("current_period_end"))
.and_then(|v| v.as_str());
html! {
(card_with_header("Summary", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Stripe Customer", html! {
span class="text-xs" { (customer_id) }
}))
(detail_row("Subscription", html! {
@if let Some(status) = sub_status {
(badge(status, subscription_badge_variant(status)))
} @else {
span class="text-sm text-neutral-900" { "none" }
}
}))
@if let Some(end) = period_end {
(detail_row("Current Period Ends", html! { (end) }))
}
}
}))
}
}
fn render_subscription(data: &serde_json::Value) -> Markup {
let sub = match data.get("subscription") {
Some(s) if !s.is_null() => s,
_ => return html! {},
};
let status = sub
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let sub_id = sub.get("id").and_then(|v| v.as_str());
let plan_interval = sub.get("plan_interval").and_then(|v| v.as_str());
let period_start = sub.get("current_period_start").and_then(|v| v.as_str());
let period_end = sub.get("current_period_end").and_then(|v| v.as_str());
let cancel_at_period_end = sub
.get("cancel_at_period_end")
.and_then(|v| v.as_bool())
.unwrap_or(false);
html! {
(card_with_header("Subscription", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Status", html! {
(badge(status, subscription_badge_variant(status)))
}))
@if let Some(id) = sub_id {
(detail_row("ID", html! {
span class="text-xs" { (id) }
}))
}
@if let Some(interval) = plan_interval {
(detail_row("Plan Interval", html! { (interval) }))
}
@if let Some(start) = period_start {
(detail_row("Period Start", html! { (start) }))
}
@if let Some(end) = period_end {
(detail_row("Period End", html! { (end) }))
}
(detail_row("Cancel at Period End", html! {
@if cancel_at_period_end { "yes" } @else { "no" }
}))
}
}))
}
}
fn render_payment_methods(data: &serde_json::Value) -> Markup {
let methods = data.get("payment_methods").and_then(|v| v.as_array());
let empty = methods.is_none() || methods.is_some_and(|m| m.is_empty());
html! {
(card_with_header("Payment Methods", html! {
@if empty {
p class="text-sm text-neutral-500" { "No payment methods on file." }
} @else if let Some(pms) = methods {
div class="space-y-3" {
@for pm in pms {
@let pm_type = pm.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
@let brand = pm.get("card_brand").and_then(|v| v.as_str());
@let last4 = pm.get("card_last4").and_then(|v| v.as_str());
@let pm_id = pm.get("id").and_then(|v| v.as_str()).unwrap_or("");
@let display = match (brand, last4) {
(Some(b), Some(l)) => format!("{b} **** {l}"),
_ => pm_type.to_string(),
};
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3" {
p class="text-sm text-neutral-900" { (display) }
p class="text-xs text-neutral-500" { (pm_id) }
}
}
}
}
}))
}
}
fn render_payments(data: &serde_json::Value) -> Markup {
let payments = data.get("payments").and_then(|v| v.as_array());
let empty = payments.is_none() || payments.is_some_and(|p| p.is_empty());
html! {
(card_with_header("Payments", html! {
@if empty {
p class="text-sm text-neutral-500" { "No payments recorded." }
} @else if let Some(ps) = payments {
div class="space-y-3" {
@for p in ps { (payment_row(p)) }
}
}
}))
}
}
fn payment_row(p: &serde_json::Value) -> Markup {
let amount = p.get("amount_cents").and_then(|v| v.as_i64()).unwrap_or(0);
let currency = p.get("currency").and_then(|v| v.as_str()).unwrap_or("");
let status = p
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let created = p.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
let display_amount = format!("{:.2} {}", amount as f64 / 100.0, currency.to_uppercase());
let variant = match status {
"completed" | "succeeded" => BadgeVariant::Success,
"pending" | "processing" => BadgeVariant::Info,
"failed" | "canceled" => BadgeVariant::Danger,
"refunded" | "partially_refunded" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
};
html! {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="flex items-center justify-between" {
div class="flex items-center gap-2" {
span class="text-sm font-medium text-neutral-900" {
(display_amount)
}
(badge(status, variant))
}
span class="text-xs text-neutral-500" { (created) }
}
}
}
}
fn invoice_badge_variant(status: Option<&str>) -> BadgeVariant {
match status {
Some("paid") => BadgeVariant::Success,
Some("open" | "draft") => BadgeVariant::Info,
Some("uncollectible" | "void") => BadgeVariant::Danger,
_ => BadgeVariant::Default,
}
}
fn render_invoices(data: &serde_json::Value) -> Markup {
let invoices = data.get("invoices").and_then(|v| v.as_array());
let empty = invoices.is_none() || invoices.is_some_and(|i| i.is_empty());
let has_more = data
.get("has_more")
.and_then(|v| v.as_bool())
.unwrap_or(false);
html! {
(card_with_header("Invoices", html! {
@if empty {
p class="text-sm text-neutral-500" { "No invoices on file." }
} @else if let Some(items) = invoices {
div class="space-y-3" {
@for invoice in items {
(invoice_row(invoice))
}
@if has_more {
p class="text-xs text-neutral-500" {
"More invoices exist beyond this list."
}
}
}
}
}))
}
}
fn invoice_row(invoice: &serde_json::Value) -> Markup {
let amount = invoice
.get("amount_paid")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let currency = invoice
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = invoice.get("status").and_then(|v| v.as_str());
let created = invoice
.get("created")
.and_then(|v| v.as_i64())
.map(format_unix_timestamp)
.unwrap_or_default();
let display_amount = format_amount(amount, currency);
let status_label = status.unwrap_or("unknown");
let billing_reason = invoice.get("billing_reason").and_then(|v| v.as_str());
let invoice_id = invoice.get("id").and_then(|v| v.as_str()).unwrap_or("");
let subscription_id = invoice.get("subscription_id").and_then(|v| v.as_str());
let payment_intent_id = invoice.get("payment_intent_id").and_then(|v| v.as_str());
let charge_id = invoice.get("charge_id").and_then(|v| v.as_str());
let hosted_invoice_url = invoice.get("hosted_invoice_url").and_then(|v| v.as_str());
let invoice_pdf = invoice.get("invoice_pdf").and_then(|v| v.as_str());
html! {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="space-y-3" {
div class="flex items-center justify-between gap-3" {
div class="flex items-center gap-2" {
span class="text-sm font-medium text-neutral-900" {
(display_amount)
}
(badge(status_label, invoice_badge_variant(status)))
}
span class="text-xs text-neutral-500" { (created) }
}
@if let Some(reason) = billing_reason {
p class="text-sm text-neutral-500" { (reason) }
}
dl class="space-y-1" {
(compact_detail_row("id", invoice_id))
@if let Some(id) = subscription_id {
(compact_detail_row("subscription", id))
}
@if let Some(id) = payment_intent_id {
(compact_detail_row("payment_intent", id))
}
@if let Some(id) = charge_id {
(compact_detail_row("charge", id))
}
}
@if hosted_invoice_url.is_some() || invoice_pdf.is_some() {
div class="flex items-center gap-3 text-sm" {
@if let Some(url) = hosted_invoice_url {
a href=(url) target="_blank" rel="noreferrer noopener"
class="text-blue-600 hover:text-blue-800 hover:underline" {
"View"
}
}
@if let Some(url) = invoice_pdf {
a href=(url) target="_blank" rel="noreferrer noopener"
class="text-blue-600 hover:text-blue-800 hover:underline" {
"PDF"
}
}
}
}
}
}
}
}
fn compact_detail_row(label: &str, value: &str) -> Markup {
html! {
div class="grid grid-cols-1 gap-1 text-xs sm:grid-cols-3" {
dt class="text-neutral-500" { (label) }
dd class="break-all text-neutral-700 sm:col-span-2" { (value) }
}
}
}
fn format_amount(amount_minor: i64, currency: &str) -> String {
let code = currency.trim().to_uppercase();
if code.is_empty() {
format!("{:.2}", amount_minor as f64 / 100.0)
} else {
format!("{:.2} {code}", amount_minor as f64 / 100.0)
}
}
fn format_unix_timestamp(value: i64) -> String {
time::OffsetDateTime::from_unix_timestamp(value)
.ok()
.and_then(|ts| {
ts.format(&time::format_description::well_known::Rfc3339)
.ok()
})
.unwrap_or_else(|| value.to_string())
}
fn render_actions(base: &str, user_id: &str, csrf_token: &str) -> Markup {
html! {
(card_with_header("Billing Actions", html! {
div class="space-y-4" {
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription_now"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm text-neutral-700" {
"Cancel subscription immediately, no refund."
}
input type="text" name="reason" placeholder="Reason (optional)"
class=(INPUT_CLS);
(form_actions(html! {
(danger_button("Cancel Now"))
}))
}
}
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm text-neutral-700" {
"Cancel at renewal (access until period end)."
}
(form_actions(html! {
(submit_button("Cancel at Renewal"))
}))
}
}
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=refund_payment"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm font-medium text-neutral-700" {
"Manual Refund"
}
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
input type="text" name="payment_intent_id"
placeholder="pi_..." required
class=(INPUT_CLS);
input type="number" name="amount_cents" min="1"
placeholder="Amount cents (blank = full)"
class=(INPUT_CLS);
}
input type="text" name="reason"
placeholder="Reason (optional)"
class=(INPUT_CLS);
(form_actions(html! {
(danger_button("Refund"))
}))
}
}
}
}))
}
}
@@ -5,7 +5,6 @@ use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
pub mod account;
pub mod applications;
pub mod archives;
pub mod billing;
pub mod dm_history;
pub mod group_dm;
pub mod guilds;