payment informatics 3 (#1706)

This commit is contained in:
Brandon Thomas
2026-06-30 08:19:52 -04:00
committed by GitHub
parent d99da56df0
commit 35add74498
49 changed files with 3934 additions and 51 deletions
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_spend_summaries\nSET\n payments_namespace = ?,\n user_token = ?,\n lifetime_gross_spend_usd_cents = ?,\n lifetime_subscription_spend_usd_cents = ?,\n lifetime_credits_spend_usd_cents = ?,\n lifetime_refund_usd_cents = ?,\n lifetime_net_spend_usd_cents = ?,\n lifetime_payment_count = ?,\n lifetime_refund_count = ?,\n first_payment_at = ?,\n first_spend_usd_cents = ?,\n last_payment_at = ?,\n last_spend_usd_cents = ?,\n days_since_first_payment = ?,\n days_since_last_payment = ?,\n net_spend_7d_usd_cents = ?,\n net_spend_prev_7d_usd_cents = ?,\n net_spend_14d_usd_cents = ?,\n net_spend_prev_14d_usd_cents = ?,\n net_spend_30d_usd_cents = ?,\n net_spend_prev_30d_usd_cents = ?,\n net_spend_60d_usd_cents = ?,\n net_spend_90d_usd_cents = ?,\n net_spend_this_year_usd_cents = ?,\n avg_weekly_net_spend_4w_usd_cents = ?,\n avg_weekly_net_spend_12w_usd_cents = ?,\n active_weeks_in_last_4 = ?,\n active_weeks_in_last_8 = ?,\n active_weeks_in_last_12 = ?,\n active_weeks_in_last_24 = ?,\n active_weeks_in_last_52 = ?,\n consecutive_active_weeks = ?,\n consecutive_inactive_weeks = ?,\n weeks_since_last_spend = ?,\n is_active_subscriber = ?,\n maybe_subscription_interval = ?,\n maybe_reengagement_score = ?\nON DUPLICATE KEY UPDATE\n lifetime_gross_spend_usd_cents = VALUES(lifetime_gross_spend_usd_cents),\n lifetime_subscription_spend_usd_cents = VALUES(lifetime_subscription_spend_usd_cents),\n lifetime_credits_spend_usd_cents = VALUES(lifetime_credits_spend_usd_cents),\n lifetime_refund_usd_cents = VALUES(lifetime_refund_usd_cents),\n lifetime_net_spend_usd_cents = VALUES(lifetime_net_spend_usd_cents),\n lifetime_payment_count = VALUES(lifetime_payment_count),\n lifetime_refund_count = VALUES(lifetime_refund_count),\n first_payment_at = VALUES(first_payment_at),\n first_spend_usd_cents = VALUES(first_spend_usd_cents),\n last_payment_at = VALUES(last_payment_at),\n last_spend_usd_cents = VALUES(last_spend_usd_cents),\n days_since_first_payment = VALUES(days_since_first_payment),\n days_since_last_payment = VALUES(days_since_last_payment),\n net_spend_7d_usd_cents = VALUES(net_spend_7d_usd_cents),\n net_spend_prev_7d_usd_cents = VALUES(net_spend_prev_7d_usd_cents),\n net_spend_14d_usd_cents = VALUES(net_spend_14d_usd_cents),\n net_spend_prev_14d_usd_cents = VALUES(net_spend_prev_14d_usd_cents),\n net_spend_30d_usd_cents = VALUES(net_spend_30d_usd_cents),\n net_spend_prev_30d_usd_cents = VALUES(net_spend_prev_30d_usd_cents),\n net_spend_60d_usd_cents = VALUES(net_spend_60d_usd_cents),\n net_spend_90d_usd_cents = VALUES(net_spend_90d_usd_cents),\n net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),\n avg_weekly_net_spend_4w_usd_cents = VALUES(avg_weekly_net_spend_4w_usd_cents),\n avg_weekly_net_spend_12w_usd_cents = VALUES(avg_weekly_net_spend_12w_usd_cents),\n active_weeks_in_last_4 = VALUES(active_weeks_in_last_4),\n active_weeks_in_last_8 = VALUES(active_weeks_in_last_8),\n active_weeks_in_last_12 = VALUES(active_weeks_in_last_12),\n active_weeks_in_last_24 = VALUES(active_weeks_in_last_24),\n active_weeks_in_last_52 = VALUES(active_weeks_in_last_52),\n consecutive_active_weeks = VALUES(consecutive_active_weeks),\n consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),\n weeks_since_last_spend = VALUES(weeks_since_last_spend),\n is_active_subscriber = VALUES(is_active_subscriber),\n maybe_subscription_interval = VALUES(maybe_subscription_interval),\n maybe_reengagement_score = VALUES(maybe_reengagement_score)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 37
},
"nullable": []
},
"hash": "41cf2f8d64aa36fb930cc1bf8a46d37e0987b743438b44e1c55859fc6b6ee6a0"
}
@@ -0,0 +1,124 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n s.user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n s.maybe_reengagement_score AS `reengagement_score!: u32`,\n s.lifetime_net_spend_usd_cents,\n s.last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,\n s.days_since_last_payment AS `maybe_days_since_last_payment: u32`,\n s.weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,\n s.is_active_subscriber AS `is_active_subscriber: bool`\nFROM user_spend_summaries s\nJOIN users u ON u.token = s.user_token\nWHERE s.payments_namespace = ?\n AND s.maybe_reengagement_score > 0\nORDER BY s.maybe_reengagement_score DESC, s.id ASC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_gravatar_hash!",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "reengagement_score!: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 5,
"name": "lifetime_net_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 6,
"name": "maybe_last_payment_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 7,
"name": "maybe_days_since_last_payment: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 8,
"name": "maybe_weeks_since_last_spend: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 9,
"name": "is_active_subscriber: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
true,
false,
true,
true,
true,
false
]
},
"hash": "5b1954301fc16703a04e2f63fd85c28bf2283bc1ecb5624107686dad0dfe364f"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n user_token AS `user_token!: UserToken`,\n spend_date AS `spend_date!: NaiveDate`,\n subscription_spend_usd_cents,\n credits_spend_usd_cents,\n gross_spend_usd_cents,\n refund_usd_cents,\n net_spend_usd_cents,\n payment_count,\n credits_granted,\n created_at AS `created_at: DateTime<Utc>`,\n updated_at AS `updated_at: DateTime<Utc>`\nFROM user_daily_spends\nWHERE user_token = ?\n AND payments_namespace = ?\nORDER BY spend_date DESC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "payments_namespace!: PaymentsNamespace",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "spend_date!: NaiveDate",
"type_info": {
"type": "Date",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 3,
"name": "subscription_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 4,
"name": "credits_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 5,
"name": "gross_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 6,
"name": "refund_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 7,
"name": "net_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 8,
"name": "payment_count",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 9,
"name": "credits_granted",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 10,
"name": "created_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 11,
"name": "updated_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "77dca63cffdd18957cbc4be6b7d982a2599a2c06df07f8e34501a032cd688ead"
}
@@ -0,0 +1,443 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n user_token AS `user_token!: UserToken`,\n lifetime_gross_spend_usd_cents,\n lifetime_subscription_spend_usd_cents,\n lifetime_credits_spend_usd_cents,\n lifetime_refund_usd_cents,\n lifetime_net_spend_usd_cents,\n lifetime_payment_count,\n lifetime_refund_count,\n first_payment_at AS `maybe_first_payment_at: DateTime<Utc>`,\n first_spend_usd_cents,\n last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,\n last_spend_usd_cents,\n days_since_first_payment AS `maybe_days_since_first_payment: u32`,\n days_since_last_payment AS `maybe_days_since_last_payment: u32`,\n net_spend_7d_usd_cents,\n net_spend_prev_7d_usd_cents,\n net_spend_14d_usd_cents,\n net_spend_prev_14d_usd_cents,\n net_spend_30d_usd_cents,\n net_spend_prev_30d_usd_cents,\n net_spend_60d_usd_cents,\n net_spend_90d_usd_cents,\n net_spend_this_year_usd_cents,\n avg_weekly_net_spend_4w_usd_cents,\n avg_weekly_net_spend_12w_usd_cents,\n active_weeks_in_last_4,\n active_weeks_in_last_8,\n active_weeks_in_last_12,\n active_weeks_in_last_24,\n active_weeks_in_last_52,\n consecutive_active_weeks,\n consecutive_inactive_weeks,\n weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,\n is_active_subscriber AS `is_active_subscriber: bool`,\n maybe_subscription_interval,\n maybe_reengagement_score AS `maybe_reengagement_score: u32`,\n created_at AS `created_at: DateTime<Utc>`,\n updated_at AS `updated_at: DateTime<Utc>`\nFROM user_spend_summaries\nWHERE payments_namespace = ?\n AND user_token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "payments_namespace!: PaymentsNamespace",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "lifetime_gross_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 3,
"name": "lifetime_subscription_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 4,
"name": "lifetime_credits_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 5,
"name": "lifetime_refund_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 6,
"name": "lifetime_net_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 7,
"name": "lifetime_payment_count",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 8,
"name": "lifetime_refund_count",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 9,
"name": "maybe_first_payment_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 10,
"name": "first_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 11,
"name": "maybe_last_payment_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 12,
"name": "last_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 13,
"name": "maybe_days_since_first_payment: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 14,
"name": "maybe_days_since_last_payment: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 15,
"name": "net_spend_7d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 16,
"name": "net_spend_prev_7d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 17,
"name": "net_spend_14d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 18,
"name": "net_spend_prev_14d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 19,
"name": "net_spend_30d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 20,
"name": "net_spend_prev_30d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 21,
"name": "net_spend_60d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 22,
"name": "net_spend_90d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 23,
"name": "net_spend_this_year_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 24,
"name": "avg_weekly_net_spend_4w_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 25,
"name": "avg_weekly_net_spend_12w_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 26,
"name": "active_weeks_in_last_4",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 27,
"name": "active_weeks_in_last_8",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 28,
"name": "active_weeks_in_last_12",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 29,
"name": "active_weeks_in_last_24",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 30,
"name": "active_weeks_in_last_52",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 3
}
},
{
"ordinal": 31,
"name": "consecutive_active_weeks",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 32,
"name": "consecutive_inactive_weeks",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 33,
"name": "maybe_weeks_since_last_spend: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 34,
"name": "is_active_subscriber: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 35,
"name": "maybe_subscription_interval",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 36,
"name": "maybe_reengagement_score: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 37,
"name": "created_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 38,
"name": "updated_at: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
true,
true,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true,
false,
false
]
},
"hash": "9715e4dc2ec6e596fdb91ecc50a2b80f63494bbc0c9df6c0a57ede2360716869"
}
@@ -0,0 +1,212 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n e.token AS `token!: UserSpendEventToken`,\n e.payments_namespace AS `payments_namespace!`,\n e.maybe_user_token AS `maybe_user_token: UserToken`,\n u.username AS `maybe_username?`,\n u.display_name AS `maybe_display_name?`,\n u.email_gravatar_hash AS `maybe_email_gravatar_hash?`,\n e.event_type AS `event_type!`,\n e.amount_usd_cents,\n e.maybe_credits_granted AS `maybe_credits_granted: u32`,\n e.payment_source AS `payment_source!`,\n e.source_object_id AS `maybe_source_object_id`,\n e.maybe_stripe_invoice_id,\n e.maybe_stripe_payment_intent_id,\n e.maybe_stripe_charge_id,\n e.maybe_stripe_customer_id,\n e.is_production AS `is_production: bool`,\n e.payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,\n e.created_at AS `created_at!: DateTime<Utc>`\nFROM user_spend_events e\nLEFT JOIN users u ON u.token = e.maybe_user_token\nWHERE (? IS NULL OR e.payments_namespace = ?)\nORDER BY e.payment_occurred_at DESC, e.id DESC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token!: UserSpendEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "payments_namespace!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "maybe_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_username?",
"type_info": {
"type": "VarString",
"flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "maybe_display_name?",
"type_info": {
"type": "VarString",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 5,
"name": "maybe_email_gravatar_hash?",
"type_info": {
"type": "String",
"flags": "BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "event_type!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "amount_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 8,
"name": "maybe_credits_granted: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
},
{
"ordinal": 9,
"name": "payment_source!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 10,
"name": "maybe_source_object_id",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 11,
"name": "maybe_stripe_invoice_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 12,
"name": "maybe_stripe_payment_intent_id",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 13,
"name": "maybe_stripe_charge_id",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 14,
"name": "maybe_stripe_customer_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 15,
"name": "is_production: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 16,
"name": "payment_occurred_at!: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 17,
"name": "created_at!: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
true,
true,
true,
true,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
false,
false
]
},
"hash": "d5d3a59ea4885868b26168cd305f6168a1e62fe9f8fa663063591335c645b927"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_spend_summaries\nSET\n payments_namespace = ?,\n user_token = ?,\n lifetime_gross_spend_usd_cents = ?,\n lifetime_subscription_spend_usd_cents = ?,\n lifetime_credits_spend_usd_cents = ?,\n lifetime_refund_usd_cents = ?,\n lifetime_net_spend_usd_cents = ?,\n lifetime_payment_count = ?,\n lifetime_refund_count = ?,\n first_payment_at = ?,\n first_spend_usd_cents = ?,\n last_payment_at = ?,\n last_spend_usd_cents = ?,\n days_since_first_payment = ?,\n days_since_last_payment = ?,\n net_spend_this_year_usd_cents = ?,\n consecutive_active_weeks = ?,\n consecutive_inactive_weeks = ?,\n weeks_since_last_spend = ?,\n is_active_subscriber = ?,\n maybe_subscription_interval = ?,\n maybe_reengagement_score = ?\nON DUPLICATE KEY UPDATE\n lifetime_gross_spend_usd_cents = VALUES(lifetime_gross_spend_usd_cents),\n lifetime_subscription_spend_usd_cents = VALUES(lifetime_subscription_spend_usd_cents),\n lifetime_credits_spend_usd_cents = VALUES(lifetime_credits_spend_usd_cents),\n lifetime_refund_usd_cents = VALUES(lifetime_refund_usd_cents),\n lifetime_net_spend_usd_cents = VALUES(lifetime_net_spend_usd_cents),\n lifetime_payment_count = VALUES(lifetime_payment_count),\n lifetime_refund_count = VALUES(lifetime_refund_count),\n first_payment_at = VALUES(first_payment_at),\n first_spend_usd_cents = VALUES(first_spend_usd_cents),\n last_payment_at = VALUES(last_payment_at),\n last_spend_usd_cents = VALUES(last_spend_usd_cents),\n days_since_first_payment = VALUES(days_since_first_payment),\n days_since_last_payment = VALUES(days_since_last_payment),\n net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),\n consecutive_active_weeks = VALUES(consecutive_active_weeks),\n consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),\n weeks_since_last_spend = VALUES(weeks_since_last_spend),\n is_active_subscriber = VALUES(is_active_subscriber),\n maybe_subscription_interval = VALUES(maybe_subscription_interval),\n maybe_reengagement_score = VALUES(maybe_reengagement_score)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 22
},
"nullable": []
},
"hash": "f4df0d23d95aded2f55d464344b32a6db63e406cff009325ce92dbdd0932809c"
}
@@ -0,0 +1,157 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n s.user_token AS `user_token!: UserToken`,\n u.username AS `username!`,\n u.display_name AS `display_name!`,\n u.email_gravatar_hash AS `email_gravatar_hash!`,\n s.lifetime_net_spend_usd_cents,\n s.net_spend_7d_usd_cents,\n s.net_spend_14d_usd_cents,\n s.net_spend_30d_usd_cents,\n s.net_spend_60d_usd_cents,\n s.net_spend_90d_usd_cents,\n s.net_spend_this_year_usd_cents,\n s.is_active_subscriber AS `is_active_subscriber: bool`,\n s.maybe_reengagement_score AS `maybe_reengagement_score: u32`\nFROM user_spend_summaries s\nJOIN users u ON u.token = s.user_token\nWHERE s.payments_namespace = ?\nORDER BY\n (CASE ?\n WHEN '7d' THEN s.net_spend_7d_usd_cents\n WHEN '14d' THEN s.net_spend_14d_usd_cents\n WHEN '30d' THEN s.net_spend_30d_usd_cents\n WHEN '60d' THEN s.net_spend_60d_usd_cents\n WHEN '90d' THEN s.net_spend_90d_usd_cents\n WHEN '1y' THEN s.net_spend_this_year_usd_cents\n ELSE CAST(s.lifetime_net_spend_usd_cents AS SIGNED)\n END) DESC,\n s.id ASC\nLIMIT ? OFFSET ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "username!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "display_name!",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "email_gravatar_hash!",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "lifetime_net_spend_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 5,
"name": "net_spend_7d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 6,
"name": "net_spend_14d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 7,
"name": "net_spend_30d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 8,
"name": "net_spend_60d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 9,
"name": "net_spend_90d_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 10,
"name": "net_spend_this_year_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 11,
"name": "is_active_subscriber: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 12,
"name": "maybe_reengagement_score: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "f6db2f3bf8695fa8431db209576328154b8ac20aa6e899739095b6206bb38944"
}
@@ -5,3 +5,6 @@ pub mod user_referrals;
pub mod user_stripe_data;
pub mod wallet_ledger_entries;
pub mod wallets;
pub mod user_daily_spends;
pub mod user_spend_summaries;
pub mod user_spend_events;
@@ -0,0 +1 @@
pub mod user_daily_spends_list;
@@ -0,0 +1,41 @@
use chrono::{DateTime, NaiveDate, Utc};
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
pub const MODERATOR_USER_DAILY_SPENDS_PATH: &str =
"/v1/moderation/user_daily_spends/user/{user_token}";
#[derive(Deserialize, IntoParams)]
pub struct ModeratorUserDailySpendsQueryParams {
/// Page size (default 200, max 5000).
pub limit: Option<u32>,
/// Row offset for pagination. Pass `next_offset` from the previous page.
pub offset: Option<u64>,
/// Payments namespace. Defaults to `artcraft`.
pub payments_namespace: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorUserDailySpendsResponse {
pub success: bool,
pub user_token: UserToken,
pub records: Vec<UserDailySpendEntry>,
/// Offset for the next page, or `None` if this was the last page.
pub maybe_next_offset: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct UserDailySpendEntry {
pub payments_namespace: String,
pub spend_date: NaiveDate,
pub subscription_spend_usd_cents: u64,
pub credits_spend_usd_cents: u64,
pub gross_spend_usd_cents: u64,
pub refund_usd_cents: u64,
pub net_spend_usd_cents: i64,
pub payment_count: u32,
pub credits_granted: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1,46 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::user_spend_events::UserSpendEventToken;
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
pub const MODERATOR_LIST_USER_SPEND_EVENTS_PATH: &str =
"/v1/moderation/user_spend_events/list";
#[derive(Deserialize, IntoParams)]
pub struct ModeratorListUserSpendEventsQueryParams {
/// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page.
pub offset: Option<u64>,
/// Optional namespace filter (e.g. `artcraft`). Omit to return all namespaces.
pub payments_namespace: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorListUserSpendEventsResponse {
pub success: bool,
pub events: Vec<UserSpendEventListEntry>,
/// Offset for the next page, or `None` if this was the last page.
pub maybe_next_offset: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct UserSpendEventListEntry {
pub token: UserSpendEventToken,
pub payments_namespace: String,
pub maybe_user_token: Option<UserToken>,
pub maybe_username: Option<String>,
pub maybe_display_name: Option<String>,
pub maybe_email_gravatar_hash: Option<String>,
pub event_type: String,
pub amount_usd_cents: i64,
pub maybe_credits_granted: Option<u32>,
pub payment_source: String,
pub maybe_source_object_id: Option<String>,
pub maybe_stripe_invoice_id: Option<String>,
pub maybe_stripe_payment_intent_id: Option<String>,
pub maybe_stripe_charge_id: Option<String>,
pub maybe_stripe_customer_id: Option<String>,
pub is_production: bool,
pub payment_occurred_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
@@ -0,0 +1,69 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
pub const MODERATOR_GET_USER_SPEND_SUMMARY_PATH: &str =
"/v1/moderation/user_spend_summaries/summary/{user_token}";
#[derive(Deserialize, IntoParams)]
pub struct ModeratorGetUserSpendSummaryQueryParams {
/// Payments namespace. Defaults to `artcraft`.
pub payments_namespace: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorGetUserSpendSummaryResponse {
pub success: bool,
/// `None` if the user has no spend summary row.
pub maybe_summary: Option<UserSpendSummaryView>,
}
/// The full `user_spend_summaries` record for a user.
#[derive(Serialize, ToSchema)]
pub struct UserSpendSummaryView {
pub payments_namespace: String,
pub user_token: UserToken,
pub lifetime_gross_spend_usd_cents: u64,
pub lifetime_subscription_spend_usd_cents: u64,
pub lifetime_credits_spend_usd_cents: u64,
pub lifetime_refund_usd_cents: u64,
pub lifetime_net_spend_usd_cents: u64,
pub lifetime_payment_count: u32,
pub lifetime_refund_count: u32,
pub maybe_first_payment_at: Option<DateTime<Utc>>,
pub first_spend_usd_cents: u64,
pub maybe_last_payment_at: Option<DateTime<Utc>>,
pub last_spend_usd_cents: u64,
pub maybe_days_since_first_payment: Option<u32>,
pub maybe_days_since_last_payment: Option<u32>,
pub net_spend_7d_usd_cents: i64,
pub net_spend_prev_7d_usd_cents: i64,
pub net_spend_14d_usd_cents: i64,
pub net_spend_prev_14d_usd_cents: i64,
pub net_spend_30d_usd_cents: i64,
pub net_spend_prev_30d_usd_cents: i64,
pub net_spend_60d_usd_cents: i64,
pub net_spend_90d_usd_cents: i64,
pub net_spend_this_year_usd_cents: i64,
pub avg_weekly_net_spend_4w_usd_cents: i64,
pub avg_weekly_net_spend_12w_usd_cents: i64,
pub active_weeks_in_last_4: u8,
pub active_weeks_in_last_8: u8,
pub active_weeks_in_last_12: u8,
pub active_weeks_in_last_24: u8,
pub active_weeks_in_last_52: u8,
pub consecutive_active_weeks: u32,
pub consecutive_inactive_weeks: u32,
pub maybe_weeks_since_last_spend: Option<u32>,
pub is_active_subscriber: bool,
pub maybe_subscription_interval: Option<String>,
pub maybe_reengagement_score: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1,3 @@
pub mod get_summary;
pub mod reengagement_list;
pub mod top_users_list;
@@ -0,0 +1,37 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
pub const MODERATOR_REENGAGEMENT_LIST_PATH: &str =
"/v1/moderation/user_spend_summaries/reengagement_list";
#[derive(Deserialize, IntoParams)]
pub struct ModeratorReengagementListQueryParams {
/// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page.
pub offset: Option<u64>,
/// Payments namespace. Defaults to `artcraft`.
pub payments_namespace: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorReengagementListResponse {
pub success: bool,
pub candidates: Vec<ReengagementCandidateEntry>,
/// Offset for the next page, or `None` if this was the last page.
pub maybe_next_offset: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct ReengagementCandidateEntry {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
pub reengagement_score: u32,
pub lifetime_net_spend_usd_cents: u64,
pub maybe_last_payment_at: Option<DateTime<Utc>>,
pub maybe_days_since_last_payment: Option<u32>,
pub maybe_weeks_since_last_spend: Option<u32>,
pub is_active_subscriber: bool,
}
@@ -0,0 +1,77 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
pub const MODERATOR_TOP_USERS_LIST_PATH: &str =
"/v1/moderation/user_spend_summaries/top_users_list";
/// Net-spend window that selects which column `top_users_list` sorts by.
#[derive(Deserialize, Serialize, Clone, Copy, ToSchema)]
pub enum TopUsersWindow {
#[serde(rename = "7d")]
SevenDays,
#[serde(rename = "14d")]
FourteenDays,
#[serde(rename = "30d")]
ThirtyDays,
#[serde(rename = "60d")]
SixtyDays,
#[serde(rename = "90d")]
NinetyDays,
#[serde(rename = "1y")]
OneYear,
#[serde(rename = "lifetime")]
Lifetime,
}
impl TopUsersWindow {
/// The `window` key the `list_top_users_by_net_spend` query matches on.
pub fn as_query_key(self) -> &'static str {
match self {
TopUsersWindow::SevenDays => "7d",
TopUsersWindow::FourteenDays => "14d",
TopUsersWindow::ThirtyDays => "30d",
TopUsersWindow::SixtyDays => "60d",
TopUsersWindow::NinetyDays => "90d",
TopUsersWindow::OneYear => "1y",
TopUsersWindow::Lifetime => "lifetime",
}
}
}
#[derive(Deserialize, IntoParams)]
pub struct ModeratorTopUsersListQueryParams {
/// Sort window: `7d`, `14d`, `30d`, `60d`, `90d`, `1y`, or `lifetime` (default).
pub window: Option<TopUsersWindow>,
/// Row offset for pagination (page size is fixed at 200). Pass `next_offset` from the previous page.
pub offset: Option<u64>,
/// Payments namespace. Defaults to `artcraft`.
pub payments_namespace: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorTopUsersListResponse {
pub success: bool,
/// The window that was sorted by (echo of the request).
pub window: String,
pub users: Vec<TopUserEntry>,
/// Offset for the next page, or `None` if this was the last page.
pub maybe_next_offset: Option<u64>,
}
#[derive(Serialize, ToSchema)]
pub struct TopUserEntry {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
pub lifetime_net_spend_usd_cents: u64,
pub net_spend_7d_usd_cents: i64,
pub net_spend_14d_usd_cents: i64,
pub net_spend_30d_usd_cents: i64,
pub net_spend_60d_usd_cents: i64,
pub net_spend_90d_usd_cents: i64,
pub net_spend_this_year_usd_cents: i64,
pub is_active_subscriber: bool,
pub maybe_reengagement_score: Option<u32>,
}
@@ -0,0 +1,75 @@
use std::marker::PhantomData;
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::{Executor, MySql};
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// One day of a user's spend activity (a `user_daily_spends` row).
pub struct UserDailySpendRow {
pub payments_namespace: PaymentsNamespace,
pub user_token: UserToken,
pub spend_date: NaiveDate,
pub subscription_spend_usd_cents: u64,
pub credits_spend_usd_cents: u64,
pub gross_spend_usd_cents: u64,
pub refund_usd_cents: u64,
pub net_spend_usd_cents: i64,
pub payment_count: u32,
pub credits_granted: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub struct ListUserDailySpendsArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub user_token: &'e UserToken,
pub payments_namespace: PaymentsNamespace,
pub limit: i64,
pub offset: i64,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// A user's daily spend rows, most-recent payment date first.
pub async fn list_user_daily_spends<'e, 'c: 'e, E>(
args: ListUserDailySpendsArgs<'e, 'c, E>,
) -> Result<Vec<UserDailySpendRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query_as!(
UserDailySpendRow,
r#"
SELECT
payments_namespace AS `payments_namespace!: PaymentsNamespace`,
user_token AS `user_token!: UserToken`,
spend_date AS `spend_date!: NaiveDate`,
subscription_spend_usd_cents,
credits_spend_usd_cents,
gross_spend_usd_cents,
refund_usd_cents,
net_spend_usd_cents,
payment_count,
credits_granted,
created_at AS `created_at: DateTime<Utc>`,
updated_at AS `updated_at: DateTime<Utc>`
FROM user_daily_spends
WHERE user_token = ?
AND payments_namespace = ?
ORDER BY spend_date DESC
LIMIT ? OFFSET ?
"#,
args.user_token.as_str(),
args.payments_namespace.to_str(),
args.limit,
args.offset,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -1,2 +1,3 @@
pub mod list_user_daily_spends;
pub mod aggregate_daily_spends_for_date;
pub mod upsert_user_daily_spend;
@@ -0,0 +1,87 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::user_spend_events::UserSpendEventToken;
use tokens::tokens::users::UserToken;
/// A spend event joined (left) to its user's display info for the moderation list.
pub struct UserSpendEventListItem {
pub token: UserSpendEventToken,
pub payments_namespace: String,
pub maybe_user_token: Option<UserToken>,
pub maybe_username: Option<String>,
pub maybe_display_name: Option<String>,
pub maybe_email_gravatar_hash: Option<String>,
pub event_type: String,
pub amount_usd_cents: i64,
pub maybe_credits_granted: Option<u32>,
pub payment_source: String,
pub maybe_source_object_id: Option<String>,
pub maybe_stripe_invoice_id: Option<String>,
pub maybe_stripe_payment_intent_id: Option<String>,
pub maybe_stripe_charge_id: Option<String>,
pub maybe_stripe_customer_id: Option<String>,
pub is_production: bool,
pub payment_occurred_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
pub struct ListUserSpendEventsForModerationArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// Optional namespace filter. `None` returns all namespaces.
pub maybe_payments_namespace: Option<&'e str>,
pub limit: i64,
pub offset: i64,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// List spend events, most recent payment date first, with the spender's display info.
pub async fn list_user_spend_events_for_moderation<'e, 'c: 'e, E>(
args: ListUserSpendEventsForModerationArgs<'e, 'c, E>,
) -> Result<Vec<UserSpendEventListItem>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query_as!(
UserSpendEventListItem,
r#"
SELECT
e.token AS `token!: UserSpendEventToken`,
e.payments_namespace AS `payments_namespace!`,
e.maybe_user_token AS `maybe_user_token: UserToken`,
u.username AS `maybe_username?`,
u.display_name AS `maybe_display_name?`,
u.email_gravatar_hash AS `maybe_email_gravatar_hash?`,
e.event_type AS `event_type!`,
e.amount_usd_cents,
e.maybe_credits_granted AS `maybe_credits_granted: u32`,
e.payment_source AS `payment_source!`,
e.source_object_id AS `maybe_source_object_id`,
e.maybe_stripe_invoice_id,
e.maybe_stripe_payment_intent_id,
e.maybe_stripe_charge_id,
e.maybe_stripe_customer_id,
e.is_production AS `is_production: bool`,
e.payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,
e.created_at AS `created_at!: DateTime<Utc>`
FROM user_spend_events e
LEFT JOIN users u ON u.token = e.maybe_user_token
WHERE (? IS NULL OR e.payments_namespace = ?)
ORDER BY e.payment_occurred_at DESC, e.id DESC
LIMIT ? OFFSET ?
"#,
args.maybe_payments_namespace,
args.maybe_payments_namespace,
args.limit,
args.offset,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -1,3 +1,4 @@
pub mod list_user_spend_events_for_moderation;
pub mod backfill_get_user_token_by_email;
pub mod backfill_list_artcraft_customer_links;
pub mod backfill_list_artcraft_ledger_payments;
@@ -0,0 +1,123 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// The full `user_spend_summaries` record for one (namespace, user).
pub struct UserSpendSummaryRecord {
pub payments_namespace: PaymentsNamespace,
pub user_token: UserToken,
pub lifetime_gross_spend_usd_cents: u64,
pub lifetime_subscription_spend_usd_cents: u64,
pub lifetime_credits_spend_usd_cents: u64,
pub lifetime_refund_usd_cents: u64,
pub lifetime_net_spend_usd_cents: u64,
pub lifetime_payment_count: u32,
pub lifetime_refund_count: u32,
pub maybe_first_payment_at: Option<DateTime<Utc>>,
pub first_spend_usd_cents: u64,
pub maybe_last_payment_at: Option<DateTime<Utc>>,
pub last_spend_usd_cents: u64,
pub maybe_days_since_first_payment: Option<u32>,
pub maybe_days_since_last_payment: Option<u32>,
pub net_spend_7d_usd_cents: i64,
pub net_spend_prev_7d_usd_cents: i64,
pub net_spend_14d_usd_cents: i64,
pub net_spend_prev_14d_usd_cents: i64,
pub net_spend_30d_usd_cents: i64,
pub net_spend_prev_30d_usd_cents: i64,
pub net_spend_60d_usd_cents: i64,
pub net_spend_90d_usd_cents: i64,
pub net_spend_this_year_usd_cents: i64,
pub avg_weekly_net_spend_4w_usd_cents: i64,
pub avg_weekly_net_spend_12w_usd_cents: i64,
pub active_weeks_in_last_4: u8,
pub active_weeks_in_last_8: u8,
pub active_weeks_in_last_12: u8,
pub active_weeks_in_last_24: u8,
pub active_weeks_in_last_52: u8,
pub consecutive_active_weeks: u32,
pub consecutive_inactive_weeks: u32,
pub maybe_weeks_since_last_spend: Option<u32>,
pub is_active_subscriber: bool,
pub maybe_subscription_interval: Option<String>,
pub maybe_reengagement_score: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub struct GetUserSpendSummaryArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub payments_namespace: PaymentsNamespace,
pub user_token: &'e UserToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn get_user_spend_summary<'e, 'c: 'e, E>(
args: GetUserSpendSummaryArgs<'e, 'c, E>,
) -> Result<Option<UserSpendSummaryRecord>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let row = sqlx::query_as!(
UserSpendSummaryRecord,
r#"
SELECT
payments_namespace AS `payments_namespace!: PaymentsNamespace`,
user_token AS `user_token!: UserToken`,
lifetime_gross_spend_usd_cents,
lifetime_subscription_spend_usd_cents,
lifetime_credits_spend_usd_cents,
lifetime_refund_usd_cents,
lifetime_net_spend_usd_cents,
lifetime_payment_count,
lifetime_refund_count,
first_payment_at AS `maybe_first_payment_at: DateTime<Utc>`,
first_spend_usd_cents,
last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,
last_spend_usd_cents,
days_since_first_payment AS `maybe_days_since_first_payment: u32`,
days_since_last_payment AS `maybe_days_since_last_payment: u32`,
net_spend_7d_usd_cents,
net_spend_prev_7d_usd_cents,
net_spend_14d_usd_cents,
net_spend_prev_14d_usd_cents,
net_spend_30d_usd_cents,
net_spend_prev_30d_usd_cents,
net_spend_60d_usd_cents,
net_spend_90d_usd_cents,
net_spend_this_year_usd_cents,
avg_weekly_net_spend_4w_usd_cents,
avg_weekly_net_spend_12w_usd_cents,
active_weeks_in_last_4,
active_weeks_in_last_8,
active_weeks_in_last_12,
active_weeks_in_last_24,
active_weeks_in_last_52,
consecutive_active_weeks,
consecutive_inactive_weeks,
weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,
is_active_subscriber AS `is_active_subscriber: bool`,
maybe_subscription_interval,
maybe_reengagement_score AS `maybe_reengagement_score: u32`,
created_at AS `created_at: DateTime<Utc>`,
updated_at AS `updated_at: DateTime<Utc>`
FROM user_spend_summaries
WHERE payments_namespace = ?
AND user_token = ?
LIMIT 1
"#,
args.payments_namespace.to_str(),
args.user_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(row)
}
@@ -0,0 +1,69 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
/// A re-engagement candidate: a lapsed spender (score > 0) joined to display info.
pub struct ReengagementCandidate {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
pub reengagement_score: u32,
pub lifetime_net_spend_usd_cents: u64,
pub maybe_last_payment_at: Option<DateTime<Utc>>,
pub maybe_days_since_last_payment: Option<u32>,
pub maybe_weeks_since_last_spend: Option<u32>,
pub is_active_subscriber: bool,
}
pub struct ListReengagementCandidatesArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub payments_namespace: &'e str,
pub limit: i64,
pub offset: i64,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Users that need re-engagement, highest re-engagement score first.
pub async fn list_reengagement_candidates<'e, 'c: 'e, E>(
args: ListReengagementCandidatesArgs<'e, 'c, E>,
) -> Result<Vec<ReengagementCandidate>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query_as!(
ReengagementCandidate,
r#"
SELECT
s.user_token AS `user_token!: UserToken`,
u.username AS `username!`,
u.display_name AS `display_name!`,
u.email_gravatar_hash AS `email_gravatar_hash!`,
s.maybe_reengagement_score AS `reengagement_score!: u32`,
s.lifetime_net_spend_usd_cents,
s.last_payment_at AS `maybe_last_payment_at: DateTime<Utc>`,
s.days_since_last_payment AS `maybe_days_since_last_payment: u32`,
s.weeks_since_last_spend AS `maybe_weeks_since_last_spend: u32`,
s.is_active_subscriber AS `is_active_subscriber: bool`
FROM user_spend_summaries s
JOIN users u ON u.token = s.user_token
WHERE s.payments_namespace = ?
AND s.maybe_reengagement_score > 0
ORDER BY s.maybe_reengagement_score DESC, s.id ASC
LIMIT ? OFFSET ?
"#,
args.payments_namespace,
args.limit,
args.offset,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -0,0 +1,87 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
/// A top spender joined to display info, with all net-spend windows so the
/// caller can render the one it sorted by.
pub struct TopUserByNetSpend {
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
pub lifetime_net_spend_usd_cents: u64,
pub net_spend_7d_usd_cents: i64,
pub net_spend_14d_usd_cents: i64,
pub net_spend_30d_usd_cents: i64,
pub net_spend_60d_usd_cents: i64,
pub net_spend_90d_usd_cents: i64,
pub net_spend_this_year_usd_cents: i64,
pub is_active_subscriber: bool,
pub maybe_reengagement_score: Option<u32>,
}
pub struct ListTopUsersByNetSpendArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub payments_namespace: &'e str,
/// One of: 7d, 14d, 30d, 60d, 90d, 1y, lifetime. Selects the sort column.
pub window: &'e str,
pub limit: i64,
pub offset: i64,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Top spenders for a namespace, sorted by net spend over the requested window.
pub async fn list_top_users_by_net_spend<'e, 'c: 'e, E>(
args: ListTopUsersByNetSpendArgs<'e, 'c, E>,
) -> Result<Vec<TopUserByNetSpend>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query_as!(
TopUserByNetSpend,
r#"
SELECT
s.user_token AS `user_token!: UserToken`,
u.username AS `username!`,
u.display_name AS `display_name!`,
u.email_gravatar_hash AS `email_gravatar_hash!`,
s.lifetime_net_spend_usd_cents,
s.net_spend_7d_usd_cents,
s.net_spend_14d_usd_cents,
s.net_spend_30d_usd_cents,
s.net_spend_60d_usd_cents,
s.net_spend_90d_usd_cents,
s.net_spend_this_year_usd_cents,
s.is_active_subscriber AS `is_active_subscriber: bool`,
s.maybe_reengagement_score AS `maybe_reengagement_score: u32`
FROM user_spend_summaries s
JOIN users u ON u.token = s.user_token
WHERE s.payments_namespace = ?
ORDER BY
(CASE ?
WHEN '7d' THEN s.net_spend_7d_usd_cents
WHEN '14d' THEN s.net_spend_14d_usd_cents
WHEN '30d' THEN s.net_spend_30d_usd_cents
WHEN '60d' THEN s.net_spend_60d_usd_cents
WHEN '90d' THEN s.net_spend_90d_usd_cents
WHEN '1y' THEN s.net_spend_this_year_usd_cents
ELSE CAST(s.lifetime_net_spend_usd_cents AS SIGNED)
END) DESC,
s.id ASC
LIMIT ? OFFSET ?
"#,
args.payments_namespace,
args.window,
args.limit,
args.offset,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -1,3 +1,6 @@
pub mod list_top_users_by_net_spend;
pub mod list_reengagement_candidates;
pub mod get_user_spend_summary;
pub mod list_payments_namespaces_with_spend_activity;
pub mod list_user_tokens_with_spend_activity;
pub mod list_user_spend_events_for_user;
@@ -16,6 +16,7 @@ where
pub payments_namespace: PaymentsNamespace,
pub user_token: &'e UserToken,
// Lifetime
pub lifetime_gross_spend_usd_cents: u64,
pub lifetime_subscription_spend_usd_cents: u64,
pub lifetime_credits_spend_usd_cents: u64,
@@ -23,7 +24,6 @@ where
pub lifetime_net_spend_usd_cents: u64,
pub lifetime_payment_count: u32,
pub lifetime_refund_count: u32,
pub maybe_first_payment_at: Option<DateTime<Utc>>,
pub first_spend_usd_cents: u64,
pub maybe_last_payment_at: Option<DateTime<Utc>>,
@@ -31,12 +31,30 @@ where
pub maybe_days_since_first_payment: Option<u32>,
pub maybe_days_since_last_payment: Option<u32>,
// Sliding NET windows (signed)
pub net_spend_7d_usd_cents: i64,
pub net_spend_prev_7d_usd_cents: i64,
pub net_spend_14d_usd_cents: i64,
pub net_spend_prev_14d_usd_cents: i64,
pub net_spend_30d_usd_cents: i64,
pub net_spend_prev_30d_usd_cents: i64,
pub net_spend_60d_usd_cents: i64,
pub net_spend_90d_usd_cents: i64,
pub net_spend_this_year_usd_cents: i64,
// Weekly cadence / rhythm
pub avg_weekly_net_spend_4w_usd_cents: i64,
pub avg_weekly_net_spend_12w_usd_cents: i64,
pub active_weeks_in_last_4: u8,
pub active_weeks_in_last_8: u8,
pub active_weeks_in_last_12: u8,
pub active_weeks_in_last_24: u8,
pub active_weeks_in_last_52: u8,
pub consecutive_active_weeks: u32,
pub consecutive_inactive_weeks: u32,
pub maybe_weeks_since_last_spend: Option<u32>,
// Flags
pub is_active_subscriber: bool,
pub maybe_subscription_interval: Option<&'e str>,
pub maybe_reengagement_score: Option<u32>,
@@ -70,7 +88,22 @@ SET
last_spend_usd_cents = ?,
days_since_first_payment = ?,
days_since_last_payment = ?,
net_spend_7d_usd_cents = ?,
net_spend_prev_7d_usd_cents = ?,
net_spend_14d_usd_cents = ?,
net_spend_prev_14d_usd_cents = ?,
net_spend_30d_usd_cents = ?,
net_spend_prev_30d_usd_cents = ?,
net_spend_60d_usd_cents = ?,
net_spend_90d_usd_cents = ?,
net_spend_this_year_usd_cents = ?,
avg_weekly_net_spend_4w_usd_cents = ?,
avg_weekly_net_spend_12w_usd_cents = ?,
active_weeks_in_last_4 = ?,
active_weeks_in_last_8 = ?,
active_weeks_in_last_12 = ?,
active_weeks_in_last_24 = ?,
active_weeks_in_last_52 = ?,
consecutive_active_weeks = ?,
consecutive_inactive_weeks = ?,
weeks_since_last_spend = ?,
@@ -91,7 +124,22 @@ ON DUPLICATE KEY UPDATE
last_spend_usd_cents = VALUES(last_spend_usd_cents),
days_since_first_payment = VALUES(days_since_first_payment),
days_since_last_payment = VALUES(days_since_last_payment),
net_spend_7d_usd_cents = VALUES(net_spend_7d_usd_cents),
net_spend_prev_7d_usd_cents = VALUES(net_spend_prev_7d_usd_cents),
net_spend_14d_usd_cents = VALUES(net_spend_14d_usd_cents),
net_spend_prev_14d_usd_cents = VALUES(net_spend_prev_14d_usd_cents),
net_spend_30d_usd_cents = VALUES(net_spend_30d_usd_cents),
net_spend_prev_30d_usd_cents = VALUES(net_spend_prev_30d_usd_cents),
net_spend_60d_usd_cents = VALUES(net_spend_60d_usd_cents),
net_spend_90d_usd_cents = VALUES(net_spend_90d_usd_cents),
net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),
avg_weekly_net_spend_4w_usd_cents = VALUES(avg_weekly_net_spend_4w_usd_cents),
avg_weekly_net_spend_12w_usd_cents = VALUES(avg_weekly_net_spend_12w_usd_cents),
active_weeks_in_last_4 = VALUES(active_weeks_in_last_4),
active_weeks_in_last_8 = VALUES(active_weeks_in_last_8),
active_weeks_in_last_12 = VALUES(active_weeks_in_last_12),
active_weeks_in_last_24 = VALUES(active_weeks_in_last_24),
active_weeks_in_last_52 = VALUES(active_weeks_in_last_52),
consecutive_active_weeks = VALUES(consecutive_active_weeks),
consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),
weeks_since_last_spend = VALUES(weeks_since_last_spend),
@@ -114,7 +162,22 @@ ON DUPLICATE KEY UPDATE
args.last_spend_usd_cents,
args.maybe_days_since_first_payment,
args.maybe_days_since_last_payment,
args.net_spend_7d_usd_cents,
args.net_spend_prev_7d_usd_cents,
args.net_spend_14d_usd_cents,
args.net_spend_prev_14d_usd_cents,
args.net_spend_30d_usd_cents,
args.net_spend_prev_30d_usd_cents,
args.net_spend_60d_usd_cents,
args.net_spend_90d_usd_cents,
args.net_spend_this_year_usd_cents,
args.avg_weekly_net_spend_4w_usd_cents,
args.avg_weekly_net_spend_12w_usd_cents,
args.active_weeks_in_last_4,
args.active_weeks_in_last_8,
args.active_weeks_in_last_12,
args.active_weeks_in_last_24,
args.active_weeks_in_last_52,
args.consecutive_active_weeks,
args.consecutive_inactive_weeks,
args.maybe_weeks_since_last_spend,
@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::marker::PhantomData;
use chrono::{DateTime, Datelike, Utc};
use chrono::{DateTime, Datelike, Duration, Utc};
use log::{error, info};
use enums::by_table::user_spend_events::payment_event_type::PaymentEventType;
@@ -87,7 +87,7 @@ async fn backfill_namespace(deps: &JobDependencies, namespace: PaymentsNamespace
error!("User-summaries: listing users for {} failed: {err:?}", namespace.to_str());
alert_pager(&deps.pager, "user-spend-analytics: user list error", &format!("ns={}: {err:?}", namespace.to_str()));
let _ = deps.job_stats.increment_failure_count();
return upserted; // can't paginate further this cycle
return upserted;
}
};
@@ -181,7 +181,22 @@ async fn process_user(
last_spend_usd_cents: aggregate.last_spend_usd_cents,
maybe_days_since_first_payment: aggregate.maybe_days_since_first_payment,
maybe_days_since_last_payment: aggregate.maybe_days_since_last_payment,
net_spend_7d_usd_cents: aggregate.net_spend_7d_usd_cents,
net_spend_prev_7d_usd_cents: aggregate.net_spend_prev_7d_usd_cents,
net_spend_14d_usd_cents: aggregate.net_spend_14d_usd_cents,
net_spend_prev_14d_usd_cents: aggregate.net_spend_prev_14d_usd_cents,
net_spend_30d_usd_cents: aggregate.net_spend_30d_usd_cents,
net_spend_prev_30d_usd_cents: aggregate.net_spend_prev_30d_usd_cents,
net_spend_60d_usd_cents: aggregate.net_spend_60d_usd_cents,
net_spend_90d_usd_cents: aggregate.net_spend_90d_usd_cents,
net_spend_this_year_usd_cents: aggregate.net_spend_this_year_usd_cents,
avg_weekly_net_spend_4w_usd_cents: aggregate.avg_weekly_net_spend_4w_usd_cents,
avg_weekly_net_spend_12w_usd_cents: aggregate.avg_weekly_net_spend_12w_usd_cents,
active_weeks_in_last_4: aggregate.active_weeks_in_last_4,
active_weeks_in_last_8: aggregate.active_weeks_in_last_8,
active_weeks_in_last_12: aggregate.active_weeks_in_last_12,
active_weeks_in_last_24: aggregate.active_weeks_in_last_24,
active_weeks_in_last_52: aggregate.active_weeks_in_last_52,
consecutive_active_weeks: aggregate.consecutive_active_weeks,
consecutive_inactive_weeks: aggregate.consecutive_inactive_weeks,
maybe_weeks_since_last_spend: aggregate.maybe_weeks_since_last_spend,
@@ -211,17 +226,36 @@ struct SummaryAggregate {
last_spend_usd_cents: u64,
maybe_days_since_first_payment: Option<u32>,
maybe_days_since_last_payment: Option<u32>,
net_spend_7d_usd_cents: i64,
net_spend_prev_7d_usd_cents: i64,
net_spend_14d_usd_cents: i64,
net_spend_prev_14d_usd_cents: i64,
net_spend_30d_usd_cents: i64,
net_spend_prev_30d_usd_cents: i64,
net_spend_60d_usd_cents: i64,
net_spend_90d_usd_cents: i64,
net_spend_this_year_usd_cents: i64,
avg_weekly_net_spend_4w_usd_cents: i64,
avg_weekly_net_spend_12w_usd_cents: i64,
active_weeks_in_last_4: u8,
active_weeks_in_last_8: u8,
active_weeks_in_last_12: u8,
active_weeks_in_last_24: u8,
active_weeks_in_last_52: u8,
consecutive_active_weeks: u32,
consecutive_inactive_weeks: u32,
maybe_weeks_since_last_spend: Option<u32>,
}
/// Aggregate one user's events (assumed ascending by `payment_occurred_at`).
/// "Positive" events are payments; negatives are refunds/chargebacks. Weekly
/// cadence uses 7-day buckets relative to `now` (week 0 = the last 7 days).
/// Aggregate one user's events (ascending by `payment_occurred_at`).
///
/// - Lifetime totals + first/last from positive (payment) and negative (refund) events.
/// - Sliding NET windows are day-rolling relative to `now` (7d = last 7×24h, etc.).
/// - Weekly cadence (avg-weekly, active-weeks, streaks) uses Mon–Sun UTC calendar
/// weeks; week 0 is the calendar week containing `now`.
fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryAggregate {
let current_year = now.year();
let now_monday = monday_of(now.date_naive());
let mut gross: i128 = 0;
let mut subscription: i128 = 0;
@@ -233,6 +267,12 @@ fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryA
let mut refund_count: u32 = 0;
let mut first_payment: Option<&UserSpendEventRow> = None;
let mut last_payment: Option<&UserSpendEventRow> = None;
// Day-rolling net windows.
let (mut w7, mut pw7, mut w14, mut pw14, mut w30, mut pw30, mut w60, mut w90): (i128, i128, i128, i128, i128, i128, i128, i128) = (0, 0, 0, 0, 0, 0, 0, 0);
// Calendar-week buckets: week_index -> net; and the set of weeks with a payment.
let mut week_net: HashMap<i64, i128> = HashMap::new();
let mut active_weeks: HashSet<i64> = HashSet::new();
for event in events {
@@ -242,6 +282,23 @@ fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryA
net_this_year += amount;
}
let days_ago = (now - event.payment_occurred_at).num_days();
if days_ago >= 0 {
if days_ago < 7 { w7 += amount; }
if (7..14).contains(&days_ago) { pw7 += amount; }
if days_ago < 14 { w14 += amount; }
if (14..28).contains(&days_ago) { pw14 += amount; }
if days_ago < 30 { w30 += amount; }
if (30..60).contains(&days_ago) { pw30 += amount; }
if days_ago < 60 { w60 += amount; }
if days_ago < 90 { w90 += amount; }
}
let week_index = (now_monday - monday_of(event.payment_occurred_at.date_naive())).num_days() / DAYS_PER_WEEK;
if week_index >= 0 {
*week_net.entry(week_index).or_insert(0) += amount;
}
if event.amount_usd_cents > 0 {
gross += amount;
payment_count += 1;
@@ -256,10 +313,8 @@ fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryA
first_payment = Some(event);
}
last_payment = Some(event);
let days_ago = (now - event.payment_occurred_at).num_days();
if days_ago >= 0 {
active_weeks.insert(days_ago / DAYS_PER_WEEK);
if week_index >= 0 {
active_weeks.insert(week_index);
}
} else if event.amount_usd_cents < 0 {
refund += -amount;
@@ -271,12 +326,10 @@ fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryA
let maybe_last_payment_at = last_payment.map(|e| e.payment_occurred_at);
let first_spend_usd_cents = first_payment.map(|e| e.amount_usd_cents.max(0) as u64).unwrap_or(0);
let last_spend_usd_cents = last_payment.map(|e| e.amount_usd_cents.max(0) as u64).unwrap_or(0);
let maybe_days_since_first_payment =
maybe_first_payment_at.map(|t| (now - t).num_days().max(0) as u32);
let maybe_days_since_last_payment =
maybe_last_payment_at.map(|t| (now - t).num_days().max(0) as u32);
let maybe_days_since_first_payment = maybe_first_payment_at.map(|t| (now - t).num_days().max(0) as u32);
let maybe_days_since_last_payment = maybe_last_payment_at.map(|t| (now - t).num_days().max(0) as u32);
// Cadence from the set of active weeks-ago.
// Calendar-week cadence.
let maybe_weeks_since_last_spend = active_weeks.iter().min().map(|w| *w as u32);
let consecutive_active_weeks = {
let mut week = 0i64;
@@ -287,6 +340,9 @@ fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryA
};
let consecutive_inactive_weeks = maybe_weeks_since_last_spend.unwrap_or(0);
let active_in_last = |n: i64| active_weeks.iter().filter(|&&w| (0..n).contains(&w)).count() as u8;
let week_net_sum = |n: i64| -> i128 { (0..n).map(|w| *week_net.get(&w).unwrap_or(&0)).sum() };
SummaryAggregate {
lifetime_gross_spend_usd_cents: gross.max(0) as u64,
lifetime_subscription_spend_usd_cents: subscription.max(0) as u64,
@@ -301,22 +357,46 @@ fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryA
last_spend_usd_cents,
maybe_days_since_first_payment,
maybe_days_since_last_payment,
net_spend_this_year_usd_cents: net_this_year.clamp(i64::MIN as i128, i64::MAX as i128) as i64,
net_spend_7d_usd_cents: clamp_i64(w7),
net_spend_prev_7d_usd_cents: clamp_i64(pw7),
net_spend_14d_usd_cents: clamp_i64(w14),
net_spend_prev_14d_usd_cents: clamp_i64(pw14),
net_spend_30d_usd_cents: clamp_i64(w30),
net_spend_prev_30d_usd_cents: clamp_i64(pw30),
net_spend_60d_usd_cents: clamp_i64(w60),
net_spend_90d_usd_cents: clamp_i64(w90),
net_spend_this_year_usd_cents: clamp_i64(net_this_year),
avg_weekly_net_spend_4w_usd_cents: clamp_i64(week_net_sum(4) / 4),
avg_weekly_net_spend_12w_usd_cents: clamp_i64(week_net_sum(12) / 12),
active_weeks_in_last_4: active_in_last(4),
active_weeks_in_last_8: active_in_last(8),
active_weeks_in_last_12: active_in_last(12),
active_weeks_in_last_24: active_in_last(24),
active_weeks_in_last_52: active_in_last(52),
consecutive_active_weeks,
consecutive_inactive_weeks,
maybe_weeks_since_last_spend,
}
}
/// The Monday (UTC) of the calendar week containing `date`.
fn monday_of(date: chrono::NaiveDate) -> chrono::NaiveDate {
date - Duration::days(date.weekday().num_days_from_monday() as i64)
}
fn clamp_i64(value: i128) -> i64 {
value.clamp(i64::MIN as i128, i64::MAX as i128) as i64
}
#[cfg(test)]
mod tests {
use chrono::Duration as ChronoDuration;
use chrono::TimeZone;
use super::*;
fn event(days_ago: i64, amount_usd_cents: i64, event_type: PaymentEventType, now: DateTime<Utc>) -> UserSpendEventRow {
UserSpendEventRow {
payment_occurred_at: now - ChronoDuration::days(days_ago),
payment_occurred_at: now - Duration::days(days_ago),
amount_usd_cents,
event_type,
maybe_credits_granted: None,
@@ -324,40 +404,64 @@ mod tests {
}
#[test]
fn aggregates_lifetime_and_cadence() {
fn lifetime_and_day_windows() {
let now = Utc::now();
// ascending by time => furthest in the past first
let events = vec![
event(40, 1999, PaymentEventType::SubscriptionInitial, now), // ~6 wks ago
event(20, 999, PaymentEventType::CreditPackPurchase, now), // ~3 wks (week 2)
event(5, 2999, PaymentEventType::SubscriptionRenewal, now), // week 0
event(3, -999, PaymentEventType::Refund, now), // refund, week 0
event(40, 1999, PaymentEventType::SubscriptionInitial, now),
event(20, 999, PaymentEventType::CreditPackPurchase, now),
event(5, 2999, PaymentEventType::SubscriptionRenewal, now),
event(3, -999, PaymentEventType::Refund, now),
];
let agg = compute_summary(&events, now);
assert_eq!(agg.lifetime_gross_spend_usd_cents, 1999 + 999 + 2999);
assert_eq!(agg.lifetime_subscription_spend_usd_cents, 1999 + 2999);
assert_eq!(agg.lifetime_credits_spend_usd_cents, 999);
assert_eq!(agg.lifetime_refund_usd_cents, 999);
assert_eq!(agg.lifetime_net_spend_usd_cents, (1999 + 999 + 2999 - 999) as u64);
assert_eq!(agg.lifetime_payment_count, 3);
assert_eq!(agg.lifetime_refund_count, 1);
assert_eq!(agg.first_spend_usd_cents, 1999);
assert_eq!(agg.last_spend_usd_cents, 2999);
assert_eq!(agg.maybe_weeks_since_last_spend, Some(0));
assert_eq!(agg.consecutive_inactive_weeks, 0);
assert_eq!(agg.consecutive_active_weeks, 1);
// 7d window: day5 (+2999) and day3 (-999) -> 2000
assert_eq!(agg.net_spend_7d_usd_cents, 2000);
// 30d window: day20 (+999) + day5 (+2999) + day3 (-999) -> 2999
assert_eq!(agg.net_spend_30d_usd_cents, 2999);
// prev_30d: 30..60 -> day40 (+1999)
assert_eq!(agg.net_spend_prev_30d_usd_cents, 1999);
}
#[test]
fn lapsed_user_has_inactive_streak() {
let now = Utc::now();
let events = vec![event(60, 4999, PaymentEventType::SubscriptionInitial, now)];
fn calendar_week_cadence() {
// Anchor `now` on a Monday so week boundaries are deterministic.
let today = Utc::now().date_naive();
let monday = monday_of(today);
let now = Utc.from_utc_datetime(&monday.and_hms_opt(12, 0, 0).unwrap());
// Payments on three consecutive Mondays => weeks 0,1,2 active.
let events = vec![
event(14, 1000, PaymentEventType::CreditPackPurchase, now),
event(7, 1000, PaymentEventType::CreditPackPurchase, now),
event(0, 1000, PaymentEventType::CreditPackPurchase, now),
];
let agg = compute_summary(&events, now);
assert_eq!(agg.maybe_weeks_since_last_spend, Some(60 / 7));
assert_eq!(agg.consecutive_inactive_weeks, (60 / 7) as u32);
assert_eq!(agg.maybe_weeks_since_last_spend, Some(0));
assert_eq!(agg.consecutive_active_weeks, 3);
assert_eq!(agg.consecutive_inactive_weeks, 0);
assert_eq!(agg.active_weeks_in_last_4, 3);
assert_eq!(agg.active_weeks_in_last_52, 3);
// weeks 0..3 net = 1000+1000+1000+0 = 3000; /4 = 750
assert_eq!(agg.avg_weekly_net_spend_4w_usd_cents, 750);
}
#[test]
fn lapsed_user_streaks() {
let today = Utc::now().date_naive();
let monday = monday_of(today);
let now = Utc.from_utc_datetime(&monday.and_hms_opt(12, 0, 0).unwrap());
// Last payment 5 weeks ago (week 5).
let events = vec![event(35, 4999, PaymentEventType::SubscriptionInitial, now)];
let agg = compute_summary(&events, now);
assert_eq!(agg.maybe_weeks_since_last_spend, Some(5));
assert_eq!(agg.consecutive_inactive_weeks, 5);
assert_eq!(agg.consecutive_active_weeks, 0);
assert_eq!(agg.maybe_days_since_last_payment, Some(60));
assert_eq!(agg.active_weeks_in_last_4, 0);
assert_eq!(agg.active_weeks_in_last_8, 1);
}
}
@@ -130,6 +130,11 @@ use crate::http_server::endpoints::folders::subfolder::list_subfolders_handler::
use artcraft_api_defs::folders::common::*;
use artcraft_api_defs::folders::folder::*;
use artcraft_api_defs::folders::media_files::*;
use artcraft_api_defs::moderation::user_spend_summaries::get_summary::*;
use artcraft_api_defs::moderation::user_spend_summaries::reengagement_list::*;
use artcraft_api_defs::moderation::user_spend_summaries::top_users_list::*;
use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*;
use artcraft_api_defs::moderation::user_spend_events::list::*;
use artcraft_api_defs::folders::subfolder::*;
use artcraft_api_defs::moderation::user_referrals::list_global_user_referrals::*;
use artcraft_api_defs::moderation::user_referrals::list_user_referrals_for_user::*;
@@ -390,6 +395,11 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
#[derive(OpenApi)]
#[openapi(
paths(
crate::http_server::endpoints::moderation::user_spend_summaries::moderator_get_user_spend_summary_handler::moderator_get_user_spend_summary_handler,
crate::http_server::endpoints::moderation::user_spend_summaries::moderator_reengagement_list_handler::moderator_reengagement_list_handler,
crate::http_server::endpoints::moderation::user_spend_summaries::moderator_top_users_list_handler::moderator_top_users_list_handler,
crate::http_server::endpoints::moderation::user_daily_spends::moderator_user_daily_spends_handler::moderator_user_daily_spends_handler,
crate::http_server::endpoints::moderation::user_spend_events::moderator_list_user_spend_events_handler::moderator_list_user_spend_events_handler,
billing_component::stripe::http_endpoints::checkout::create::stripe_create_checkout_session_json_handler::stripe_create_checkout_session_json_handler,
crate::http_server::endpoints::billing_fakeyou::list_active_user_subscriptions_handler::list_active_user_subscriptions_handler,
crate::http_server::deprecated_endpoints::conversion::enqueue_fbx_to_gltf_handler::enqueue_fbx_to_gltf_handler,
@@ -645,6 +655,17 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
),
components(schemas(
ModeratorGetUserSpendSummaryResponse,
UserSpendSummaryView,
ModeratorReengagementListResponse,
ReengagementCandidateEntry,
ModeratorTopUsersListResponse,
TopUserEntry,
TopUsersWindow,
ModeratorUserDailySpendsResponse,
ModeratorListUserSpendEventsResponse,
UserSpendEventListEntry,
UserDailySpendEntry,
// Tokens
BatchGenerationToken,
BetaKeyToken,
@@ -13,3 +13,20 @@ pub mod user_sessions;
pub mod user_stripe_data;
pub mod wallet_ledger_entries;
pub mod wallets;
pub mod user_daily_spends;
pub mod user_spend_events;
pub mod user_spend_summaries;
use enums::common::payments_namespace::PaymentsNamespace;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// Resolve the optional `payments_namespace` query param, defaulting to `artcraft`.
pub(crate) fn resolve_payments_namespace(
maybe_namespace: Option<&str>,
) -> Result<PaymentsNamespace, CommonWebError> {
match maybe_namespace {
None => Ok(PaymentsNamespace::Artcraft),
Some(value) => PaymentsNamespace::from_str(value)
.map_err(|_| CommonWebError::BadInputWithSimpleMessage("invalid payments_namespace".to_string())),
}
}
@@ -0,0 +1 @@
pub mod moderator_user_daily_spends_handler;
@@ -0,0 +1,95 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::{
ModeratorUserDailySpendsQueryParams, ModeratorUserDailySpendsResponse, UserDailySpendEntry,
};
use mysql_queries::queries::user_daily_spends::list_user_daily_spends::{
list_user_daily_spends, ListUserDailySpendsArgs,
};
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::moderation::resolve_payments_namespace;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
const DEFAULT_LIMIT: u32 = 200;
const MAX_LIMIT: u32 = 5000;
/// List a user's daily spend rows, most recent payment date first. Moderators only.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_daily_spends/user/{user_token}",
params(
("user_token" = String, Path, description = "The user's token"),
ModeratorUserDailySpendsQueryParams,
),
responses(
(status = 200, description = "Success", body = ModeratorUserDailySpendsResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderator_user_daily_spends_handler(
http_request: HttpRequest,
path: web::Path<String>,
query: Query<ModeratorUserDailySpendsQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorUserDailySpendsResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_token = UserToken(path.into_inner());
let payments_namespace = resolve_payments_namespace(query.payments_namespace.as_deref())?;
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let offset = query.offset.unwrap_or(0);
let rows = list_user_daily_spends(ListUserDailySpendsArgs {
user_token: &user_token,
payments_namespace,
limit: limit as i64,
offset: offset.min(i64::MAX as u64) as i64,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("Failed to list user daily spends: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_next_offset = if rows.len() as u32 == limit {
Some(offset + limit as u64)
} else {
None
};
let records = rows
.into_iter()
.map(|r| UserDailySpendEntry {
payments_namespace: r.payments_namespace.to_str().to_string(),
spend_date: r.spend_date,
subscription_spend_usd_cents: r.subscription_spend_usd_cents,
credits_spend_usd_cents: r.credits_spend_usd_cents,
gross_spend_usd_cents: r.gross_spend_usd_cents,
refund_usd_cents: r.refund_usd_cents,
net_spend_usd_cents: r.net_spend_usd_cents,
payment_count: r.payment_count,
credits_granted: r.credits_granted,
created_at: r.created_at,
updated_at: r.updated_at,
})
.collect();
Ok(Json(ModeratorUserDailySpendsResponse {
success: true,
user_token,
records,
maybe_next_offset,
}))
}
@@ -0,0 +1 @@
pub mod moderator_list_user_spend_events_handler;
@@ -0,0 +1,93 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::user_spend_events::list::{
ModeratorListUserSpendEventsQueryParams, ModeratorListUserSpendEventsResponse, UserSpendEventListEntry,
};
use mysql_queries::queries::user_spend_events::list_user_spend_events_for_moderation::{
list_user_spend_events_for_moderation, ListUserSpendEventsForModerationArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
const PAGE_SIZE: i64 = 200;
/// List spend events, most recent payment date first, with the spender's display info.
/// Moderators only.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_spend_events/list",
params(
ModeratorListUserSpendEventsQueryParams,
),
responses(
(status = 200, description = "Success", body = ModeratorListUserSpendEventsResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderator_list_user_spend_events_handler(
http_request: HttpRequest,
query: Query<ModeratorListUserSpendEventsQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorListUserSpendEventsResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let offset = query.offset.unwrap_or(0);
let rows = list_user_spend_events_for_moderation(ListUserSpendEventsForModerationArgs {
maybe_payments_namespace: query.payments_namespace.as_deref(),
limit: PAGE_SIZE,
offset: offset.min(i64::MAX as u64) as i64,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("Failed to list user spend events: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_next_offset = if rows.len() as i64 == PAGE_SIZE {
Some(offset + PAGE_SIZE as u64)
} else {
None
};
let events = rows
.into_iter()
.map(|r| UserSpendEventListEntry {
token: r.token,
payments_namespace: r.payments_namespace,
maybe_user_token: r.maybe_user_token,
maybe_username: r.maybe_username,
maybe_display_name: r.maybe_display_name,
maybe_email_gravatar_hash: r.maybe_email_gravatar_hash,
event_type: r.event_type,
amount_usd_cents: r.amount_usd_cents,
maybe_credits_granted: r.maybe_credits_granted,
payment_source: r.payment_source,
maybe_source_object_id: r.maybe_source_object_id,
maybe_stripe_invoice_id: r.maybe_stripe_invoice_id,
maybe_stripe_payment_intent_id: r.maybe_stripe_payment_intent_id,
maybe_stripe_charge_id: r.maybe_stripe_charge_id,
maybe_stripe_customer_id: r.maybe_stripe_customer_id,
is_production: r.is_production,
payment_occurred_at: r.payment_occurred_at,
created_at: r.created_at,
})
.collect();
Ok(Json(ModeratorListUserSpendEventsResponse {
success: true,
events,
maybe_next_offset,
}))
}
@@ -0,0 +1,3 @@
pub mod moderator_get_user_spend_summary_handler;
pub mod moderator_reengagement_list_handler;
pub mod moderator_top_users_list_handler;
@@ -0,0 +1,107 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::user_spend_summaries::get_summary::{
ModeratorGetUserSpendSummaryQueryParams, ModeratorGetUserSpendSummaryResponse, UserSpendSummaryView,
};
use mysql_queries::queries::user_spend_summaries::get_user_spend_summary::{
get_user_spend_summary, GetUserSpendSummaryArgs, UserSpendSummaryRecord,
};
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::moderation::resolve_payments_namespace;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
/// Get the full spend summary for a user. Moderators only.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_spend_summaries/summary/{user_token}",
params(
("user_token" = String, Path, description = "The user's token"),
ModeratorGetUserSpendSummaryQueryParams,
),
responses(
(status = 200, description = "Success", body = ModeratorGetUserSpendSummaryResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderator_get_user_spend_summary_handler(
http_request: HttpRequest,
path: web::Path<String>,
query: Query<ModeratorGetUserSpendSummaryQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorGetUserSpendSummaryResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let user_token = UserToken(path.into_inner());
let payments_namespace = resolve_payments_namespace(query.payments_namespace.as_deref())?;
let maybe_record = get_user_spend_summary(GetUserSpendSummaryArgs {
payments_namespace,
user_token: &user_token,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("Failed to get user spend summary: {:?}", err);
CommonWebError::from_error(err)
})?;
Ok(Json(ModeratorGetUserSpendSummaryResponse {
success: true,
maybe_summary: maybe_record.map(to_view),
}))
}
fn to_view(r: UserSpendSummaryRecord) -> UserSpendSummaryView {
UserSpendSummaryView {
payments_namespace: r.payments_namespace.to_str().to_string(),
user_token: r.user_token,
lifetime_gross_spend_usd_cents: r.lifetime_gross_spend_usd_cents,
lifetime_subscription_spend_usd_cents: r.lifetime_subscription_spend_usd_cents,
lifetime_credits_spend_usd_cents: r.lifetime_credits_spend_usd_cents,
lifetime_refund_usd_cents: r.lifetime_refund_usd_cents,
lifetime_net_spend_usd_cents: r.lifetime_net_spend_usd_cents,
lifetime_payment_count: r.lifetime_payment_count,
lifetime_refund_count: r.lifetime_refund_count,
maybe_first_payment_at: r.maybe_first_payment_at,
first_spend_usd_cents: r.first_spend_usd_cents,
maybe_last_payment_at: r.maybe_last_payment_at,
last_spend_usd_cents: r.last_spend_usd_cents,
maybe_days_since_first_payment: r.maybe_days_since_first_payment,
maybe_days_since_last_payment: r.maybe_days_since_last_payment,
net_spend_7d_usd_cents: r.net_spend_7d_usd_cents,
net_spend_prev_7d_usd_cents: r.net_spend_prev_7d_usd_cents,
net_spend_14d_usd_cents: r.net_spend_14d_usd_cents,
net_spend_prev_14d_usd_cents: r.net_spend_prev_14d_usd_cents,
net_spend_30d_usd_cents: r.net_spend_30d_usd_cents,
net_spend_prev_30d_usd_cents: r.net_spend_prev_30d_usd_cents,
net_spend_60d_usd_cents: r.net_spend_60d_usd_cents,
net_spend_90d_usd_cents: r.net_spend_90d_usd_cents,
net_spend_this_year_usd_cents: r.net_spend_this_year_usd_cents,
avg_weekly_net_spend_4w_usd_cents: r.avg_weekly_net_spend_4w_usd_cents,
avg_weekly_net_spend_12w_usd_cents: r.avg_weekly_net_spend_12w_usd_cents,
active_weeks_in_last_4: r.active_weeks_in_last_4,
active_weeks_in_last_8: r.active_weeks_in_last_8,
active_weeks_in_last_12: r.active_weeks_in_last_12,
active_weeks_in_last_24: r.active_weeks_in_last_24,
active_weeks_in_last_52: r.active_weeks_in_last_52,
consecutive_active_weeks: r.consecutive_active_weeks,
consecutive_inactive_weeks: r.consecutive_inactive_weeks,
maybe_weeks_since_last_spend: r.maybe_weeks_since_last_spend,
is_active_subscriber: r.is_active_subscriber,
maybe_subscription_interval: r.maybe_subscription_interval,
maybe_reengagement_score: r.maybe_reengagement_score,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
@@ -0,0 +1,86 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::user_spend_summaries::reengagement_list::{
ModeratorReengagementListQueryParams, ModeratorReengagementListResponse, ReengagementCandidateEntry,
};
use mysql_queries::queries::user_spend_summaries::list_reengagement_candidates::{
list_reengagement_candidates, ListReengagementCandidatesArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::moderation::resolve_payments_namespace;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
const PAGE_SIZE: i64 = 200;
/// List users that need re-engagement (re-engagement score descending). Moderators only.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_spend_summaries/reengagement_list",
params(
ModeratorReengagementListQueryParams,
),
responses(
(status = 200, description = "Success", body = ModeratorReengagementListResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderator_reengagement_list_handler(
http_request: HttpRequest,
query: Query<ModeratorReengagementListQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorReengagementListResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let payments_namespace = resolve_payments_namespace(query.payments_namespace.as_deref())?;
let offset = query.offset.unwrap_or(0);
let rows = list_reengagement_candidates(ListReengagementCandidatesArgs {
payments_namespace: payments_namespace.to_str(),
limit: PAGE_SIZE,
offset: offset.min(i64::MAX as u64) as i64,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("Failed to list reengagement candidates: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_next_offset = if rows.len() as i64 == PAGE_SIZE {
Some(offset + PAGE_SIZE as u64)
} else {
None
};
let candidates = rows
.into_iter()
.map(|r| ReengagementCandidateEntry {
user_token: r.user_token,
username: r.username,
display_name: r.display_name,
email_gravatar_hash: r.email_gravatar_hash,
reengagement_score: r.reengagement_score,
lifetime_net_spend_usd_cents: r.lifetime_net_spend_usd_cents,
maybe_last_payment_at: r.maybe_last_payment_at,
maybe_days_since_last_payment: r.maybe_days_since_last_payment,
maybe_weeks_since_last_spend: r.maybe_weeks_since_last_spend,
is_active_subscriber: r.is_active_subscriber,
})
.collect();
Ok(Json(ModeratorReengagementListResponse {
success: true,
candidates,
maybe_next_offset,
}))
}
@@ -0,0 +1,92 @@
use std::marker::PhantomData;
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::{web, HttpRequest};
use log::warn;
use artcraft_api_defs::moderation::user_spend_summaries::top_users_list::{
ModeratorTopUsersListQueryParams, ModeratorTopUsersListResponse, TopUserEntry, TopUsersWindow,
};
use mysql_queries::queries::user_spend_summaries::list_top_users_by_net_spend::{
list_top_users_by_net_spend, ListTopUsersByNetSpendArgs,
};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::moderation::resolve_payments_namespace;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
const PAGE_SIZE: i64 = 200;
/// List top spenders, sorted by net spend over the requested window. Moderators only.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/user_spend_summaries/top_users_list",
params(
ModeratorTopUsersListQueryParams,
),
responses(
(status = 200, description = "Success", body = ModeratorTopUsersListResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderator_top_users_list_handler(
http_request: HttpRequest,
query: Query<ModeratorTopUsersListQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorTopUsersListResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let payments_namespace = resolve_payments_namespace(query.payments_namespace.as_deref())?;
let window = query.window.unwrap_or(TopUsersWindow::Lifetime);
let offset = query.offset.unwrap_or(0);
let rows = list_top_users_by_net_spend(ListTopUsersByNetSpendArgs {
payments_namespace: payments_namespace.to_str(),
window: window.as_query_key(),
limit: PAGE_SIZE,
offset: offset.min(i64::MAX as u64) as i64,
mysql_executor: &server_state.mysql_pool,
phantom: PhantomData,
})
.await
.map_err(|err| {
warn!("Failed to list top users by net spend: {:?}", err);
CommonWebError::from_error(err)
})?;
let maybe_next_offset = if rows.len() as i64 == PAGE_SIZE {
Some(offset + PAGE_SIZE as u64)
} else {
None
};
let users = rows
.into_iter()
.map(|r| TopUserEntry {
user_token: r.user_token,
username: r.username,
display_name: r.display_name,
email_gravatar_hash: r.email_gravatar_hash,
lifetime_net_spend_usd_cents: r.lifetime_net_spend_usd_cents,
net_spend_7d_usd_cents: r.net_spend_7d_usd_cents,
net_spend_14d_usd_cents: r.net_spend_14d_usd_cents,
net_spend_30d_usd_cents: r.net_spend_30d_usd_cents,
net_spend_60d_usd_cents: r.net_spend_60d_usd_cents,
net_spend_90d_usd_cents: r.net_spend_90d_usd_cents,
net_spend_this_year_usd_cents: r.net_spend_this_year_usd_cents,
is_active_subscriber: r.is_active_subscriber,
maybe_reengagement_score: r.maybe_reengagement_score,
})
.collect();
Ok(Json(ModeratorTopUsersListResponse {
success: true,
window: window.as_query_key().to_string(),
users,
maybe_next_offset,
}))
}
@@ -47,6 +47,11 @@ use crate::http_server::endpoints::moderation::user_referrals::moderator_list_us
use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler;
use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler;
use crate::http_server::endpoints::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids_handler::moderator_get_user_stripe_customer_ids_handler;
use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_get_user_spend_summary_handler::moderator_get_user_spend_summary_handler;
use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_reengagement_list_handler::moderator_reengagement_list_handler;
use crate::http_server::endpoints::moderation::user_spend_summaries::moderator_top_users_list_handler::moderator_top_users_list_handler;
use crate::http_server::endpoints::moderation::user_daily_spends::moderator_user_daily_spends_handler::moderator_user_daily_spends_handler;
use crate::http_server::endpoints::moderation::user_spend_events::moderator_list_user_spend_events_handler::moderator_list_user_spend_events_handler;
pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
where
@@ -60,6 +65,32 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
>,
{
app.service(web::scope("/v1/moderation")
.service(web::scope("/user_spend_summaries")
.service(web::resource("/summary/{user_token}")
.route(web::get().to(moderator_get_user_spend_summary_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/reengagement_list")
.route(web::get().to(moderator_reengagement_list_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/top_users_list")
.route(web::get().to(moderator_top_users_list_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/user_spend_events")
.service(web::resource("/list")
.route(web::get().to(moderator_list_user_spend_events_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/user_daily_spends")
.service(web::resource("/user/{user_token}")
.route(web::get().to(moderator_user_daily_spends_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/alerts")
.service(web::resource("/send")
.route(web::post().to(moderation_send_alert_handler))
@@ -23,6 +23,9 @@ import { JobInfo } from "@/pages/JobInfo";
import { JobTokenSearch } from "@/pages/JobTokenSearch";
import { DebugLogs } from "@/pages/DebugLogs";
import { DebugLogsSearch } from "@/pages/DebugLogsSearch";
import { SpendEvents } from "@/pages/SpendEvents";
import { UserSpendSummary } from "@/pages/UserSpendSummary";
import { UserSpendHistory } from "@/pages/UserSpendHistory";
import { NotFoundPage } from "@/pages/NotFound";
import { Spinner } from "./components/ui/spinner";
import { ThemeProvider } from "@/components/theme-provider";
@@ -59,9 +62,12 @@ function AppRoutes() {
<Route path="user/profile/:username/jobs" element={<JobHistory />} />
<Route path="user/profile/:username/creations" element={<UserCreations />} />
<Route path="user/profile/:username/referrals" element={<UserReferrals />} />
<Route path="user/spend-summary/:username" element={<UserSpendSummary />} />
<Route path="user/spend-history/:username" element={<UserSpendHistory />} />
<Route path="stripe-lookup" element={<StripeLookup />} />
<Route path="user-signups" element={<UserSignups />} />
<Route path="subscriber-signups" element={<SubscriberSignups />} />
<Route path="spend-events" element={<SpendEvents />} />
<Route path="impersonation" element={<Impersonation />} />
<Route path="staff-audit-logs" element={<StaffAuditLogs />} />
<Route path="send-pager" element={<SendPager />} />
@@ -6,6 +6,11 @@ export interface ApiResponse<T, P = undefined> {
}
const getApiHost = () => {
// NB: Temporary for Brandon's machine
console.log("window.location.hostname:", window.location.hostname);
if (window.location.hostname === "localhost") {
return "http://localhost:12345"
}
return import.meta.env.VITE_API_HOST || "/api";
};
@@ -84,7 +84,175 @@ export interface ModeratorUserLookupResponse {
username_is_not_customized: boolean;
}
export interface UserSpendEvent {
token: string;
payments_namespace: string;
maybe_user_token: string | null;
maybe_username: string | null;
maybe_display_name: string | null;
maybe_email_gravatar_hash: string | null;
event_type: string;
amount_usd_cents: number;
maybe_credits_granted: number | null;
payment_source: string;
maybe_source_object_id: string | null;
maybe_stripe_invoice_id: string | null;
maybe_stripe_payment_intent_id: string | null;
maybe_stripe_charge_id: string | null;
maybe_stripe_customer_id: string | null;
is_production: boolean;
payment_occurred_at: string;
created_at: string;
}
export interface UserSpendSummary {
payments_namespace: string;
user_token: string;
lifetime_gross_spend_usd_cents: number;
lifetime_subscription_spend_usd_cents: number;
lifetime_credits_spend_usd_cents: number;
lifetime_refund_usd_cents: number;
lifetime_net_spend_usd_cents: number;
lifetime_payment_count: number;
lifetime_refund_count: number;
maybe_first_payment_at: string | null;
first_spend_usd_cents: number;
maybe_last_payment_at: string | null;
last_spend_usd_cents: number;
maybe_days_since_first_payment: number | null;
maybe_days_since_last_payment: number | null;
net_spend_7d_usd_cents: number;
net_spend_prev_7d_usd_cents: number;
net_spend_14d_usd_cents: number;
net_spend_prev_14d_usd_cents: number;
net_spend_30d_usd_cents: number;
net_spend_prev_30d_usd_cents: number;
net_spend_60d_usd_cents: number;
net_spend_90d_usd_cents: number;
net_spend_this_year_usd_cents: number;
avg_weekly_net_spend_4w_usd_cents: number;
avg_weekly_net_spend_12w_usd_cents: number;
active_weeks_in_last_4: number;
active_weeks_in_last_8: number;
active_weeks_in_last_12: number;
active_weeks_in_last_24: number;
active_weeks_in_last_52: number;
consecutive_active_weeks: number;
consecutive_inactive_weeks: number;
maybe_weeks_since_last_spend: number | null;
is_active_subscriber: boolean;
maybe_subscription_interval: string | null;
maybe_reengagement_score: number | null;
created_at: string;
updated_at: string;
}
export interface UserDailySpend {
payments_namespace: string;
spend_date: string;
subscription_spend_usd_cents: number;
credits_spend_usd_cents: number;
gross_spend_usd_cents: number;
refund_usd_cents: number;
net_spend_usd_cents: number;
payment_count: number;
credits_granted: number;
created_at: string;
updated_at: string;
}
export class ModerationApi extends ApiManager {
// Full spend summary for one user.
public async GetUserSpendSummary(
user_token: string,
payments_namespace?: string,
): Promise<ApiResponse<{ summary: UserSpendSummary | null }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_spend_summaries/summary/${encodeURIComponent(user_token)}`;
return await this.get<{
success: boolean;
maybe_summary: UserSpendSummary | null;
error_message?: string;
}>({
endpoint,
query: { payments_namespace: payments_namespace ?? undefined },
})
.then((response) => ({
success: response.success,
data: { summary: response.maybe_summary ?? null },
errorMessage: response.error_message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// One page of a user's daily spend rows (offset-paginated, newest date first).
public async ListUserDailySpends(
user_token: string,
offset?: number | null,
limit?: number,
payments_namespace?: string,
): Promise<ApiResponse<{ records: UserDailySpend[]; next_offset: number | null }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_daily_spends/user/${encodeURIComponent(user_token)}`;
return await this.get<{
success: boolean;
records: UserDailySpend[];
maybe_next_offset: number | null;
error_message?: string;
}>({
endpoint,
query: {
offset: offset ?? undefined,
limit,
payments_namespace: payments_namespace ?? undefined,
},
})
.then((response) => ({
success: response.success,
data: {
records: response.records || [],
next_offset: response.maybe_next_offset ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// Spend events list (offset-paginated, newest payment first).
public async ListUserSpendEvents(
offset?: number | null,
payments_namespace?: string,
): Promise<
ApiResponse<{
events: UserSpendEvent[];
next_offset: number | null;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_spend_events/list`;
return await this.get<{
success: boolean;
events: UserSpendEvent[];
maybe_next_offset: number | null;
error_message?: string;
}>({
endpoint,
query: {
offset: offset ?? undefined,
payments_namespace: payments_namespace ?? undefined,
},
})
.then((response) => ({
success: response.success,
data: {
events: response.events || [],
next_offset: response.maybe_next_offset ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public async UserLookup(
search: string,
): Promise<ApiResponse<{ maybe_user?: ModeratorUserLookupResponse }>> {
@@ -10,6 +10,7 @@ import {
IconFlag,
IconKey,
IconClipboardList,
IconCoin,
IconMail,
IconBellRinging,
IconFileAi,
@@ -78,6 +79,11 @@ const data = {
url: "/subscriber-signups",
icon: IconCreditCard,
},
{
title: "Spend Events",
url: "/spend-events",
icon: IconCoin,
},
{
title: "Impersonation Logs",
url: "/impersonation",
@@ -0,0 +1,329 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { UserSpendEvent } from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconCoin,
IconRefresh,
IconLoader2,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
const COLUMN_COUNT = 8;
const usdFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
function formatUsd(cents: number): string {
return usdFormatter.format(cents / 100);
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function stripeRef(event: UserSpendEvent): string | null {
return (
event.maybe_stripe_payment_intent_id ||
event.maybe_stripe_invoice_id ||
event.maybe_stripe_charge_id ||
event.maybe_source_object_id ||
null
);
}
export function SpendEvents() {
usePageTitle("Spend Events");
const [events, setEvents] = useState<UserSpendEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextOffset, setNextOffset] = useState<number | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadData = async (offset?: number | null, append = false) => {
if (!append) {
setIsLoading(true);
setError(null);
} else {
setIsLoadingMore(true);
}
try {
const modApi = new ModerationApi();
const resp = await modApi.ListUserSpendEvents(offset);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setEvents((prev) =>
append ? [...prev, ...resp.data!.events] : resp.data!.events,
);
setNextOffset(resp.data.next_offset);
} else {
setError(resp.errorMessage || "Failed to load spend events");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load spend events");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
};
const handleRefresh = () => {
setEvents([]);
setNextOffset(null);
loadData();
};
useEffect(() => {
cancelledRef.current = false;
loadData();
return () => {
cancelledRef.current = true;
};
}, []);
const loadMore = useCallback(() => {
if (!isLoadingMore && nextOffset != null) {
loadData(nextOffset, true);
}
}, [nextOffset, isLoadingMore]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconCoin className="size-6 text-muted-foreground" />
Spend Events
</h1>
<p className="text-muted-foreground">
Every payment, refund, and credit grant, newest first
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">When</TableHead>
<TableHead className="text-xs">User</TableHead>
<TableHead className="text-xs">Type</TableHead>
<TableHead className="text-xs text-right">Amount</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
<TableHead className="text-xs">Source</TableHead>
<TableHead className="text-xs">Stripe Ref</TableHead>
<TableHead className="text-xs">Env</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 12 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: COLUMN_COUNT }).map((__, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-20" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconCoin className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No spend events yet.</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">When</TableHead>
<TableHead className="text-xs">User</TableHead>
<TableHead className="text-xs">Type</TableHead>
<TableHead className="text-xs text-right">Amount</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
<TableHead className="text-xs">Source</TableHead>
<TableHead className="text-xs">Stripe Ref</TableHead>
<TableHead className="text-xs">Env</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => {
const ref = stripeRef(event);
const isRefund = event.amount_usd_cents < 0;
return (
<TableRow key={event.token} className="group">
<TableCell className="text-sm text-muted-foreground tabular-nums whitespace-nowrap">
{formatDate(event.payment_occurred_at)}
</TableCell>
<TableCell className="text-sm font-medium">
{event.maybe_username ? (
<Link
to={`/user/profile/${event.maybe_username}`}
className="hover:underline text-foreground"
>
@{event.maybe_username}
</Link>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
{event.maybe_display_name && (
<span className="ml-2 text-xs text-muted-foreground">
{event.maybe_display_name}
</span>
)}
</TableCell>
<TableCell className="text-xs">
<Badge
variant="secondary"
className="text-[10px] font-mono bg-indigo-500/10 text-indigo-300 border-transparent"
>
{event.event_type}
</Badge>
</TableCell>
<TableCell
className={`text-sm text-right tabular-nums whitespace-nowrap ${
isRefund ? "text-red-400" : "text-emerald-400"
}`}
>
{formatUsd(event.amount_usd_cents)}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{event.maybe_credits_granted != null ? (
event.maybe_credits_granted.toLocaleString()
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
<Badge
variant="outline"
className="text-[10px] text-muted-foreground"
>
{event.payment_source}
</Badge>
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground/70">
{ref ? (
<span className="truncate max-w-[160px] inline-block align-bottom">
{ref}
</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
{event.is_production ? (
<Badge
variant="outline"
className="text-[10px] text-emerald-400 border-emerald-500/30"
>
prod
</Badge>
) : (
<Badge
variant="outline"
className="text-[10px] text-amber-400 border-amber-500/30"
>
test
</Badge>
)}
</TableCell>
</TableRow>
);
})}
{nextOffset != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={COLUMN_COUNT} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">
Scroll for more
</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
@@ -604,6 +604,18 @@ export function UserProfile() {
Referrals
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to={`/user/spend-summary/${username}`}>
<IconCoin className="size-4" />
Spend Summary
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to={`/user/spend-history/${username}`}>
<IconHistory className="size-4" />
Spend History
</Link>
</Button>
<Button
variant="outline"
size="sm"
@@ -0,0 +1,293 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import {
ModerationApi,
type UserDailySpend,
type ModeratorUserLookupResponse,
} from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconArrowLeft,
IconChartLine,
IconCoin,
IconLoader2,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
const PAGE_SIZE = 200;
const COLUMN_COUNT = 8;
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function formatUsd(cents: number): string {
return usd.format(cents / 100);
}
function formatDay(spendDate: string): string {
// spend_date is a UTC calendar date (YYYY-MM-DD); render without TZ shifting.
const [y, m, d] = spendDate.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
timeZone: "UTC",
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
}
export function UserSpendHistory() {
const { username } = useParams<{ username: string }>();
const navigate = useNavigate();
usePageTitle(username ? `@${username} — Spend History` : "Spend History");
const [user, setUser] = useState<ModeratorUserLookupResponse | null>(null);
const [rows, setRows] = useState<UserDailySpend[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextOffset, setNextOffset] = useState<number | null>(null);
const cancelledRef = useRef(false);
const tokenRef = useRef<string | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadPage = useCallback(async (offset: number, append: boolean) => {
const token = tokenRef.current;
if (!token) return;
if (append) setIsLoadingMore(true);
try {
const modApi = new ModerationApi();
const resp = await modApi.ListUserDailySpends(token, offset, PAGE_SIZE);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setRows((prev) => (append ? [...prev, ...resp.data!.records] : resp.data!.records));
setNextOffset(resp.data.next_offset);
} else {
setError(resp.errorMessage || "Failed to load spend history");
}
} catch (err: any) {
if (!cancelledRef.current) setError(err.message || "Failed to load spend history");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
}, []);
useEffect(() => {
if (!username) return;
cancelledRef.current = false;
(async () => {
setIsLoading(true);
setError(null);
setRows([]);
setNextOffset(null);
try {
const modApi = new ModerationApi();
const userResp = await modApi.UserLookup(username);
if (cancelledRef.current) return;
if (!userResp.success || !userResp.data?.maybe_user) {
setError(userResp.errorMessage || "User not found");
setIsLoading(false);
return;
}
setUser(userResp.data.maybe_user);
tokenRef.current = userResp.data.maybe_user.token;
await loadPage(0, false);
} catch (err: any) {
if (!cancelledRef.current) {
setError(err.message || "Failed to load user");
setIsLoading(false);
}
}
})();
return () => {
cancelledRef.current = true;
};
}, [username, loadPage]);
const loadMore = useCallback(() => {
if (!isLoadingMore && nextOffset != null) {
loadPage(nextOffset, true);
}
}, [nextOffset, isLoadingMore, loadPage]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) observerRef.current.observe(sentinelRef.current);
return () => observerRef.current?.disconnect();
}, [loadMore]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
const headerRow = (
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Date</TableHead>
<TableHead className="text-xs text-right">Gross</TableHead>
<TableHead className="text-xs text-right">Refund</TableHead>
<TableHead className="text-xs text-right">Net</TableHead>
<TableHead className="text-xs text-right">Subscription</TableHead>
<TableHead className="text-xs text-right">Credit Packs</TableHead>
<TableHead className="text-xs text-right">Payments</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
</TableRow>
);
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-start gap-4 flex-wrap">
<div className="flex-1 flex flex-col gap-1 min-w-0">
<Button
variant="ghost"
size="sm"
className="w-fit -ml-2 mb-1"
onClick={() => navigate(`/user/profile/${username}`)}
>
<IconArrowLeft className="size-4" />
Back to profile
</Button>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconCoin className="size-6 text-muted-foreground" />
Spend History
</h1>
<p className="text-muted-foreground">
Daily spend for{" "}
<Link to={`/user/profile/${username}`} className="hover:underline">
@{username}
</Link>
{user?.display_name && <span className="ml-2 text-sm">{user.display_name}</span>}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/user/spend-summary/${username}`}>
<IconChartLine className="size-4" />
Spend Summary
</Link>
</Button>
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>{headerRow}</TableHeader>
<TableBody>
{Array.from({ length: 12 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: COLUMN_COUNT }).map((__, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-16" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
) : rows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconCoin className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No spend history for this user.</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">{headerRow}</TableHeader>
<TableBody>
{rows.map((row) => {
const isRefundDay = row.net_spend_usd_cents < 0;
return (
<TableRow key={row.spend_date} className="group">
<TableCell className="text-sm whitespace-nowrap">
{formatDay(row.spend_date)}
</TableCell>
<TableCell className="text-sm text-right tabular-nums">
{formatUsd(row.gross_spend_usd_cents)}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{row.refund_usd_cents > 0 ? (
<span className="text-red-400">{formatUsd(row.refund_usd_cents)}</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell
className={`text-sm text-right tabular-nums font-medium ${
isRefundDay ? "text-red-400" : "text-emerald-400"
}`}
>
{formatUsd(row.net_spend_usd_cents)}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{row.subscription_spend_usd_cents > 0
? formatUsd(row.subscription_spend_usd_cents)
: "—"}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{row.credits_spend_usd_cents > 0
? formatUsd(row.credits_spend_usd_cents)
: "—"}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{row.payment_count}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{row.credits_granted > 0 ? row.credits_granted.toLocaleString() : "—"}
</TableCell>
</TableRow>
);
})}
{nextOffset != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={COLUMN_COUNT} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">Scroll for more</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,488 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
ModerationApi,
type UserSpendSummary as UserSpendSummaryData,
type UserDailySpend,
type ModeratorUserLookupResponse,
} from "@/api/ModerationApi";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
IconAlertCircle,
IconArrowLeft,
IconCoin,
IconHistory,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
// --- formatting helpers ---
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const usdWhole = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
function formatUsd(cents: number): string {
return usd.format(cents / 100);
}
// Compact axis label that scales with magnitude ($1.2k, $3.4M).
function formatUsdCompact(cents: number): string {
const dollars = cents / 100;
const abs = Math.abs(dollars);
if (abs >= 1_000_000) return `$${(dollars / 1_000_000).toFixed(1)}M`;
if (abs >= 1_000) return `$${(dollars / 1_000).toFixed(1)}k`;
return usdWhole.format(dollars);
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
// --- period selector (filters client-side; data is fetched once) ---
const PERIODS: { value: string; label: string; days: number | null }[] = [
{ value: "7d", label: "Last 7 days", days: 7 },
{ value: "14d", label: "Last 2 weeks", days: 14 },
{ value: "30d", label: "Last 30 days", days: 30 },
{ value: "60d", label: "Last 60 days", days: 60 },
{ value: "90d", label: "Last 90 days", days: 90 },
{ value: "180d", label: "Last 180 days", days: 180 },
{ value: "1y", label: "Last year", days: 365 },
{ value: "all", label: "All time", days: null },
];
const chartConfig = {
net: { label: "Net spend", color: "var(--primary)" },
} satisfies ChartConfig;
const MAX_DAILY_PAGES = 50;
const DAILY_PAGE_SIZE = 5000;
export function UserSpendSummary() {
const { username } = useParams<{ username: string }>();
const navigate = useNavigate();
usePageTitle(username ? `@${username} — Spend Summary` : "Spend Summary");
const [user, setUser] = useState<ModeratorUserLookupResponse | null>(null);
const [summary, setSummary] = useState<UserSpendSummaryData | null>(null);
const [daily, setDaily] = useState<UserDailySpend[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [period, setPeriod] = useState<string>("90d");
const cancelledRef = useRef(false);
useEffect(() => {
if (!username) return;
cancelledRef.current = false;
(async () => {
setIsLoading(true);
setError(null);
try {
const modApi = new ModerationApi();
const userResp = await modApi.UserLookup(username);
if (cancelledRef.current) return;
if (!userResp.success || !userResp.data?.maybe_user) {
setError(userResp.errorMessage || "User not found");
return;
}
const resolvedUser = userResp.data.maybe_user;
setUser(resolvedUser);
const summaryResp = await modApi.GetUserSpendSummary(resolvedUser.token);
if (cancelledRef.current) return;
if (!summaryResp.success) {
setError(summaryResp.errorMessage || "Failed to load spend summary");
return;
}
setSummary(summaryResp.data?.summary ?? null);
// Load ALL daily rows (paginated) so the period selector can filter
// client-side without re-fetching.
const allDaily: UserDailySpend[] = [];
let offset: number | null = 0;
for (let i = 0; i < MAX_DAILY_PAGES; i++) {
const resp = await modApi.ListUserDailySpends(
resolvedUser.token,
offset,
DAILY_PAGE_SIZE,
);
if (cancelledRef.current) return;
if (!resp.success || !resp.data) {
setError(resp.errorMessage || "Failed to load daily spend");
return;
}
allDaily.push(...resp.data.records);
if (resp.data.next_offset == null) break;
offset = resp.data.next_offset;
}
// API returns newest first; chart wants oldest -> newest.
allDaily.sort((a, b) => a.spend_date.localeCompare(b.spend_date));
setDaily(allDaily);
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load spend summary");
} finally {
if (!cancelledRef.current) setIsLoading(false);
}
})();
return () => {
cancelledRef.current = true;
};
}, [username]);
const chartData = useMemo(() => {
const days = PERIODS.find((p) => p.value === period)?.days ?? null;
let rows = daily;
if (days != null) {
const cutoff = new Date();
cutoff.setHours(0, 0, 0, 0);
cutoff.setDate(cutoff.getDate() - (days - 1));
rows = daily.filter((d) => new Date(d.spend_date) >= cutoff);
}
return rows.map((d) => ({
date: d.spend_date,
net: d.net_spend_usd_cents / 100,
gross: d.gross_spend_usd_cents / 100,
}));
}, [daily, period]);
const periodTotalCents = useMemo(
() => Math.round(chartData.reduce((sum, d) => sum + d.net, 0) * 100),
[chartData],
);
if (isLoading) {
return (
<div className="flex flex-col gap-6">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-[360px] w-full rounded-xl" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col gap-6">
<Button variant="ghost" className="w-fit" onClick={() => navigate(`/user/profile/${username}`)}>
<IconArrowLeft className="size-4" />
Back to profile
</Button>
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-1">
<Button
variant="ghost"
size="sm"
className="w-fit -ml-2 mb-1"
onClick={() => navigate(`/user/profile/${username}`)}
>
<IconArrowLeft className="size-4" />
Back to profile
</Button>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconCoin className="size-6 text-muted-foreground" />
Spend Summary
</h1>
<p className="text-muted-foreground">
<Link to={`/user/profile/${username}`} className="hover:underline">
@{username}
</Link>
{user?.display_name && (
<span className="ml-2 text-sm">{user.display_name}</span>
)}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/user/spend-history/${username}`}>
<IconHistory className="size-4" />
Spend History
</Link>
</Button>
</div>
{/* Interactive spending-over-time graph */}
<Card className="@container/card">
<CardHeader>
<CardTitle>Spending over time</CardTitle>
<CardDescription>
Net spend per day &middot;{" "}
<span className="text-foreground font-medium">
{formatUsd(periodTotalCents)}
</span>{" "}
over this period
</CardDescription>
<div className="ml-auto">
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="w-40" size="sm" aria-label="Select period">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl">
{PERIODS.map((p) => (
<SelectItem key={p.value} value={p.value} className="rounded-lg">
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
{chartData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[320px] bg-muted/20 border border-dashed rounded-xl">
<IconCoin className="size-10 text-muted-foreground mb-3 opacity-20" />
<p className="text-muted-foreground">No spend in this period.</p>
</div>
) : (
<ChartContainer config={chartConfig} className="aspect-auto h-[320px] w-full">
<AreaChart data={chartData} margin={{ left: 4, right: 12 }}>
<defs>
<linearGradient id="fillNet" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-net)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-net)" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) =>
new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
width={56}
tickMargin={8}
domain={["auto", "auto"]}
tickFormatter={(value) => formatUsdCompact(Number(value) * 100)}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
indicator="dot"
labelFormatter={(value) =>
new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
formatter={(value) => formatUsd(Number(value) * 100)}
/>
}
/>
<Area
dataKey="net"
type="natural"
fill="url(#fillNet)"
stroke="var(--color-net)"
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
{/* Infographic HUD */}
{summary ? (
<SpendHud summary={summary} />
) : (
<div className="flex flex-col items-center justify-center py-16 bg-muted/20 border border-dashed rounded-xl">
<IconCoin className="size-10 text-muted-foreground mb-3 opacity-20" />
<p className="text-muted-foreground">No spend summary for this user.</p>
</div>
)}
</div>
);
}
// --- HUD ---
function SpendHud({ summary }: { summary: UserSpendSummaryData }) {
return (
<div className="flex flex-col gap-4">
<Section title="Lifetime">
<StatCard label="Net spend" value={formatUsd(summary.lifetime_net_spend_usd_cents)} emphasis />
<StatCard label="Gross spend" value={formatUsd(summary.lifetime_gross_spend_usd_cents)} />
<StatCard
label="Refunded"
value={formatUsd(summary.lifetime_refund_usd_cents)}
sub={`${summary.lifetime_refund_count} refund${summary.lifetime_refund_count === 1 ? "" : "s"}`}
/>
<StatCard label="Payments" value={summary.lifetime_payment_count.toLocaleString()} />
</Section>
<Section title="Revenue mix">
<StatCard label="Subscriptions" value={formatUsd(summary.lifetime_subscription_spend_usd_cents)} />
<StatCard label="Credit packs" value={formatUsd(summary.lifetime_credits_spend_usd_cents)} />
<StatCard
label="Subscriber"
value={summary.is_active_subscriber ? "Active" : "No"}
sub={summary.maybe_subscription_interval ?? undefined}
badge={
summary.is_active_subscriber ? (
<Badge variant="outline" className="text-[10px] text-emerald-400 border-emerald-500/30">
active
</Badge>
) : undefined
}
/>
<StatCard
label="Re-engagement"
value={summary.maybe_reengagement_score != null ? `${summary.maybe_reengagement_score}` : "—"}
sub="0–1000"
/>
</Section>
<Section title="Recency">
<StatCard
label="First payment"
value={formatDate(summary.maybe_first_payment_at)}
sub={summary.first_spend_usd_cents ? formatUsd(summary.first_spend_usd_cents) : undefined}
/>
<StatCard
label="Last payment"
value={formatDate(summary.maybe_last_payment_at)}
sub={summary.last_spend_usd_cents ? formatUsd(summary.last_spend_usd_cents) : undefined}
/>
<StatCard
label="Days since last"
value={summary.maybe_days_since_last_payment != null ? `${summary.maybe_days_since_last_payment}d` : "—"}
/>
<StatCard
label="Weeks since spend"
value={summary.maybe_weeks_since_last_spend != null ? `${summary.maybe_weeks_since_last_spend}w` : "—"}
/>
</Section>
<Section title="Recent net spend">
<StatCard label="Last 7 days" value={formatUsd(summary.net_spend_7d_usd_cents)} sub={`prev ${formatUsd(summary.net_spend_prev_7d_usd_cents)}`} />
<StatCard label="Last 30 days" value={formatUsd(summary.net_spend_30d_usd_cents)} sub={`prev ${formatUsd(summary.net_spend_prev_30d_usd_cents)}`} />
<StatCard label="Last 90 days" value={formatUsd(summary.net_spend_90d_usd_cents)} />
<StatCard label="This year" value={formatUsd(summary.net_spend_this_year_usd_cents)} />
</Section>
<Section title="Cadence (Mon–Sun weeks)">
<StatCard label="Avg weekly (4w)" value={formatUsd(summary.avg_weekly_net_spend_4w_usd_cents)} />
<StatCard label="Avg weekly (12w)" value={formatUsd(summary.avg_weekly_net_spend_12w_usd_cents)} />
<StatCard
label="Active weeks (52)"
value={`${summary.active_weeks_in_last_52}/52`}
sub={`${summary.active_weeks_in_last_12}/12 recent`}
/>
<StatCard
label="Current streak"
value={
summary.consecutive_active_weeks > 0
? `${summary.consecutive_active_weeks}w active`
: `${summary.consecutive_inactive_weeks}w idle`
}
/>
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">{children}</div>
</div>
);
}
function StatCard({
label,
value,
sub,
emphasis,
badge,
}: {
label: string;
value: string;
sub?: string;
emphasis?: boolean;
badge?: React.ReactNode;
}) {
return (
<Card className="gap-0 py-4">
<CardContent className="px-4 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{label}</span>
{badge}
</div>
<span
className={
emphasis
? "text-2xl font-bold tabular-nums tracking-tight"
: "text-xl font-semibold tabular-nums tracking-tight"
}
>
{value}
</span>
{sub && <span className="text-[11px] text-muted-foreground/70 tabular-nums">{sub}</span>}
</CardContent>
</Card>
);
}
@@ -10,6 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 4202,
proxy: {
"/api": {
target: "https://api.storyteller.ai",
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Launch the Artcraft admin/moderator dashboard dev server.
#
# NOTE: frontend/apps/artcraft-dashboard is a STANDALONE app (its own
# package.json + pnpm-lock.yaml + node_modules, package manager: pnpm). It is
# NOT part of the Nx workspace, so we do NOT use `nx` or the nx preflight here.
root_dir=$(pwd)
dashboard_path="${root_dir}/frontend/apps/artcraft-dashboard"
if [ ! -d "${dashboard_path}" ]; then
echo "ERROR: dashboard directory not found at: ${dashboard_path}"
echo "Run this script from the repository root."
exit 1
fi
if ! command -v node &>/dev/null; then
echo "ERROR: node is not installed (or not on PATH)."
exit 1
fi
# pnpm is the dashboard's declared package manager. Prefer a real pnpm on PATH;
# otherwise run it through corepack (ships with Node) which provisions the
# version pinned in package.json's "packageManager" field.
if command -v pnpm &>/dev/null; then
pnpm_cmd=(pnpm)
elif command -v corepack &>/dev/null; then
pnpm_cmd=(corepack pnpm)
else
echo "ERROR: pnpm is not available. Install it with one of:"
echo " corepack enable # ships with Node.js"
echo " npm install -g pnpm"
exit 1
fi
echo "Running Artcraft Dashboard in Dev Mode..."
echo ""
# Kill any process running on port 4202, which will block startup
if lsof -i tcp:4202 &>/dev/null; then
lsof -i tcp:4202 -t | xargs kill -9
echo "Killed process running on port 4202"
else
echo "No process running on port 4202"
fi
pushd "${dashboard_path}" || exit
"${pnpm_cmd[@]}" install
export VITE_ENVIRONMENT_TYPE="production"
"${pnpm_cmd[@]}" dev
popd || exit