mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
payment informatics 3 (#1706)
This commit is contained in:
+12
@@ -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"
|
||||
}
|
||||
+124
@@ -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"
|
||||
}
|
||||
+146
@@ -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"
|
||||
}
|
||||
+443
@@ -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"
|
||||
}
|
||||
+212
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
+157
@@ -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;
|
||||
+41
@@ -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>,
|
||||
}
|
||||
+46
@@ -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 @@
|
||||
pub mod list;
|
||||
+69
@@ -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>,
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod get_summary;
|
||||
pub mod reengagement_list;
|
||||
pub mod top_users_list;
|
||||
+37
@@ -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,
|
||||
}
|
||||
+77
@@ -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>,
|
||||
}
|
||||
+75
@@ -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;
|
||||
|
||||
+87
@@ -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;
|
||||
|
||||
+123
@@ -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)
|
||||
}
|
||||
+69
@@ -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)
|
||||
}
|
||||
+87
@@ -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;
|
||||
|
||||
+64
-1
@@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod moderator_user_daily_spends_handler;
|
||||
+95
@@ -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,
|
||||
}))
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod moderator_list_user_spend_events_handler;
|
||||
+93
@@ -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,
|
||||
}))
|
||||
}
|
||||
+3
@@ -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;
|
||||
+107
@@ -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,
|
||||
}
|
||||
}
|
||||
+86
@@ -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,
|
||||
}))
|
||||
}
|
||||
+92
@@ -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,
|
||||
}))
|
||||
}
|
||||
+31
@@ -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">—</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">—</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">—</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">—</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 ·{" "}
|
||||
<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
@@ -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
|
||||
Reference in New Issue
Block a user