diff --git a/database/entities/src/entities/discount.rs b/database/entities/src/entities/discount.rs index 9a55e56..fcc483a 100644 --- a/database/entities/src/entities/discount.rs +++ b/database/entities/src/entities/discount.rs @@ -36,6 +36,8 @@ pub enum Relation { DiscountRedemption, #[sea_orm(has_many = "super::discount_target::Entity")] DiscountTarget, + #[sea_orm(has_many = "super::referral::Entity")] + Referral, } impl Related for Entity { @@ -50,4 +52,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Referral.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/database/entities/src/entities/mod.rs b/database/entities/src/entities/mod.rs index d69e7bf..58b62e1 100644 --- a/database/entities/src/entities/mod.rs +++ b/database/entities/src/entities/mod.rs @@ -50,6 +50,7 @@ pub mod player_owned_cosmetic; pub mod player_skin; pub mod product_requirement; pub mod product_settings; +pub mod referral; pub mod relationship_requests; pub mod relationships; pub mod sea_orm_active_enums; diff --git a/database/entities/src/entities/prelude.rs b/database/entities/src/entities/prelude.rs index 429d382..2b574f7 100644 --- a/database/entities/src/entities/prelude.rs +++ b/database/entities/src/entities/prelude.rs @@ -48,6 +48,7 @@ pub use super::player_owned_cosmetic::Entity as PlayerOwnedCosmetic; pub use super::player_skin::Entity as PlayerSkin; pub use super::product_requirement::Entity as ProductRequirement; pub use super::product_settings::Entity as ProductSettings; +pub use super::referral::Entity as Referral; pub use super::relationship_requests::Entity as RelationshipRequests; pub use super::relationships::Entity as Relationships; pub use super::session_invites::Entity as SessionInvites; diff --git a/database/entities/src/entities/referral.rs b/database/entities/src/entities/referral.rs new file mode 100644 index 0000000..ae3eb98 --- /dev/null +++ b/database/entities/src/entities/referral.rs @@ -0,0 +1,43 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.14 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "referral")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false, column_type = "Text")] + pub code: String, + #[sea_orm(column_type = "Text")] + pub name: String, + pub discount_id: Option, + pub enabled: bool, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::discount::Entity", + from = "Column::DiscountId", + to = "super::discount::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + Discount, + #[sea_orm(has_many = "super::tracked_links::Entity")] + TrackedLinks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Discount.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TrackedLinks.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/database/entities/src/entities/tracked_links.rs b/database/entities/src/entities/tracked_links.rs index 64ae81a..a896de3 100644 --- a/database/entities/src/entities/tracked_links.rs +++ b/database/entities/src/entities/tracked_links.rs @@ -11,9 +11,26 @@ pub struct Model { pub target_url: String, pub clicks: i64, pub created_at: DateTimeWithTimeZone, + #[sea_orm(column_type = "Text", nullable)] + pub referral_code: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} +pub enum Relation { + #[sea_orm( + belongs_to = "super::referral::Entity", + from = "Column::ReferralCode", + to = "super::referral::Column::Code", + on_update = "NoAction", + on_delete = "SetNull" + )] + Referral, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Referral.def() + } +} impl ActiveModelBehavior for ActiveModel {} diff --git a/database/entities/src/entities/transaction.rs b/database/entities/src/entities/transaction.rs index 7b9bab9..7ed8c22 100644 --- a/database/entities/src/entities/transaction.rs +++ b/database/entities/src/entities/transaction.rs @@ -25,6 +25,8 @@ pub struct Model { pub refunded_at: Option, pub refunded_minor: i64, pub charged_back_at: Option, + #[sea_orm(column_type = "Text", nullable)] + pub referral_code: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/database/migrations/src/lib.rs b/database/migrations/src/lib.rs index 4a75343..c435638 100644 --- a/database/migrations/src/lib.rs +++ b/database/migrations/src/lib.rs @@ -72,6 +72,7 @@ mod m20260928_000000_create_player_mods; mod m20260929_000000_product_for_sale; mod m20261014_000000_create_dm_delegates; mod m20261015_000000_create_outfits_and_skins; +mod m20261017_000000_create_referrals; mod m20261017_000000_create_linked_accounts; pub struct Migrator; @@ -152,6 +153,7 @@ impl MigratorTrait for Migrator { Box::new(m20260929_000000_product_for_sale::Migration), Box::new(m20261014_000000_create_dm_delegates::Migration), Box::new(m20261015_000000_create_outfits_and_skins::Migration), + Box::new(m20261017_000000_create_referrals::Migration), Box::new(m20261017_000000_create_linked_accounts::Migration), ] } diff --git a/database/migrations/src/m20261017_000000_create_referrals.rs b/database/migrations/src/m20261017_000000_create_referrals.rs new file mode 100644 index 0000000..a6f9620 --- /dev/null +++ b/database/migrations/src/m20261017_000000_create_referrals.rs @@ -0,0 +1,123 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +/// A creator code. With a coupon linked, using it also applies that coupon. +#[derive(DeriveIden)] +enum Referral { + Table, + /// Stored and matched upper case. + Code, + Name, + DiscountId, + Enabled, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Discount { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Transaction { + Table, + /// No foreign key: attribution outlives a deleted code. + ReferralCode, +} + +#[derive(DeriveIden)] +enum TrackedLinks { + Table, + ReferralCode, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Referral::Table) + .if_not_exists() + .col( + ColumnDef::new(Referral::Code) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Referral::Name).text().not_null()) + .col(ColumnDef::new(Referral::DiscountId).integer().null()) + .col( + ColumnDef::new(Referral::Enabled) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(Referral::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .from(Referral::Table, Referral::DiscountId) + .to(Discount::Table, Discount::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Transaction::Table) + .add_column(ColumnDef::new(Transaction::ReferralCode).text().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(TrackedLinks::Table) + .add_column(ColumnDef::new(TrackedLinks::ReferralCode).text().null()) + .add_foreign_key( + TableForeignKey::new() + .from_tbl(TrackedLinks::Table) + .from_col(TrackedLinks::ReferralCode) + .to_tbl(Referral::Table) + .to_col(Referral::Code) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(TrackedLinks::Table) + .drop_column(TrackedLinks::ReferralCode) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(Transaction::Table) + .drop_column(Transaction::ReferralCode) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(Referral::Table).to_owned()) + .await + } +} diff --git a/src/api/v0/links.rs b/src/api/v0/links.rs index 4910ef1..b2401a6 100644 --- a/src/api/v0/links.rs +++ b/src/api/v0/links.rs @@ -23,6 +23,7 @@ use serde::{Deserialize, Serialize}; use crate::{ api::{ApiState, admin_auth::AdminAuthenticationExtractor}, + pricing::normalise_code, utils::{ hash::sha256_hex_parts, user_agent::is_bot, @@ -48,6 +49,8 @@ pub enum LinksError { InvalidSlug, #[error("Target url must be an absolute http(s) url")] InvalidUrl, + #[error("No creator code {0} exists")] + UnknownReferral(String), #[error("Unable to query database: {0}")] Database(#[from] sea_orm::error::DbErr), } @@ -58,7 +61,9 @@ impl IntoResponse for LinksError { match self { Self::NotFound => StatusCode::NOT_FOUND, Self::SlugTaken => StatusCode::CONFLICT, - Self::InvalidSlug | Self::InvalidUrl => StatusCode::BAD_REQUEST, + Self::InvalidSlug | Self::InvalidUrl | Self::UnknownReferral(_) => { + StatusCode::BAD_REQUEST + } Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, }, self, @@ -121,6 +126,7 @@ pub struct LinkInfo { target_url: String, clicks: i64, created_at: DateTime, + referral: Option, } impl LinkInfo { @@ -130,6 +136,7 @@ impl LinkInfo { target_url: link.target_url, clicks: link.clicks, created_at: link.created_at, + referral: link.referral_code, } } } @@ -138,6 +145,10 @@ impl LinkInfo { struct CreateRequest { slug: String, target_url: String, + /// A creator code to credit; following the link adds `ref=CODE` to the + /// target so the store can pass it to checkout. + #[serde(default)] + referral: Option, } #[derive(Debug, Serialize, JsonSchema)] @@ -161,7 +172,9 @@ fn create_doc(op: TransformOperation) -> TransformOperation { .summary("Create a tracked link") .description( "Creates a tracked short link redirecting `/go/{slug}` to an arbitrary \ - absolute http(s) url. Admin password required.", + absolute http(s) url. With `referral`, the redirect carries \ + `ref=CODE` and the code's stats count the link's clicks. Admin \ + password required.", ) .tag("links") } @@ -227,7 +240,7 @@ async fn follow( txn.commit().await?; } - Ok(Redirect::temporary(&link.target_url)) + Ok(Redirect::temporary(&target(&link))) } #[tracing::instrument(level = "debug", skip(state, _auth))] @@ -253,10 +266,21 @@ async fn create( return Err(LinksError::SlugTaken); } + let referral = body.referral.as_deref().map(normalise_code); + if let Some(code) = &referral + && Referral::find_by_id(code) + .one(&state.database) + .await? + .is_none() + { + return Err(LinksError::UnknownReferral(code.clone())); + } + let link = tracked_links::ActiveModel { slug: Set(body.slug), target_url: Set(body.target_url), clicks: Set(0), + referral_code: Set(referral), ..Default::default() } .insert(&state.database) @@ -302,12 +326,46 @@ async fn delete( Ok(StatusCode::NO_CONTENT) } +fn target(link: &entities::tracked_links::Model) -> String { + let Some(code) = &link.referral_code else { + return link.target_url.clone(); + }; + + match reqwest::Url::parse(&link.target_url) { + Ok(mut url) => { + url.query_pairs_mut().append_pair("ref", code); + url.into() + } + Err(_) => link.target_url.clone(), + } +} + #[cfg(test)] mod tests { use std::net::IpAddr; use super::VisitorId; + #[test] + fn referral_rides_along_as_ref() { + let link = |target: &str, code: Option<&str>| entities::tracked_links::Model { + slug: "x".into(), + target_url: target.into(), + clicks: 0, + created_at: chrono::Utc::now().fixed_offset(), + referral_code: code.map(str::to_owned), + }; + + assert_eq!( + super::target(&link("https://store.example/item?id=1", Some("LYNITH"))), + "https://store.example/item?id=1&ref=LYNITH", + ); + assert_eq!( + super::target(&link("https://store.example/", None)), + "https://store.example/", + ); + } + fn visitor(ip: &str, user_agent: &str) -> VisitorId { VisitorId { ip: ip diff --git a/src/api/v1/discounts.rs b/src/api/v1/discounts.rs index 5603b87..630b384 100644 --- a/src/api/v1/discounts.rs +++ b/src/api/v1/discounts.rs @@ -155,7 +155,7 @@ pub struct DiscountBody { pub max_per_player: Option, } -fn enabled_by_default() -> bool { +pub(super) fn enabled_by_default() -> bool { true } diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index d6275e3..9f756f8 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -4,6 +4,7 @@ mod grants; mod hello; mod listings; mod paynow; +mod referrals; mod store; mod tokens; @@ -20,6 +21,7 @@ pub(super) async fn setup_router() -> ApiRouter { .merge(discounts::router()) .merge(grants::router()) .merge(listings::router()) + .merge(referrals::router()) .merge(store::router()) .merge(hello::router()) .merge(tokens::router()) diff --git a/src/api/v1/paynow/create.rs b/src/api/v1/paynow/create.rs index 752c894..3e0867c 100644 --- a/src/api/v1/paynow/create.rs +++ b/src/api/v1/paynow/create.rs @@ -12,7 +12,7 @@ use axum::{ }; use axum_client_ip::ClientIp; use chrono::Utc; -use entities::{player_owned_cosmetic, prelude::*, tags_cosmetic, user}; +use entities::{player_owned_cosmetic, prelude::*, referral, tags_cosmetic, user}; use schemars::JsonSchema; use sea_orm::{DbErr, prelude::*}; use serde::{Deserialize, Serialize}; @@ -83,6 +83,8 @@ pub(super) enum CreateError { TooManyPromoCodes, #[error("Unknown or expired promo code {0}")] UnknownPromoCode(String), + #[error("Unknown creator code {0}")] + UnknownReferral(String), #[error("{0} is not on sale right now")] NotAvailable(String), #[error("{0} cannot be gifted")] @@ -118,6 +120,7 @@ impl IntoResponse for CreateError { | CreateError::UnknownProduct(_) | CreateError::TooManyPromoCodes | CreateError::UnknownPromoCode(_) + | CreateError::UnknownReferral(_) | CreateError::NotAvailable(_) | CreateError::GiftingDisabled(_) | CreateError::MissingRequirement(_) @@ -140,6 +143,10 @@ pub(super) struct CreateRequest { /// buyer is told which. #[serde(default)] promo_codes: Vec, + /// A creator code, from the buyer or a tracked link's `ref`. Its coupon, + /// if any, applies on its own. + #[serde(default)] + referral: Option, } #[derive(Debug, Serialize, JsonSchema)] @@ -158,7 +165,8 @@ pub fn endpoint_doc(op: TransformOperation) -> TransformOperation { "player already owns any of them or a product is sold out or at the ", "buyer's limit, and 400 if a product id does not resolve to an ", "enabled cosmetic or bundle, is outside its sale window, cannot be ", - "gifted, needs a cosmetic the player lacks, or a promo code is rejected." + "gifted, needs a cosmetic the player lacks, or a promo code or ", + "creator code is rejected." )) .tag("checkout") } @@ -174,6 +182,7 @@ pub(super) async fn endpoint( products, buyer, promo_codes, + referral, } = request; let buyer = buyer.unwrap_or(player); @@ -190,6 +199,8 @@ pub(super) async fn endpoint( return Err(CreateError::TooManyPromoCodes); } + let referral = find_referral(&state, referral.as_deref()).await?; + let products = dedupe(products); if products.is_empty() { return Err(CreateError::NoProducts); @@ -227,6 +238,7 @@ pub(super) async fn endpoint( &resolved, &settings, &promo_codes, + referral.as_ref().and_then(|(_, coupon)| coupon.as_deref()), buyer, ) .await?; @@ -248,7 +260,7 @@ pub(super) async fn endpoint( }) .collect(); - let metadata = HashMap::from([ + let mut metadata = HashMap::from([ ("player".to_string(), player.to_string()), ("buyer".to_string(), buyer.to_string()), ("products".to_string(), products.join(",")), @@ -257,6 +269,9 @@ pub(super) async fn endpoint( applied.quoted_discount_minor.to_string(), ), ]); + if let Some((code, _)) = referral { + metadata.insert("referral".to_string(), code); + } let session = state .paynow @@ -303,6 +318,30 @@ async fn enforce_rate_limit( } } +/// The code as stored, and its coupon's code if it has one. +async fn find_referral( + state: &ApiState, + code: Option<&str>, +) -> Result)>, CreateError> { + let Some(code) = code.map(normalise_code).filter(|code| !code.is_empty()) else { + return Ok(None); + }; + + let Some((referral, discount)) = Referral::find_by_id(&code) + .filter(referral::Column::Enabled.eq(true)) + .find_also_related(Discount) + .one(&state.database) + .await? + else { + return Err(CreateError::UnknownReferral(code)); + }; + + Ok(Some(( + referral.code, + discount.and_then(|discount| discount.code), + ))) +} + async fn reject_already_owned( state: &ApiState, player: Uuid, @@ -460,12 +499,18 @@ async fn apply_discounts( resolved: &HashMap, settings: &HashMap, codes: &[String], + referral_coupon: Option<&str>, buyer: Uuid, ) -> Result { let buyer_id = user_id(state, buyer).await?; - let live = - live_rules(&state.database, codes, buyer_id, Utc::now().into()).await?; + let wanted: Vec = codes + .iter() + .cloned() + .chain(referral_coupon.map(str::to_owned)) + .collect(); + let mut live = + live_rules(&state.database, &wanted, buyer_id, Utc::now().into()).await?; // A code that matched nothing is the buyer's typo, or a campaign that has // ended. Either way they should be told, not silently charged full price. @@ -484,6 +529,21 @@ async fn apply_discounts( paynow_codes.push(code); } + // Unlike a typed code, a lapsed referral coupon must not cost the sale. + if let Some(code) = referral_coupon + && honoured.contains(code) + && !paynow_codes.iter().any(|known| known == code) + { + paynow_codes.push(code.to_owned()); + } + live.retain(|discount| { + discount + .model + .code + .as_ref() + .is_none_or(|code| paynow_codes.contains(code)) + }); + let tags = tags_by_cosmetic(state, resolved).await?; let lines: Vec = product_ids .iter() diff --git a/src/api/v1/paynow/webhook.rs b/src/api/v1/paynow/webhook.rs index be309cf..32e032b 100644 --- a/src/api/v1/paynow/webhook.rs +++ b/src/api/v1/paynow/webhook.rs @@ -151,6 +151,8 @@ async fn handle_order(state: &ApiState, event_id: &str, order: Order) -> Handled ); } + let referral = metadata.get("referral").cloned(); + let charge = OrderCharge { amount_minor: Some(order.total_amount), currency: order.currency.clone(), @@ -193,6 +195,7 @@ async fn handle_order(state: &ApiState, event_id: &str, order: Order) -> Handled "checkout_metadata": metadata.clone() }), charge, + referral, ) .await?; diff --git a/src/api/v1/referrals.rs b/src/api/v1/referrals.rs new file mode 100644 index 0000000..5950d13 --- /dev/null +++ b/src/api/v1/referrals.rs @@ -0,0 +1,327 @@ +use std::collections::HashMap; + +use aide::{ + OperationIo, + axum::{ + ApiRouter, + routing::{get_with, post_with}, + }, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use entities::{prelude::*, referral, tracked_links, transaction}; +use schemars::JsonSchema; +use sea_orm::{ + ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, FromQueryResult, + QueryFilter, QueryOrder, QuerySelect, Set, SqlErr, + sea_query::{Alias, Expr, Func}, +}; +use serde::{Deserialize, Serialize}; + +use super::discounts::enabled_by_default; +use crate::{ + api::{ApiState, admin_auth::AdminAuthenticationExtractor}, + pricing::normalise_code, + utils::validation::valid_slug, +}; + +#[derive(thiserror::Error, Debug, OperationIo)] +pub enum ReferralError { + #[error("No referral with that code exists")] + NotFound, + #[error("The code {0} is already in use")] + DuplicateCode(String), + #[error("Code must be non-empty and contain only A-Z, 0-9, '-' or '_'")] + InvalidCode, + #[error("Discount {0} does not exist or is a sale rather than a coupon")] + NotACoupon(i32), + #[error("Database error: {0}")] + Database(#[from] sea_orm::error::DbErr), +} + +impl IntoResponse for ReferralError { + fn into_response(self) -> axum::response::Response { + crate::api::admin_error_response( + match self { + Self::NotFound => StatusCode::NOT_FOUND, + Self::DuplicateCode(_) => StatusCode::CONFLICT, + Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::InvalidCode | Self::NotACoupon(_) => StatusCode::BAD_REQUEST, + }, + self, + ) + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateBody { + /// Stored and matched upper case. + pub code: String, + /// Who the code belongs to, e.g. the creator. + pub name: String, + /// A coupon applied automatically whenever the code is used. + #[serde(default)] + pub discount_id: Option, + #[serde(default = "enabled_by_default")] + pub enabled: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateBody { + pub name: String, + #[serde(default)] + pub discount_id: Option, + #[serde(default = "enabled_by_default")] + pub enabled: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ReferralView { + pub code: String, + pub name: String, + pub discount_id: Option, + pub enabled: bool, + pub created_at: String, + /// Orders placed with the code, refunded ones included. + pub orders: i64, + /// Charged minus refunded, chargebacks excluded, in the checkout currency. + pub revenue_minor: i64, + /// Unique visits across the tracked links carrying the code. + pub clicks: i64, +} + +#[derive(Debug, Default, FromQueryResult)] +struct Stats { + referral_code: String, + orders: i64, + revenue_minor: Option, +} + +#[derive(Debug, FromQueryResult)] +struct Clicks { + referral_code: String, + clicks: Option, +} + +pub(super) fn router() -> ApiRouter { + ApiRouter::new() + .api_route( + "/admin/referrals", + get_with(self::list, self::list_doc) + .post_with(self::create, self::create_doc), + ) + .api_route( + "/admin/referrals/{code}", + post_with(self::update, self::update_doc) + .delete_with(self::remove, self::remove_doc), + ) +} + +fn list_doc(op: TransformOperation) -> TransformOperation { + op.id("listReferrals") + .summary("List creator codes") + .description( + "Lists every creator code with the orders, revenue and tracked-link \ + clicks attributed to it. Admin password required.", + ) + .tag("referrals") +} + +fn create_doc(op: TransformOperation) -> TransformOperation { + op.id("createReferral") + .summary("Create a creator code") + .description( + "Creates a creator code buyers pass to checkout as `referral`. Link a \ + coupon with `discount_id` and checkout applies it on its own; a \ + coupon that has ended or run out is skipped rather than failing the \ + checkout. Point a tracked link at the code and `/go/{slug}` adds \ + `ref=CODE` to its target. Admin password required.", + ) + .tag("referrals") +} + +fn update_doc(op: TransformOperation) -> TransformOperation { + op.id("updateReferral") + .summary("Replace a creator code") + .description( + "Replaces a creator code's name, coupon and enabled flag. The code \ + itself cannot change. Admin password required.", + ) + .tag("referrals") +} + +fn remove_doc(op: TransformOperation) -> TransformOperation { + op.id("deleteReferral") + .summary("Delete a creator code") + .description( + "Deletes a creator code and unlinks its tracked links. Orders keep \ + the code they were placed with; prefer disabling one in use. Admin \ + password required.", + ) + .tag("referrals") +} + +#[tracing::instrument(level = "debug", skip(state, _auth))] +async fn list( + State(state): State, + _auth: AdminAuthenticationExtractor, +) -> Result>, ReferralError> { + let rows = Referral::find() + .order_by_desc(referral::Column::CreatedAt) + .all(&state.database) + .await?; + + let mut stats: HashMap = Transaction::find() + .select_only() + .column(transaction::Column::ReferralCode) + .column_as(transaction::Column::Id.count(), "orders") + .column_as( + Expr::expr(Func::sum( + Expr::case( + transaction::Column::ChargedBackAt.is_null(), + Expr::col(transaction::Column::AmountMinor) + .sub(Expr::col(transaction::Column::RefundedMinor)), + ) + .finally(0), + )) + .cast_as(Alias::new("bigint")), + "revenue_minor", + ) + .filter(transaction::Column::ReferralCode.is_not_null()) + .group_by(transaction::Column::ReferralCode) + .into_model::() + .all(&state.database) + .await? + .into_iter() + .map(|mut row| (std::mem::take(&mut row.referral_code), row)) + .collect(); + + let mut clicks: HashMap = TrackedLinks::find() + .select_only() + .column(tracked_links::Column::ReferralCode) + .column_as( + Expr::col(tracked_links::Column::Clicks) + .sum() + .cast_as(Alias::new("bigint")), + "clicks", + ) + .filter(tracked_links::Column::ReferralCode.is_not_null()) + .group_by(tracked_links::Column::ReferralCode) + .into_model::() + .all(&state.database) + .await? + .into_iter() + .map(|row| (row.referral_code, row.clicks.unwrap_or_default())) + .collect(); + + Ok(Json( + rows.into_iter() + .map(|row| { + let stats = stats.remove(&row.code).unwrap_or_default(); + let clicks = clicks.remove(&row.code).unwrap_or_default(); + view(row, &stats, clicks) + }) + .collect(), + )) +} + +#[tracing::instrument(level = "debug", skip(state, _auth))] +async fn create( + State(state): State, + _auth: AdminAuthenticationExtractor, + Json(body): Json, +) -> Result, ReferralError> { + let code = normalise_code(&body.code); + if !valid_slug(&code.to_ascii_lowercase()) { + return Err(ReferralError::InvalidCode); + } + ensure_coupon(&state, body.discount_id).await?; + + let model = referral::ActiveModel { + code: Set(code.clone()), + name: Set(body.name), + discount_id: Set(body.discount_id), + enabled: Set(body.enabled), + created_at: ActiveValue::NotSet, + } + .insert(&state.database) + .await + .map_err(|error| match error.sql_err() { + Some(SqlErr::UniqueConstraintViolation(_)) => ReferralError::DuplicateCode(code), + _ => error.into(), + })?; + + Ok(Json(view(model, &Stats::default(), 0))) +} + +#[tracing::instrument(level = "debug", skip(state, _auth))] +async fn update( + State(state): State, + _auth: AdminAuthenticationExtractor, + Path(code): Path, + Json(body): Json, +) -> Result, ReferralError> { + ensure_coupon(&state, body.discount_id).await?; + + let mut model: referral::ActiveModel = Referral::find_by_id(normalise_code(&code)) + .one(&state.database) + .await? + .ok_or(ReferralError::NotFound)? + .into(); + model.name = Set(body.name); + model.discount_id = Set(body.discount_id); + model.enabled = Set(body.enabled); + let model = model.update(&state.database).await?; + + Ok(Json(view(model, &Stats::default(), 0))) +} + +#[tracing::instrument(level = "debug", skip(state, _auth))] +async fn remove( + State(state): State, + _auth: AdminAuthenticationExtractor, + Path(code): Path, +) -> Result { + let result = Referral::delete_by_id(normalise_code(&code)) + .exec(&state.database) + .await?; + + if result.rows_affected == 0 { + return Err(ReferralError::NotFound); + } + + Ok(StatusCode::NO_CONTENT) +} + +fn view(model: referral::Model, stats: &Stats, clicks: i64) -> ReferralView { + ReferralView { + code: model.code, + name: model.name, + discount_id: model.discount_id, + enabled: model.enabled, + created_at: model.created_at.to_rfc3339(), + orders: stats.orders, + revenue_minor: stats.revenue_minor.unwrap_or_default(), + clicks, + } +} + +/// A sale applies by itself, so only a coupon means anything here. +async fn ensure_coupon( + state: &ApiState, + discount_id: Option, +) -> Result<(), ReferralError> { + let Some(id) = discount_id else { + return Ok(()); + }; + + match Discount::find_by_id(id).one(&state.database).await? { + Some(discount) if discount.code.is_some() => Ok(()), + _ => Err(ReferralError::NotACoupon(id)), + } +} diff --git a/src/database.rs b/src/database.rs index d56728e..3ed0c96 100644 --- a/src/database.rs +++ b/src/database.rs @@ -45,6 +45,7 @@ pub(crate) trait DatabaseTransactionExt { order_id: &str, raw_metadata: serde_json::Value, charged: OrderCharge, + referral_code: Option, ) -> Result; } @@ -98,6 +99,7 @@ impl DatabaseTransactionExt for Transaction { order_id: &str, raw_metadata: serde_json::Value, charged: OrderCharge, + referral_code: Option, ) -> Result { // Insert first rather than find-then-insert: two deliveries for the // same order can both miss the find. @@ -111,6 +113,7 @@ impl DatabaseTransactionExt for Transaction { amount_minor: ActiveValue::Set(charged.amount_minor), currency: ActiveValue::Set(charged.currency.clone()), discount_minor: ActiveValue::Set(charged.discount_minor), + referral_code: ActiveValue::Set(referral_code.clone()), ..Default::default() }) .on_conflict( @@ -140,6 +143,7 @@ impl DatabaseTransactionExt for Transaction { update.amount_minor = Set(charged.amount_minor); update.currency = Set(charged.currency); update.discount_minor = Set(charged.discount_minor); + update.referral_code = Set(referral_code); update.update(db).await }