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;
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,6 @@ import {ArchiveAdminController} from './ArchiveAdminController';
import {AssetAdminController} from './AssetAdminController';
import {AuditLogAdminController} from './AuditLogAdminController';
import {BanAdminController} from './BanAdminController';
import {BillingAdminController} from './BillingAdminController';
import {BulkAdminController} from './BulkAdminController';
import {CodesAdminController} from './CodesAdminController';
import {DiscoveryAdminController} from './DiscoveryAdminController';
@@ -39,7 +38,6 @@ export function registerAdminControllers(app: HonoApp) {
AuditLogAdminController(app);
ArchiveAdminController(app);
ReportAdminController(app);
BillingAdminController(app);
VoiceAdminController(app);
GatewayAdminController(app);
SearchAdminController(app);
@@ -1,62 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
describe('Admin Billing Authorization', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('billing overview requires billing:view ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
await createBuilder(harness, `${admin.token}`)
.get(`/admin/billing/users/${admin.userId}/overview`)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('billing refund requires billing:refund ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${admin.userId}/refund`)
.body({payment_intent_id: 'pi_test_refund'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('billing subscription management requires billing:manage_subscription ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${admin.userId}/cancel-subscription`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${admin.userId}/reactivate-subscription`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('refund policy immediate cancellation requires both refund and subscription management ACLs', async () => {
const refundOnlyAdmin = await createTestAccount(harness);
await setUserACLs(harness, refundOnlyAdmin, ['admin:authenticate', 'billing:refund']);
await createBuilder(harness, `${refundOnlyAdmin.token}`)
.post(`/admin/billing/users/${refundOnlyAdmin.userId}/refund-policy-cancel-now`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
const subscriptionOnlyAdmin = await createTestAccount(harness);
await setUserACLs(harness, subscriptionOnlyAdmin, ['admin:authenticate', 'billing:manage_subscription']);
await createBuilder(harness, `${subscriptionOnlyAdmin.token}`)
.post(`/admin/billing/users/${subscriptionOnlyAdmin.userId}/refund-policy-cancel-now`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
});
@@ -1,924 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import type {
AdminBillingOverviewResponse,
AdminBillingRefundLatestInvoiceCancelResponse,
AdminInvoiceListResponse,
} from '@fluxer/schema/src/domains/admin/AdminBillingSchemas';
import type Stripe from 'stripe';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {createBuilder} from '../../test/TestRequestBuilder';
import {PaymentRepository} from '../../user/repositories/PaymentRepository';
const DAY_SECONDS = 24 * 60 * 60;
function stripeFixture<T>(value: object): T {
return value as T;
}
describe('Admin billing overview', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
server.resetHandlers();
});
async function setStripeCustomerId(userId: string, stripeCustomerId: string): Promise<void> {
await createBuilder(harness, '')
.post(`/test/users/${userId}/premium`)
.body({stripe_customer_id: stripeCustomerId})
.execute();
}
async function mirrorCustomer(params: {stripeCustomerId: string; userId?: string}): Promise<void> {
await getBillingRepository().customers.upsertFromStripe(
{
id: params.stripeCustomerId,
object: 'customer',
created: Math.floor(Date.now() / 1000),
email: null,
invoice_settings: {default_payment_method: null},
livemode: false,
metadata: params.userId ? {userId: params.userId} : {},
} as Stripe.Customer,
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
);
}
async function mirrorSubscription(params: {
currentPeriodEnd?: number;
currentPeriodStart?: number;
latestInvoiceId?: string;
status?: Stripe.Subscription.Status;
stripeCustomerId: string;
stripeSubscriptionId: string;
userId?: string;
}): Promise<void> {
const now = Math.floor(Date.now() / 1000);
const currentPeriodStart = params.currentPeriodStart ?? now - DAY_SECONDS;
const currentPeriodEnd = params.currentPeriodEnd ?? now + 29 * DAY_SECONDS;
await getBillingRepository().subscriptions.upsertFromStripe(
{
id: params.stripeSubscriptionId,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
collection_method: 'charge_automatically',
created: currentPeriodStart,
currency: 'eur',
customer: params.stripeCustomerId,
items: {
data: [
{
id: `si_${params.stripeSubscriptionId}`,
current_period_start: currentPeriodStart,
current_period_end: currentPeriodEnd,
price: {
id: 'price_monthly_eur',
product: 'prod_monthly',
unit_amount: 499,
},
quantity: 1,
},
],
},
latest_invoice: params.latestInvoiceId ?? null,
livemode: false,
metadata: params.userId ? {userId: params.userId} : {},
status: params.status ?? 'active',
},
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
);
}
async function mirrorInvoice(params: {
amountPaidCents: number;
chargeId?: string;
created?: number;
currency?: string;
invoiceId: string;
paymentIntentId?: string;
paymentId?: string;
stripeCustomerId: string;
stripeSubscriptionId?: string;
userId?: string;
}): Promise<void> {
const created = params.created ?? Math.floor(Date.now() / 1000);
await getBillingRepository().invoices.upsertFromStripe(
stripeFixture<Stripe.Invoice>({
id: params.invoiceId,
object: 'invoice',
amount_due: params.amountPaidCents,
amount_paid: params.amountPaidCents,
amount_remaining: 0,
attempt_count: 1,
attempted: true,
billing_reason: 'subscription_cycle',
collection_method: 'charge_automatically',
created,
currency: params.currency ?? 'eur',
customer: params.stripeCustomerId,
livemode: false,
metadata: params.userId ? {userId: params.userId} : {},
paid: true,
payments:
params.paymentIntentId || params.chargeId
? {
object: 'list',
data: [
{
id: params.paymentId ?? `inpay_${params.invoiceId}`,
object: 'invoice_payment',
amount_paid: params.amountPaidCents,
amount_requested: params.amountPaidCents,
created,
currency: params.currency ?? 'eur',
invoice: params.invoiceId,
is_default: true,
livemode: false,
payment: {
type: 'payment_intent',
payment_intent: params.paymentIntentId ?? null,
charge: params.chargeId ?? null,
},
status: 'paid',
status_transitions: {canceled_at: null, paid_at: created + 20},
},
],
has_more: false,
url: `/v1/invoices/${params.invoiceId}/payments`,
}
: {object: 'list', data: [], has_more: false, url: `/v1/invoices/${params.invoiceId}/payments`},
status: 'paid',
status_transitions: {finalized_at: created, paid_at: created + 20, voided_at: null},
subscription: params.stripeSubscriptionId ?? null,
subtotal: params.amountPaidCents,
total: params.amountPaidCents,
}),
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
);
}
async function mirrorPaymentIntent(params: {
amountCents?: number;
chargeId?: string;
invoiceId?: string;
paymentIntentId: string;
stripeCustomerId: string;
}): Promise<void> {
await getBillingRepository().paymentIntents.upsertFromStripe(
stripeFixture<Stripe.PaymentIntent>({
id: params.paymentIntentId,
object: 'payment_intent',
amount: params.amountCents ?? 499,
amount_capturable: 0,
amount_received: params.amountCents ?? 499,
capture_method: 'automatic',
confirmation_method: 'automatic',
created: Math.floor(Date.now() / 1000),
currency: 'eur',
customer: params.stripeCustomerId,
invoice: params.invoiceId ?? null,
latest_charge: params.chargeId ?? null,
livemode: false,
metadata: {},
payment_method_types: ['card'],
status: 'succeeded',
}),
);
}
async function mirrorCharge(params: {
amountCents?: number;
chargeId: string;
invoiceId?: string;
paymentIntentId?: string;
stripeCustomerId: string;
}): Promise<void> {
await getBillingRepository().charges.upsertFromStripe(
stripeFixture<Stripe.Charge>({
id: params.chargeId,
object: 'charge',
amount: params.amountCents ?? 499,
amount_captured: params.amountCents ?? 499,
amount_refunded: 0,
billing_details: {address: {country: null}},
captured: true,
created: Math.floor(Date.now() / 1000),
currency: 'eur',
customer: params.stripeCustomerId,
invoice: params.invoiceId ?? null,
livemode: false,
metadata: {},
paid: true,
payment_intent: params.paymentIntentId ?? null,
payment_method_details: {type: 'card', card: {brand: 'visa', last4: '4242', country: null}},
refunded: false,
status: 'succeeded',
}),
);
}
async function mirrorPaymentMethod(params: {paymentMethodId: string; stripeCustomerId: string}): Promise<void> {
await getBillingRepository().paymentMethods.upsertFromStripe(
{
id: params.paymentMethodId,
object: 'payment_method',
billing_details: {address: {country: 'US'}, email: null, name: null, phone: null},
card: {brand: 'visa', country: 'US', exp_month: 12, exp_year: 2031, funding: 'credit', last4: '4242'},
created: Math.floor(Date.now() / 1000),
customer: params.stripeCustomerId,
livemode: false,
metadata: {},
type: 'card',
} as Stripe.PaymentMethod,
{isDefault: true},
);
}
async function setStripeSubscriptionState(params: {
userId: string;
stripeCustomerId: string;
stripeSubscriptionId: string;
}): Promise<void> {
await createBuilder(harness, '')
.post(`/test/users/${params.userId}/premium`)
.body({
stripe_customer_id: params.stripeCustomerId,
stripe_subscription_id: params.stripeSubscriptionId,
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_billing_cycle: 'monthly',
premium_will_cancel: false,
})
.execute();
}
function createRefundPolicyStripeHandlers(params: {
amountPaidCents: number;
currency?: string;
elapsedDays: number;
invoiceId: string;
stripeCustomerId: string;
stripeSubscriptionId: string;
}) {
const now = Math.floor(Date.now() / 1000);
const currentPeriodStart = now - params.elapsedDays * DAY_SECONDS;
const currentPeriodEnd = currentPeriodStart + 30 * DAY_SECONDS;
return createStripeApiHandlers({
subscriptions: {
[params.stripeSubscriptionId]: {
customer: params.stripeCustomerId,
current_period_start: currentPeriodStart,
current_period_end: currentPeriodEnd,
latest_invoice: params.invoiceId,
status: 'active',
},
},
invoices: {
[params.invoiceId]: {
customer: params.stripeCustomerId,
subscriptionId: params.stripeSubscriptionId,
amount_due: params.amountPaidCents,
amount_paid: params.amountPaidCents,
billing_reason: 'subscription_cycle',
currency: params.currency ?? 'eur',
created: now - 300,
status: 'paid',
},
},
});
}
async function createPaymentRecord(params: {
userId: string;
checkoutSessionId: string;
completedAt?: Date;
euWithdrawalWaiverAccepted?: boolean;
euWithdrawalWaiverAcceptedAt?: Date | null;
euWithdrawalWaiverRequired?: boolean;
euWithdrawalWaiverTextVersion?: string | null;
invoiceId: string;
purchaseClientCountryCode?: string | null;
purchaseGeoipCountryCode?: string | null;
subscriptionId: string;
stripeCustomerId: string;
}): Promise<void> {
const paymentRepository = new PaymentRepository();
const createdAt = params.completedAt ?? new Date('2026-02-23T14:27:32.409Z');
await paymentRepository.createPayment({
checkout_session_id: params.checkoutSessionId,
user_id: createUserID(BigInt(params.userId)),
price_id: 'price_monthly_eur',
product_type: 'monthly_subscription',
status: 'completed',
is_gift: false,
created_at: createdAt,
purchase_geoip_country_code: params.purchaseGeoipCountryCode ?? null,
purchase_client_country_code: params.purchaseClientCountryCode ?? null,
eu_withdrawal_waiver_required: params.euWithdrawalWaiverRequired ?? false,
eu_withdrawal_waiver_accepted: params.euWithdrawalWaiverAccepted ?? false,
eu_withdrawal_waiver_accepted_at: params.euWithdrawalWaiverAcceptedAt ?? null,
eu_withdrawal_waiver_text_version: params.euWithdrawalWaiverTextVersion ?? null,
});
await paymentRepository.updatePayment({
checkout_session_id: params.checkoutSessionId,
stripe_customer_id: params.stripeCustomerId,
payment_intent_id: null,
subscription_id: params.subscriptionId,
invoice_id: params.invoiceId,
amount_cents: 499,
currency: 'eur',
status: 'completed',
completed_at: createdAt,
purchase_geoip_country_code: params.purchaseGeoipCountryCode ?? null,
purchase_client_country_code: params.purchaseClientCountryCode ?? null,
eu_withdrawal_waiver_required: params.euWithdrawalWaiverRequired ?? false,
eu_withdrawal_waiver_accepted: params.euWithdrawalWaiverAccepted ?? false,
eu_withdrawal_waiver_accepted_at: params.euWithdrawalWaiverAcceptedAt ?? null,
eu_withdrawal_waiver_text_version: params.euWithdrawalWaiverTextVersion ?? null,
});
}
test('resolves missing payment intents from Stripe invoice payments for overview and invoice listings', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'billing:view']);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_billing_target';
await setStripeCustomerId(targetUser.userId, stripeCustomerId);
await createPaymentRecord({
userId: targetUser.userId,
checkoutSessionId: 'cs_billing_overview_1',
invoiceId: 'in_local_checkout_1',
subscriptionId: 'sub_billing_target',
stripeCustomerId,
});
const stripeHandlers = createStripeApiHandlers({
invoices: {
in_local_checkout_1: {
customer: stripeCustomerId,
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
currency: 'eur',
created: 1771862851,
payments: {
object: 'list',
data: [
{
id: 'inpay_local_checkout_1',
object: 'invoice_payment',
amount_paid: 499,
amount_requested: 499,
created: 1771862851,
currency: 'eur',
invoice: 'in_local_checkout_1',
is_default: true,
livemode: false,
payment: {
type: 'payment_intent',
payment_intent: 'pi_local_checkout_1',
charge: 'ch_local_checkout_1',
},
status: 'paid',
status_transitions: {
canceled_at: null,
paid_at: 1771862871,
},
},
],
has_more: false,
url: '/v1/invoices/in_local_checkout_1/payments',
},
},
in_renewal_1: {
customer: stripeCustomerId,
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_cycle',
currency: 'eur',
created: 1776065330,
payments: {
object: 'list',
data: [
{
id: 'inpay_renewal_1',
object: 'invoice_payment',
amount_paid: 499,
amount_requested: 499,
created: 1776065330,
currency: 'eur',
invoice: 'in_renewal_1',
is_default: true,
livemode: false,
payment: {
type: 'payment_intent',
payment_intent: 'pi_renewal_1',
charge: 'ch_renewal_1',
},
status: 'paid',
status_transitions: {
canceled_at: null,
paid_at: 1776065360,
},
},
],
has_more: false,
url: '/v1/invoices/in_renewal_1/payments',
},
},
},
paymentIntents: {
pi_local_checkout_1: {
customer: stripeCustomerId,
currency: 'eur',
latest_charge: 'ch_local_checkout_1',
},
pi_renewal_1: {
customer: stripeCustomerId,
currency: 'eur',
latest_charge: 'ch_renewal_1',
},
},
paymentMethods: {
pm_billing_target_1: {
customer: stripeCustomerId,
type: 'card',
card: {
brand: 'visa',
last4: '4242',
exp_month: 12,
exp_year: 2031,
country: 'US',
},
},
},
});
server.use(...stripeHandlers.handlers);
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_local_checkout_1',
created: 1771862851,
invoiceId: 'in_local_checkout_1',
paymentId: 'inpay_local_checkout_1',
paymentIntentId: 'pi_local_checkout_1',
stripeCustomerId,
stripeSubscriptionId: 'sub_billing_target',
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_renewal_1',
created: 1776065330,
invoiceId: 'in_renewal_1',
paymentId: 'inpay_renewal_1',
paymentIntentId: 'pi_renewal_1',
stripeCustomerId,
stripeSubscriptionId: 'sub_billing_target',
userId: targetUser.userId,
});
await mirrorPaymentIntent({
chargeId: 'ch_local_checkout_1',
invoiceId: 'in_local_checkout_1',
paymentIntentId: 'pi_local_checkout_1',
stripeCustomerId,
});
await mirrorPaymentIntent({
chargeId: 'ch_renewal_1',
invoiceId: 'in_renewal_1',
paymentIntentId: 'pi_renewal_1',
stripeCustomerId,
});
await mirrorCharge({
chargeId: 'ch_local_checkout_1',
invoiceId: 'in_local_checkout_1',
paymentIntentId: 'pi_local_checkout_1',
stripeCustomerId,
});
await mirrorCharge({
chargeId: 'ch_renewal_1',
invoiceId: 'in_renewal_1',
paymentIntentId: 'pi_renewal_1',
stripeCustomerId,
});
await mirrorPaymentMethod({paymentMethodId: 'pm_billing_target_1', stripeCustomerId});
const overview = await createBuilder<AdminBillingOverviewResponse>(harness, `${admin.token}`)
.get(`/admin/billing/users/${targetUser.userId}/overview`)
.execute();
expect(overview.payments).toHaveLength(2);
const localCheckoutPayment = overview.payments.find((payment) => payment.invoice_id === 'in_local_checkout_1');
expect(localCheckoutPayment?.payment_intent_id).toBe('pi_local_checkout_1');
expect(localCheckoutPayment?.resolved_payment_intent_id).toBe('pi_local_checkout_1');
expect(localCheckoutPayment?.charge_id).toBe('ch_local_checkout_1');
expect(localCheckoutPayment?.refundable_via_payment_intent).toBe(true);
expect(overview.payment_methods[0]?.id).toBe('pm_billing_target_1');
const invoices = await createBuilder<AdminInvoiceListResponse>(harness, `${admin.token}`)
.get(`/admin/billing/users/${targetUser.userId}/invoices`)
.execute();
expect(invoices.invoices).toHaveLength(2);
expect(invoices.invoices[0]?.id).toBe('in_renewal_1');
expect(invoices.invoices[0]?.payment_intent_id).toBe('pi_renewal_1');
expect(invoices.invoices[0]?.charge_id).toBe('ch_renewal_1');
expect(invoices.invoices[0]?.billing_reason).toBe('subscription_cycle');
expect(invoices.invoices[1]?.payment_intent_id).toBe('pi_local_checkout_1');
});
test('resolves billing overview from Stripe metadata even when local Stripe linkage is missing', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'billing:view']);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_billing_metadata_only';
const stripeSubscriptionId = 'sub_billing_metadata_only';
const invoiceId = 'in_billing_metadata_only';
const now = Math.floor(Date.now() / 1000);
const stripeHandlers = createStripeApiHandlers({
customers: {
[stripeCustomerId]: {
email: '[email protected]',
metadata: {
userId: targetUser.userId,
},
},
},
invoices: {
[invoiceId]: {
customer: stripeCustomerId,
subscriptionId: stripeSubscriptionId,
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
currency: 'eur',
created: now - 300,
status: 'paid',
},
},
paymentMethods: {
pm_billing_metadata_only: {
customer: stripeCustomerId,
type: 'card',
card: {
brand: 'visa',
last4: '1111',
exp_month: 8,
exp_year: 2031,
country: 'FR',
},
},
},
subscriptions: {
[stripeSubscriptionId]: {
customer: stripeCustomerId,
latest_invoice: invoiceId,
status: 'active',
current_period_start: now - DAY_SECONDS,
current_period_end: now + 29 * DAY_SECONDS,
},
},
});
server.use(...stripeHandlers.handlers);
await mirrorCustomer({stripeCustomerId, userId: targetUser.userId});
await mirrorSubscription({
currentPeriodEnd: now + 29 * DAY_SECONDS,
currentPeriodStart: now - DAY_SECONDS,
latestInvoiceId: invoiceId,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: `ch_${invoiceId}`,
created: now - 300,
invoiceId,
paymentIntentId: `pi_${invoiceId}`,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorPaymentIntent({
chargeId: `ch_${invoiceId}`,
invoiceId,
paymentIntentId: `pi_${invoiceId}`,
stripeCustomerId,
});
await mirrorCharge({
chargeId: `ch_${invoiceId}`,
invoiceId,
paymentIntentId: `pi_${invoiceId}`,
stripeCustomerId,
});
await mirrorPaymentMethod({paymentMethodId: 'pm_billing_metadata_only', stripeCustomerId});
const overview = await createBuilder<AdminBillingOverviewResponse>(harness, `${admin.token}`)
.get(`/admin/billing/users/${targetUser.userId}/overview`)
.execute();
expect(overview.stripe_customer_id).toBe(stripeCustomerId);
expect(overview.subscription?.id).toBe(stripeSubscriptionId);
expect(overview.subscription?.status).toBe('active');
expect(overview.payment_methods[0]?.id).toBe('pm_billing_metadata_only');
expect(overview.payments[0]?.invoice_id).toBe(invoiceId);
expect(overview.payments[0]?.resolved_payment_intent_id).toBe(`pi_${invoiceId}`);
});
test('cancels a Stripe subscription at period end after resolving missing local Stripe IDs from Stripe metadata', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:manage_subscription',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_billing_cancel_metadata';
const stripeSubscriptionId = 'sub_billing_cancel_metadata';
const now = Math.floor(Date.now() / 1000);
const stripeHandlers = createStripeApiHandlers({
customers: {
[stripeCustomerId]: {
metadata: {
userId: targetUser.userId,
},
},
},
subscriptions: {
[stripeSubscriptionId]: {
customer: stripeCustomerId,
status: 'active',
current_period_start: now - DAY_SECONDS,
current_period_end: now + 29 * DAY_SECONDS,
},
},
});
server.use(...stripeHandlers.handlers);
await mirrorCustomer({stripeCustomerId, userId: targetUser.userId});
await mirrorSubscription({
currentPeriodEnd: now + 29 * DAY_SECONDS,
currentPeriodStart: now - DAY_SECONDS,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/cancel-subscription`)
.body({})
.expect(204)
.execute();
expect(stripeHandlers.spies.updatedSubscriptions).toContainEqual({
id: stripeSubscriptionId,
params: {
cancel_at_period_end: 'true',
},
});
});
test('allows admin refunds when the payment intent belongs to the target Stripe customer even without a local payment-intent index', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:refund',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_refund_target';
await setStripeCustomerId(targetUser.userId, stripeCustomerId);
const stripeHandlers = createStripeApiHandlers({
paymentIntents: {
pi_remote_only_refund: {
customer: stripeCustomerId,
latest_charge: 'ch_remote_only_refund',
},
},
});
server.use(...stripeHandlers.handlers);
await mirrorPaymentIntent({
chargeId: 'ch_remote_only_refund',
paymentIntentId: 'pi_remote_only_refund',
stripeCustomerId,
});
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/refund`)
.body({
payment_intent_id: 'pi_remote_only_refund',
reason: 'Customer requested a refund',
})
.expect(204)
.execute();
expect(stripeHandlers.spies.createdRefunds).toHaveLength(1);
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
payment_intent: 'pi_remote_only_refund',
reason: 'requested_by_customer',
metadata: {
admin_user_id: admin.userId,
target_user_id: targetUser.userId,
admin_reason: 'Customer requested a refund',
},
});
});
test('forces a full refund when the latest invoice is inside the EU withdrawal window without a waiver', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:refund',
'billing:manage_subscription',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_eu_missing_waiver';
const stripeSubscriptionId = 'sub_eu_missing_waiver';
const invoiceId = 'in_eu_missing_waiver';
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
await createPaymentRecord({
userId: targetUser.userId,
checkoutSessionId: 'cs_eu_missing_waiver',
completedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
euWithdrawalWaiverRequired: true,
euWithdrawalWaiverAccepted: false,
euWithdrawalWaiverTextVersion: '2026-04-23',
invoiceId,
purchaseClientCountryCode: 'DE',
purchaseGeoipCountryCode: 'DE',
subscriptionId: stripeSubscriptionId,
stripeCustomerId,
});
const stripeHandlers = createRefundPolicyStripeHandlers({
amountPaidCents: 499,
elapsedDays: 6,
invoiceId,
stripeCustomerId,
stripeSubscriptionId,
});
server.use(...stripeHandlers.handlers);
const now = Math.floor(Date.now() / 1000);
await mirrorSubscription({
currentPeriodEnd: now + 24 * DAY_SECONDS,
currentPeriodStart: now - 6 * DAY_SECONDS,
latestInvoiceId: invoiceId,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_eu_missing_waiver',
invoiceId,
paymentIntentId: 'pi_eu_missing_waiver',
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorCharge({
chargeId: 'ch_eu_missing_waiver',
invoiceId,
paymentIntentId: 'pi_eu_missing_waiver',
stripeCustomerId,
});
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
.body({reason: 'Withdrawal waiver missing'})
.execute();
expect(result.refund_policy).toBe('full_refund');
expect(result.refund_policy_basis).toBe('eu_eea_withdrawal_no_waiver');
expect(result.refunded_amount_cents).toBe(499);
expect(result.eu_withdrawal_waiver_required).toBe(true);
expect(result.eu_withdrawal_waiver_accepted).toBe(false);
expect(result.purchase_geoip_country_code).toBe('DE');
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
amount: '499',
metadata: {
refund_policy: 'full_refund',
refund_policy_basis: 'eu_eea_withdrawal_no_waiver',
eu_withdrawal_waiver_required: 'true',
eu_withdrawal_waiver_accepted: 'false',
},
});
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
});
test('uses the support prorate policy when an EU waiver was accepted', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:refund',
'billing:manage_subscription',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_eu_accepted_waiver';
const stripeSubscriptionId = 'sub_eu_accepted_waiver';
const invoiceId = 'in_eu_accepted_waiver';
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
await createPaymentRecord({
userId: targetUser.userId,
checkoutSessionId: 'cs_eu_accepted_waiver',
completedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
euWithdrawalWaiverRequired: true,
euWithdrawalWaiverAccepted: true,
euWithdrawalWaiverAcceptedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
euWithdrawalWaiverTextVersion: '2026-04-23',
invoiceId,
purchaseClientCountryCode: 'DE',
purchaseGeoipCountryCode: 'DE',
subscriptionId: stripeSubscriptionId,
stripeCustomerId,
});
const stripeHandlers = createRefundPolicyStripeHandlers({
amountPaidCents: 499,
elapsedDays: 6,
invoiceId,
stripeCustomerId,
stripeSubscriptionId,
});
server.use(...stripeHandlers.handlers);
const now = Math.floor(Date.now() / 1000);
await mirrorSubscription({
currentPeriodEnd: now + 24 * DAY_SECONDS,
currentPeriodStart: now - 6 * DAY_SECONDS,
latestInvoiceId: invoiceId,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_eu_accepted_waiver',
invoiceId,
paymentIntentId: 'pi_eu_accepted_waiver',
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorCharge({
chargeId: 'ch_eu_accepted_waiver',
invoiceId,
paymentIntentId: 'pi_eu_accepted_waiver',
stripeCustomerId,
});
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
.body({})
.execute();
expect(result.refund_policy).toBe('prorated_refund');
expect(result.refund_policy_basis).toBe('support_policy');
expect(result.refunded_amount_cents).toBe(400);
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
amount: '400',
metadata: {
refund_policy: 'prorated_refund',
refund_policy_basis: 'support_policy',
eu_withdrawal_waiver_required: 'true',
eu_withdrawal_waiver_accepted: 'true',
},
});
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
});
test('cancels without refund after the support refund window', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:refund',
'billing:manage_subscription',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_cancel_only';
const stripeSubscriptionId = 'sub_cancel_only';
const invoiceId = 'in_cancel_only';
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
await createPaymentRecord({
userId: targetUser.userId,
checkoutSessionId: 'cs_cancel_only',
completedAt: new Date(Date.now() - 20 * DAY_SECONDS * 1000),
invoiceId,
subscriptionId: stripeSubscriptionId,
stripeCustomerId,
});
const stripeHandlers = createRefundPolicyStripeHandlers({
amountPaidCents: 499,
elapsedDays: 20,
invoiceId,
stripeCustomerId,
stripeSubscriptionId,
});
server.use(...stripeHandlers.handlers);
const now = Math.floor(Date.now() / 1000);
await mirrorSubscription({
currentPeriodEnd: now + 10 * DAY_SECONDS,
currentPeriodStart: now - 20 * DAY_SECONDS,
latestInvoiceId: invoiceId,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_cancel_only',
invoiceId,
paymentIntentId: 'pi_cancel_only',
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorCharge({
chargeId: 'ch_cancel_only',
invoiceId,
paymentIntentId: 'pi_cancel_only',
stripeCustomerId,
});
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
.body({})
.execute();
expect(result.refund_policy).toBe('cancel_only');
expect(result.refund_policy_basis).toBe('support_policy');
expect(result.refunded_amount_cents).toBe(0);
expect(stripeHandlers.spies.createdRefunds).toHaveLength(0);
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
});
});
-3
View File
@@ -42,9 +42,6 @@ export const AdminACLs = {
BAN_PROFILE_SUBSTRING_ADD: 'ban:profile_substring:add',
BAN_PROFILE_SUBSTRING_CHECK: 'ban:profile_substring:check',
BAN_PROFILE_SUBSTRING_REMOVE: 'ban:profile_substring:remove',
BILLING_MANAGE_SUBSCRIPTION: 'billing:manage_subscription',
BILLING_REFUND: 'billing:refund',
BILLING_VIEW: 'billing:view',
BULK_ADD_GUILD_MEMBERS: 'bulk:add:guild_members',
BULK_DELETE_USERS: 'bulk:delete:users',
BULK_UPDATE_GUILD_FEATURES: 'bulk:update:guild_features',
@@ -1,172 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {z} from 'zod';
const AdminPaymentRefundResponse = z.object({
id: z.string(),
amount_cents: z.number(),
currency: z.string(),
status: z.string().nullable(),
reason: z.string().nullable(),
created: z.number(),
payment_intent_id: z.string().nullable(),
charge_id: z.string().nullable(),
});
const AdminPaymentResponse = z.object({
checkout_session_id: z.string().nullable(),
user_id: z.string(),
stripe_customer_id: z.string().nullable(),
payment_intent_id: z.string().nullable(),
resolved_payment_intent_id: z.string().nullable(),
charge_id: z.string().nullable(),
subscription_id: z.string().nullable(),
invoice_id: z.string().nullable(),
price_id: z.string().nullable(),
product_type: z.string().nullable(),
amount_cents: z.number(),
currency: z.string(),
status: z.string(),
stripe_source: z.enum(['invoice']),
refundable_via_payment_intent: z.boolean(),
refunded_amount_cents: z.number(),
net_amount_cents: z.number(),
refunds: z.array(AdminPaymentRefundResponse),
payment_method_type: z.string().nullable(),
payment_method_brand: z.string().nullable(),
payment_method_last4: z.string().nullable(),
stripe_payment_method_country_code: z.string().nullable(),
stripe_billing_country_code: z.string().nullable(),
stripe_customer_country_code: z.string().nullable(),
stripe_terms_of_service_accepted: z.boolean().nullable(),
is_gift: z.boolean(),
gift_code: z.string().nullable(),
purchase_geoip_country_code: z.string().nullable(),
purchase_client_country_code: z.string().nullable(),
eu_withdrawal_waiver_required: z.boolean(),
eu_withdrawal_waiver_accepted: z.boolean(),
eu_withdrawal_waiver_accepted_at: z.string().nullable(),
eu_withdrawal_waiver_text_version: z.string().nullable(),
created_at: z.string(),
completed_at: z.string().nullable(),
});
export const AdminPaymentListResponse = z.object({
payments: z.array(AdminPaymentResponse),
});
export type AdminPaymentListResponse = z.infer<typeof AdminPaymentListResponse>;
export const AdminSubscriptionResponse = z.object({
id: z.string(),
status: z.string(),
current_period_start: z.string().nullable(),
current_period_end: z.string().nullable(),
cancel_at_period_end: z.boolean(),
cancel_at: z.string().nullable(),
canceled_at: z.string().nullable(),
plan_interval: z.string().nullable(),
plan_amount_cents: z.number().nullable(),
plan_currency: z.string().nullable(),
default_payment_method_id: z.string().nullable(),
});
export type AdminSubscriptionResponse = z.infer<typeof AdminSubscriptionResponse>;
const AdminPaymentMethodResponse = z.object({
id: z.string(),
type: z.string(),
card_brand: z.string().nullable(),
card_last4: z.string().nullable(),
card_exp_month: z.number().nullable(),
card_exp_year: z.number().nullable(),
created: z.number(),
});
export const AdminPaymentMethodListResponse = z.object({
payment_methods: z.array(AdminPaymentMethodResponse),
});
export type AdminPaymentMethodListResponse = z.infer<typeof AdminPaymentMethodListResponse>;
const AdminInvoiceResponse = z.object({
id: z.string(),
amount_due: z.number(),
amount_paid: z.number(),
currency: z.string(),
status: z.string().nullable(),
created: z.number(),
billing_reason: z.string().nullable(),
subscription_id: z.string().nullable(),
payment_type: z.string().nullable(),
payment_status: z.string().nullable(),
payment_intent_id: z.string().nullable(),
charge_id: z.string().nullable(),
paid_at: z.string().nullable(),
hosted_invoice_url: z.string().nullable(),
invoice_pdf: z.string().nullable(),
});
export const AdminInvoiceListResponse = z.object({
invoices: z.array(AdminInvoiceResponse),
has_more: z.boolean(),
});
export type AdminInvoiceListResponse = z.infer<typeof AdminInvoiceListResponse>;
export const AdminBillingRefundRequest = z.object({
payment_intent_id: z.string(),
amount_cents: z.number().int().positive().optional(),
reason: z.string().trim().min(1).max(512).optional(),
});
export type AdminBillingRefundRequest = z.infer<typeof AdminBillingRefundRequest>;
export const AdminBillingRefundLatestInvoiceCancelRequest = z.object({
reason: z.string().trim().min(1).max(512).optional(),
});
export type AdminBillingRefundLatestInvoiceCancelRequest = z.infer<typeof AdminBillingRefundLatestInvoiceCancelRequest>;
export const AdminBillingCancelImmediatelyRequest = z.object({
reason: z.string().trim().min(1).max(512).optional(),
});
export type AdminBillingCancelImmediatelyRequest = z.infer<typeof AdminBillingCancelImmediatelyRequest>;
export const AdminBillingRefundLatestInvoiceCancelResponse = z.object({
subscription_id: z.string(),
invoice_id: z.string(),
payment_intent_id: z.string().nullable(),
charge_id: z.string().nullable(),
refund_policy: z.enum(['full_refund', 'prorated_refund', 'cancel_only']),
refund_policy_basis: z.enum(['support_policy', 'eu_eea_withdrawal_no_waiver']),
refund_id: z.string().nullable(),
refunded_amount_cents: z.number(),
invoice_amount_paid_cents: z.number(),
currency: z.string(),
cycle_elapsed_days: z.number(),
purchase_geoip_country_code: z.string().nullable(),
purchase_client_country_code: z.string().nullable(),
stripe_payment_method_country_code: z.string().nullable(),
stripe_billing_country_code: z.string().nullable(),
stripe_customer_country_code: z.string().nullable(),
stripe_terms_of_service_accepted: z.boolean().nullable(),
eu_withdrawal_waiver_required: z.boolean(),
eu_withdrawal_waiver_accepted: z.boolean(),
eu_withdrawal_waiver_accepted_at: z.string().nullable(),
eu_withdrawal_waiver_text_version: z.string().nullable(),
});
export type AdminBillingRefundLatestInvoiceCancelResponse = z.infer<
typeof AdminBillingRefundLatestInvoiceCancelResponse
>;
export const AdminBillingOverviewResponse = z.object({
subscription: AdminSubscriptionResponse.nullable(),
payments: z.array(AdminPaymentResponse),
payment_methods: z.array(AdminPaymentMethodResponse),
stripe_customer_id: z.string().nullable(),
});
export type AdminBillingOverviewResponse = z.infer<typeof AdminBillingOverviewResponse>;