2025-07-12 18:06:36 -04:00
use std ::{ str ::FromStr , time ::Duration } ;
2025-05-05 19:38:01 -04:00
use axum ::{ http ::HeaderMap , response ::IntoResponse , Extension , Json } ;
2025-07-12 18:06:36 -04:00
use axum_extra ::extract ::CookieJar ;
2025-05-10 21:58:02 -04:00
use tetratto_core ::model ::{
auth ::{ User , Notification } ,
moderation ::AuditLogEntry ,
permissions ::FinePermission ,
ApiReturn , Error ,
} ;
2025-05-05 19:38:01 -04:00
use stripe ::{ EventObject , EventType } ;
2025-07-12 18:06:36 -04:00
use crate ::{ get_user_from_token , State } ;
2025-05-05 19:38:01 -04:00
pub async fn stripe_webhook (
Extension ( data ) : Extension < State > ,
headers : HeaderMap ,
body : String ,
) -> impl IntoResponse {
let data = & ( data . read ( ) . await ) . 0 ;
2025-07-12 16:30:57 -04:00
let stripe_cnf = match data . 0. 0. stripe {
Some ( ref c ) = > c ,
None = > return Json ( Error ::MiscError ( " Disabled " . to_string ( ) ) . into ( ) ) ,
} ;
2025-05-05 19:38:01 -04:00
let sig = match headers . get ( " Stripe-Signature " ) {
Some ( s ) = > s ,
None = > return Json ( Error ::NotAllowed . into ( ) ) ,
} ;
let req = match stripe ::Webhook ::construct_event (
& body ,
2025-05-09 22:36:16 -04:00
sig . to_str ( ) . unwrap ( ) ,
2025-06-08 14:15:42 -04:00
& data . 0. 0. stripe . as_ref ( ) . unwrap ( ) . webhook_signing_secret ,
2025-05-05 19:38:01 -04:00
) {
Ok ( e ) = > e ,
Err ( e ) = > return Json ( Error ::MiscError ( e . to_string ( ) ) . into ( ) ) ,
} ;
match req . type_ {
EventType ::CheckoutSessionCompleted = > {
// checkout session ended, store user customer id (from stripe) in their user data
let session = match req . data . object {
EventObject ::CheckoutSession ( c ) = > c ,
_ = > unreachable! ( " cannot be this " ) ,
} ;
let user_id = session
. client_reference_id
. unwrap ( )
. parse ::< usize > ( )
. unwrap ( ) ;
let customer_id = session . customer . unwrap ( ) . id ( ) ;
let user = match data . get_user_by_id ( user_id ) . await {
Ok ( ua ) = > ua ,
Err ( e ) = > return Json ( e . into ( ) ) ,
} ;
2025-07-12 16:30:57 -04:00
tracing ::info! ( " payment {} (stripe: {}) " , user . id , customer_id ) ;
2025-05-05 19:38:01 -04:00
if let Err ( e ) = data
. update_user_stripe_id ( user . id , customer_id . as_str ( ) )
. await
{
return Json ( e . into ( ) ) ;
}
}
EventType ::InvoicePaymentSucceeded = > {
// payment finished and subscription created
2025-06-14 10:16:01 -04:00
// we're doing this *instead* of CustomerSubscriptionCreated because
2025-05-05 19:38:01 -04:00
// the invoice happens *after* the checkout session ends... which is what we need
let invoice = match req . data . object {
EventObject ::Invoice ( c ) = > c ,
_ = > unreachable! ( " cannot be this " ) ,
} ;
let customer_id = invoice . customer . unwrap ( ) . id ( ) ;
2025-07-12 16:30:57 -04:00
let lines = invoice . lines . unwrap ( ) ;
if lines . total_count . unwrap ( ) > 1 {
if let Err ( e ) = data
. create_audit_log_entry ( AuditLogEntry ::new (
0 ,
format! ( " too many invoice line items: stripe {customer_id} " ) ,
) )
. await
{
return Json ( e . into ( ) ) ;
}
return Json ( Error ::MiscError ( " Too many line items " . to_string ( ) ) . into ( ) ) ;
}
let item = match lines . data . get ( 0 ) {
Some ( i ) = > i ,
None = > {
if let Err ( e ) = data
. create_audit_log_entry ( AuditLogEntry ::new (
0 ,
format! ( " too few invoice line items: stripe {customer_id} " ) ,
) )
. await
{
return Json ( e . into ( ) ) ;
}
return Json ( Error ::MiscError ( " Too few line items " . to_string ( ) ) . into ( ) ) ;
}
} ;
let product_id = item
. price
. as_ref ( )
. unwrap ( )
. product
. as_ref ( )
. unwrap ( )
. id ( )
. to_string ( ) ;
2025-05-05 19:38:01 -04:00
// pull user and update role
2025-05-10 21:58:02 -04:00
let mut retries : usize = 0 ;
let mut user : Option < User > = None ;
loop {
if retries > = 5 {
2025-06-27 01:38:35 -04:00
// we've already tried 5 times (25 seconds of waiting)... it's not
2025-05-10 21:58:02 -04:00
// going to happen
//
// we're going to report this error to the audit log so someone can
// check manually later
if let Err ( e ) = data
. create_audit_log_entry ( AuditLogEntry ::new (
0 ,
format! ( " invoice tier update failed: stripe {customer_id} " ) ,
) )
. await
{
return Json ( e . into ( ) ) ;
}
return Json ( Error ::GeneralNotFound ( " user " . to_string ( ) ) . into ( ) ) ;
}
match data . get_user_by_stripe_id ( customer_id . as_str ( ) ) . await {
Ok ( ua ) = > {
if ! user . is_none ( ) {
break ;
}
user = Some ( ua ) ;
break ;
}
Err ( _ ) = > {
tracing ::info! ( " checkout session not stored in db yet " ) ;
retries + = 1 ;
2025-06-27 01:38:35 -04:00
tokio ::time ::sleep ( Duration ::from_secs ( 5 ) ) . await ;
2025-05-10 21:58:02 -04:00
continue ;
}
}
}
let user = user . unwrap ( ) ;
2025-05-07 21:54:21 -04:00
2025-07-12 16:30:57 -04:00
if product_id = = stripe_cnf . product_ids . supporter {
// supporter
tracing ::info! ( " found subscription user in {retries} tries " ) ;
2025-05-05 19:38:01 -04:00
2025-07-12 16:30:57 -04:00
if user . permissions . check ( FinePermission ::SUPPORTER ) {
return Json ( ApiReturn {
ok : true ,
message : " Already applied " . to_string ( ) ,
payload : ( ) ,
} ) ;
}
tracing ::info! ( " invoice {} (stripe: {}) " , user . id , customer_id ) ;
let new_user_permissions = user . permissions | FinePermission ::SUPPORTER ;
2025-05-05 19:38:01 -04:00
2025-07-03 21:56:21 -04:00
if let Err ( e ) = data
2025-07-12 16:30:57 -04:00
. update_user_role ( user . id , new_user_permissions , user . clone ( ) , true )
2025-07-03 21:56:21 -04:00
. await
{
return Json ( e . into ( ) ) ;
}
2025-07-12 16:30:57 -04:00
if data . 0. 0. security . enable_invite_codes & & user . awaiting_purchase {
if let Err ( e ) = data
. update_user_awaiting_purchased_status ( user . id , false , user . clone ( ) , false )
. await
{
return Json ( e . into ( ) ) ;
}
}
if let Err ( e ) = data
. create_notification ( Notification ::new (
" Welcome new supporter! " . to_string ( ) ,
" Thank you for your support! Your account has been updated with your new role. "
. to_string ( ) ,
user . id ,
) )
. await
{
return Json ( e . into ( ) ) ;
}
} else {
tracing ::error! (
" received an invalid stripe product id, please check config.stripe.product_ids "
) ;
return Json ( Error ::MiscError ( " Unknown product ID " . to_string ( ) ) . into ( ) ) ;
2025-05-05 19:38:01 -04:00
}
}
EventType ::CustomerSubscriptionDeleted = > {
// payment failed and subscription deleted
let subscription = match req . data . object {
EventObject ::Subscription ( c ) = > c ,
_ = > unreachable! ( " cannot be this " ) ,
} ;
let customer_id = subscription . customer . id ( ) ;
let user = match data . get_user_by_stripe_id ( customer_id . as_str ( ) ) . await {
Ok ( ua ) = > ua ,
Err ( e ) = > return Json ( e . into ( ) ) ,
} ;
tracing ::info! ( " unsubscribe {} (stripe: {}) " , user . id , customer_id ) ;
let new_user_permissions = user . permissions - FinePermission ::SUPPORTER ;
if let Err ( e ) = data
. update_user_role ( user . id , new_user_permissions , user . clone ( ) , true )
. await
{
return Json ( e . into ( ) ) ;
}
2025-07-03 21:56:21 -04:00
if data . 0. 0. security . enable_invite_codes & & user . was_purchased & & user . invite_code = = 0
{
// user doesn't come from an invite code, and is a purchased account
// this means their account must be locked if they stop paying
if let Err ( e ) = data
. update_user_awaiting_purchased_status ( user . id , true , user . clone ( ) , false )
. await
{
return Json ( e . into ( ) ) ;
}
}
2025-05-05 19:38:01 -04:00
if let Err ( e ) = data
. create_notification ( Notification ::new (
" Sorry to see you go... :( " . to_string ( ) ,
" Thank you for your past support! Please feel free to leave us feedback on why you decided to cancel. "
. to_string ( ) ,
user . id ,
) )
. await
{
return Json ( e . into ( ) ) ;
}
}
2025-07-07 14:45:30 -04:00
EventType ::InvoicePaymentFailed = > {
// payment failed
2025-07-13 12:42:28 -04:00
let invoice = match req . data . object {
EventObject ::Invoice ( i ) = > i ,
2025-07-07 14:45:30 -04:00
_ = > unreachable! ( " cannot be this " ) ,
} ;
2025-07-13 12:42:28 -04:00
let customer_id = invoice . customer . expect ( " TETRATTO_STRIPE_NO_CUSTOMER " ) . id ( ) ;
2025-07-07 14:45:30 -04:00
let user = match data . get_user_by_stripe_id ( customer_id . as_str ( ) ) . await {
Ok ( ua ) = > ua ,
Err ( e ) = > return Json ( e . into ( ) ) ,
} ;
tracing ::info! (
" unsubscribe (pay fail) {} (stripe: {}) " ,
user . id ,
customer_id
) ;
let new_user_permissions = user . permissions - FinePermission ::SUPPORTER ;
if let Err ( e ) = data
. update_user_role ( user . id , new_user_permissions , user . clone ( ) , true )
. await
{
return Json ( e . into ( ) ) ;
}
if data . 0. 0. security . enable_invite_codes & & user . was_purchased & & user . invite_code = = 0
{
// user doesn't come from an invite code, and is a purchased account
// this means their account must be locked if they stop paying
if let Err ( e ) = data
. update_user_awaiting_purchased_status ( user . id , true , user . clone ( ) , false )
. await
{
return Json ( e . into ( ) ) ;
}
}
if let Err ( e ) = data
. create_notification ( Notification ::new (
" It seems your recent payment has failed :( " . to_string ( ) ,
2025-07-13 23:15:00 -04:00
" No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment. \n \n If you've cancelled your subscription, you can safely disregard this. "
2025-07-07 14:45:30 -04:00
. to_string ( ) ,
user . id ,
) )
. await
{
return Json ( e . into ( ) ) ;
}
}
2025-05-05 19:38:01 -04:00
_ = > return Json ( Error ::Unknown . into ( ) ) ,
}
Json ( ApiReturn {
ok : true ,
message : " Acceptable " . to_string ( ) ,
payload : ( ) ,
} )
}
2025-07-12 18:06:36 -04:00
pub async fn onboarding_account_link_request (
jar : CookieJar ,
Extension ( data ) : Extension < State > ,
) -> impl IntoResponse {
let data = & ( data . read ( ) . await ) ;
let user = match get_user_from_token! ( jar , data . 0 ) {
Some ( ua ) = > ua ,
None = > return Json ( Error ::NotAllowed . into ( ) ) ,
} ;
2025-07-12 21:05:45 -04:00
if user . seller_data . account_id . is_some ( ) {
return Json ( Error ::NotAllowed . into ( ) ) ;
}
2025-07-12 18:06:36 -04:00
let client = match data . 3 {
Some ( ref c ) = > c ,
None = > return Json ( Error ::Unknown . into ( ) ) ,
} ;
match stripe ::AccountLink ::create (
& client ,
stripe ::CreateAccountLink {
account : match user . seller_data . account_id {
Some ( id ) = > stripe ::AccountId ::from_str ( & id ) . unwrap ( ) ,
None = > return Json ( Error ::NotAllowed . into ( ) ) ,
} ,
type_ : stripe ::AccountLinkType ::AccountOnboarding ,
collect : None ,
expand : & [ ] ,
refresh_url : Some ( & format! (
" {}/auth/connections_link/seller/refresh " ,
data . 0. 0. 0. host
) ) ,
return_url : Some ( & format! (
" {}/auth/connections_link/seller/return " ,
data . 0. 0. 0. host
) ) ,
collection_options : None ,
} ,
)
. await
{
Ok ( x ) = > Json ( ApiReturn {
ok : true ,
message : " Acceptable " . to_string ( ) ,
payload : Some ( x . url ) ,
} ) ,
Err ( e ) = > Json ( Error ::MiscError ( e . to_string ( ) ) . into ( ) ) ,
}
}
pub async fn create_seller_account_request (
jar : CookieJar ,
Extension ( data ) : Extension < State > ,
) -> impl IntoResponse {
let data = & ( data . read ( ) . await ) ;
let mut user = match get_user_from_token! ( jar , data . 0 ) {
Some ( ua ) = > ua ,
None = > return Json ( Error ::NotAllowed . into ( ) ) ,
} ;
2025-07-12 21:05:45 -04:00
if user . seller_data . account_id . is_some ( ) {
return Json ( Error ::NotAllowed . into ( ) ) ;
}
2025-07-12 18:06:36 -04:00
let client = match data . 3 {
Some ( ref c ) = > c ,
None = > return Json ( Error ::Unknown . into ( ) ) ,
} ;
let account = match stripe ::Account ::create (
& client ,
stripe ::CreateAccount {
type_ : Some ( stripe ::AccountType ::Express ) ,
capabilities : Some ( stripe ::CreateAccountCapabilities {
card_payments : Some ( stripe ::CreateAccountCapabilitiesCardPayments {
requested : Some ( true ) ,
} ) ,
transfers : Some ( stripe ::CreateAccountCapabilitiesTransfers {
requested : Some ( true ) ,
} ) ,
.. Default ::default ( )
} ) ,
.. Default ::default ( )
} ,
)
. await
{
Ok ( a ) = > a ,
Err ( e ) = > return Json ( Error ::MiscError ( e . to_string ( ) ) . into ( ) ) ,
} ;
user . seller_data . account_id = Some ( account . id . to_string ( ) ) ;
match data
. 0
. update_user_seller_data ( user . id , user . seller_data )
. await
{
Ok ( _ ) = > Json ( ApiReturn {
ok : true ,
message : " Acceptable " . to_string ( ) ,
payload : ( ) ,
} ) ,
Err ( e ) = > return Json ( e . into ( ) ) ,
}
}
2025-07-12 21:05:45 -04:00
pub async fn login_link_request (
jar : CookieJar ,
Extension ( data ) : Extension < State > ,
) -> impl IntoResponse {
let data = & ( data . read ( ) . await ) ;
let user = match get_user_from_token! ( jar , data . 0 ) {
Some ( ua ) = > ua ,
None = > return Json ( Error ::NotAllowed . into ( ) ) ,
} ;
if user . seller_data . account_id . is_none ( ) | ! user . seller_data . completed_onboarding {
return Json ( Error ::NotAllowed . into ( ) ) ;
}
let client = match data . 3 {
Some ( ref c ) = > c ,
None = > return Json ( Error ::Unknown . into ( ) ) ,
} ;
match stripe ::LoginLink ::create (
& client ,
& stripe ::AccountId ::from_str ( & user . seller_data . account_id . unwrap ( ) ) . unwrap ( ) ,
& data . 0. 0. 0. host ,
)
. await
{
Ok ( x ) = > Json ( ApiReturn {
ok : true ,
message : " Acceptable " . to_string ( ) ,
payload : Some ( x . url ) ,
} ) ,
Err ( e ) = > Json ( Error ::MiscError ( e . to_string ( ) ) . into ( ) ) ,
}
}