mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(admin): remove the unfinished billing APIs and panel UI (#2248)
This commit is contained in:
+5
-1100
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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(¶ms),
|
||||
)
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
@@ -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::*};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user