diff options
Diffstat (limited to 'crates/server')
| -rw-r--r-- | crates/server/migrations/0017_merch.sql | 47 | ||||
| -rw-r--r-- | crates/server/src/admin.rs | 95 | ||||
| -rw-r--r-- | crates/server/src/billing.rs | 189 | ||||
| -rw-r--r-- | crates/server/src/cosmetics.rs | 29 |
4 files changed, 355 insertions, 5 deletions
diff --git a/crates/server/migrations/0017_merch.sql b/crates/server/migrations/0017_merch.sql new file mode 100644 index 0000000..9aba955 --- /dev/null +++ b/crates/server/migrations/0017_merch.sql @@ -0,0 +1,47 @@ +- Physical goods: shirts, mugs, deskmats. +-- +- These differ from cosmetics in three ways that the schema has to carry. They +- have a size or colour to choose. They need a shipping address. And nothing +- is granted on payment: an order is recorded for someone to pack and post. +CREATE TABLE merch ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + price_cents INTEGER NOT NULL, + - 'shirt', 'mug', 'deskmat'. Groups the store display. + kind TEXT NOT NULL, + - Sizes or colours. Empty means the item has only one form. + variants TEXT[] NOT NULL DEFAULT '{}', + - Postage, charged once per order rather than per item. + shipping_cents INTEGER NOT NULL DEFAULT 0, + available BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +INSERT INTO merch (id, name, description, price_cents, kind, variants, shipping_cents, sort_order) VALUES + ('shirt-mark', 'Mark T-Shirt', 'The TyperPunk mark, printed small on the left chest.', 2600, 'shirt', + ARRAY['S','M','L','XL','2XL'], 600, 1), + ('shirt-layout', 'Home Row T-Shirt', 'ASDF JKL; across the chest, set in the same face the app types in.', 2800, 'shirt', + ARRAY['S','M','L','XL','2XL'], 600, 2), + ('mug-prompt', 'Prompt Mug', 'The mark on one side, a blinking block cursor on the other. 11oz.', 1600, 'mug', + '{}', 700, 3), + ('mug-wpm', 'WPM Mug', 'Reads "measured in words per minute, drunk in cups per hour". 11oz.', 1600, 'mug', + '{}', 700, 4), + ('deskmat-track', 'Race Deskmat', 'The race track across 900x400mm, stitched edge, rubber base.', 3800, 'deskmat', + '{}', 900, 5), + ('deskmat-mark', 'Mark Deskmat', 'The mark bottom-right on plain black. 900x400mm, stitched edge.', 3600, 'deskmat', + '{}', 900, 6); + +- Orders. A purchase row already records payment; this records what has to be +- packed and where it goes. The address comes back from the processor, which +- collected it during checkout, so it is never typed into this site. +ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_kind_check; +ALTER TABLE purchases ADD CONSTRAINT purchases_kind_check + CHECK (kind IN ('cosmetic', 'supporter', 'bundle', 'merch')); +ALTER TABLE purchases ADD COLUMN merch_id TEXT REFERENCES merch(id) ON DELETE SET NULL; +ALTER TABLE purchases ADD COLUMN merch_variant TEXT; +- Set once the item is posted. NULL means it is still waiting to be packed. +ALTER TABLE purchases ADD COLUMN shipped_at TEXT; +ALTER TABLE purchases ADD COLUMN shipping_address TEXT; + +CREATE INDEX idx_purchases_unshipped ON purchases(kind, status, shipped_at); diff --git a/crates/server/src/admin.rs b/crates/server/src/admin.rs index 4b9ec51..805c2d2 100644 --- a/crates/server/src/admin.rs +++ b/crates/server/src/admin.rs @@ -22,6 +22,8 @@ pub fn router() -> Router<Arc<AppState>> { Router::new() .route("/api/admin/users", get(list_users)) .route("/api/admin/users/:username/role", post(set_role)) + .route("/api/admin/orders", get(list_orders)) + .route("/api/admin/orders/:id/shipped", post(mark_shipped)) } #[derive(Debug, Serialize)] @@ -152,3 +154,96 @@ async fn set_role( ); Ok(Json(serde_json::json!({ "username": username, "moderator": body.moderator }))) } + + +/// Paid merchandise that has not been posted yet. +/// +/// Selling a physical object creates an obligation that no amount of code +/// discharges: somebody has to pack it and take it to a post office. This is +/// the list of what is owed, which without it lives only in the processor's +/// dashboard. +#[derive(Debug, Serialize)] +struct OrderView { + id: String, + username: String, + item: String, + variant: Option<String>, + amount_cents: i32, + paid_at: Option<String>, + shipping_address: Option<String>, + shipped_at: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct OrderQuery { + /// Include orders already posted. Off by default, because the useful + /// question is what still has to go out. + #[serde(default)] + all: bool, +} + +async fn list_orders( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Query(q): Query<OrderQuery>, +) -> Result<impl IntoResponse, AppError> { + require_admin(&state, &jar, &headers).await?; + + let rows = sqlx::query( + "SELECT p.id, u.username, m.name AS item, p.merch_variant, p.amount_cents, + p.paid_at, p.shipping_address, p.shipped_at + FROM purchases p + JOIN users u ON u.id = p.user_id + LEFT JOIN merch m ON m.id = p.merch_id + WHERE p.kind = 'merch' AND p.status = 'paid' + AND ($1 OR p.shipped_at IS NULL) + ORDER BY p.paid_at ASC + LIMIT 200", + ) + .bind(q.all) + .fetch_all(&state.db) + .await?; + + let orders: Vec<OrderView> = rows + .iter() + .map(|r| OrderView { + id: r.try_get("id").unwrap_or_default(), + username: r.try_get("username").unwrap_or_default(), + item: r + .try_get::<Option<String>, _>("item") + .unwrap_or(None) + .unwrap_or_else(|| "(item removed)".to_string()), + variant: r.try_get("merch_variant").unwrap_or(None), + amount_cents: r.try_get("amount_cents").unwrap_or(0), + paid_at: r.try_get("paid_at").unwrap_or(None), + shipping_address: r.try_get("shipping_address").unwrap_or(None), + shipped_at: r.try_get("shipped_at").unwrap_or(None), + }) + .collect(); + + Ok(Json(orders)) +} + +async fn mark_shipped( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + require_admin(&state, &jar, &headers).await?; + + let done = sqlx::query( + "UPDATE purchases SET shipped_at = $1 + WHERE id = $2 AND kind = 'merch' AND status = 'paid' AND shipped_at IS NULL", + ) + .bind(crate::auth::format_timestamp(time::OffsetDateTime::now_utc())) + .bind(&id) + .execute(&state.db) + .await?; + + if done.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/crates/server/src/billing.rs b/crates/server/src/billing.rs index 2852b0d..7ae3207 100644 --- a/crates/server/src/billing.rs +++ b/crates/server/src/billing.rs @@ -31,6 +31,13 @@ use std::sync::Arc; use subtle::ConstantTimeEq; use time::{Duration as TimeDuration, OffsetDateTime}; +/// Where physical orders can be sent. Kept short deliberately: every country +/// added is one somebody has to be willing to post to and handle returns for. +const SHIPPING_COUNTRIES: &[&str] = &[ + "US", "CA", "GB", "IE", "AU", "NZ", "DE", "FR", "NL", "BE", "ES", "IT", + "SE", "NO", "DK", "FI", "PL", "PT", "AT", "CH", "ZA", +]; + /// What the supporter subscription costs, and how long it lasts. const SUPPORTER_PRICE_CENTS: i32 = 300; const SUPPORTER_DAYS: i64 = 30; @@ -43,6 +50,7 @@ pub fn router() -> Router<Arc<AppState>> { Router::new() .route("/api/billing/checkout/:cosmetic_id", post(checkout_cosmetic)) .route("/api/billing/bundle/:bundle_id", post(checkout_bundle)) + .route("/api/billing/merch/:merch_id", post(checkout_merch)) .route("/api/billing/supporter", post(checkout_supporter)) .route("/api/billing/webhook", post(webhook)) } @@ -76,6 +84,7 @@ async fn create_session( kind: &str, cosmetic_id: Option<&str>, bundle_id: Option<&str>, + merch: Option<(&str, Option<&str>, i32)>, name: &str, amount_cents: i32, ) -> Result<String, AppError> { @@ -113,6 +122,35 @@ async fn create_session( if let Some(id) = cosmetic_id { form.push(("metadata[cosmetic_id]".into(), id.to_string())); } + if let Some((merch_id, variant, shipping_cents)) = merch { + form.push(("metadata[merch_id]".into(), merch_id.to_string())); + if let Some(v) = variant { + form.push(("metadata[merch_variant]".into(), v.to_string())); + } + // Stripe collects the address on its own page, so no postal detail is + // ever entered on this site or held by it before an order exists. + for (i, country) in SHIPPING_COUNTRIES.iter().enumerate() { + form.push(( + format!("shipping_address_collection[allowed_countries][{i}]"), + (*country).to_string(), + )); + } + form.push(("phone_number_collection[enabled]".into(), "false".into())); + // Postage as its own line, so the buyer sees what it costs rather + // than finding it folded into the price. + if shipping_cents > 0 { + form.push(("line_items[1][quantity]".into(), "1".into())); + form.push(("line_items[1][price_data][currency]".into(), "usd".into())); + form.push(( + "line_items[1][price_data][unit_amount]".into(), + shipping_cents.to_string(), + )); + form.push(( + "line_items[1][price_data][product_data][name]".into(), + "Shipping".into(), + )); + } + } let res = state .http @@ -140,15 +178,19 @@ async fn create_session( let session: Session = res.json().await.map_err(|e| AppError::Internal(e.into()))?; sqlx::query( - "INSERT INTO purchases (id, user_id, kind, cosmetic_id, bundle_id, amount_cents, currency, status, session_id, created_at) - VALUES ($1, $2, $3, $4, $5, $6, 'usd', 'pending', $7, $8)", + "INSERT INTO purchases (id, user_id, kind, cosmetic_id, bundle_id, merch_id, merch_variant, + amount_cents, currency, status, session_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'usd', 'pending', $9, $10)", ) .bind(&purchase_id) .bind(user_id) .bind(kind) .bind(cosmetic_id) .bind(bundle_id) - .bind(amount_cents) + .bind(merch.map(|(id, _, _)| id)) + .bind(merch.and_then(|(_, v, _)| v)) + // The recorded amount includes postage, because that is what was charged. + .bind(amount_cents + merch.map_or(0, |(_, _, s)| s)) .bind(&session.id) .bind(format_timestamp(OffsetDateTime::now_utc())) .execute(&state.db) @@ -183,7 +225,7 @@ async fn checkout_cosmetic( return Err(AppError::InvalidInput("you already own that".into())); } - let url = create_session(&state, &user.id, "cosmetic", Some(&cosmetic_id), None, &name, price).await?; + let url = create_session(&state, &user.id, "cosmetic", Some(&cosmetic_id), None, None, &name, price).await?; Ok(Json(serde_json::json!({ "url": url }))) } @@ -217,7 +259,69 @@ async fn checkout_bundle( return Err(AppError::InvalidInput("you already own everything in that bundle".into())); } - let url = create_session(&state, &user.id, "bundle", None, Some(&bundle_id), &name, price).await?; + let url = create_session(&state, &user.id, "bundle", None, Some(&bundle_id), None, &name, price).await?; + Ok(Json(serde_json::json!({ "url": url }))) +} + +#[derive(Deserialize)] +struct MerchRequest { + #[serde(default)] + variant: Option<String>, +} + +async fn checkout_merch( + State(state): State<Arc<AppState>>, + jar: CookieJar, + Path(merch_id): Path<String>, + Json(body): Json<MerchRequest>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let row = sqlx::query( + "SELECT name, price_cents, shipping_cents, variants, available FROM merch WHERE id = $1", + ) + .bind(&merch_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let available: bool = row.try_get("available")?; + if !available { + return Err(AppError::InvalidInput("that item is not for sale".into())); + } + + let name: String = row.try_get("name")?; + let price: i32 = row.try_get("price_cents")?; + let shipping: i32 = row.try_get("shipping_cents")?; + let variants: Vec<String> = row.try_get("variants").unwrap_or_default(); + + // The variant has to be one this item actually comes in. A request naming + // anything else is rejected rather than quietly posted as a medium. + let variant = match (&body.variant, variants.is_empty()) { + (_, true) => None, + (Some(v), false) if variants.iter().any(|allowed| allowed == v) => Some(v.clone()), + _ => { + return Err(AppError::InvalidInput( + "choose a size before buying".into(), + )) + } + }; + + let label = match &variant { + Some(v) => format!("{name} ({v})"), + None => name, + }; + let url = create_session( + &state, + &user.id, + "merch", + None, + None, + Some((&merch_id, variant.as_deref(), shipping)), + &label, + price, + ) + .await?; Ok(Json(serde_json::json!({ "url": url }))) } @@ -232,6 +336,7 @@ async fn checkout_supporter( "supporter", None, None, + None, "TyperPunk supporter, 30 days", SUPPORTER_PRICE_CENTS, ) @@ -304,6 +409,58 @@ struct WebhookData { #[derive(Deserialize)] struct WebhookObject { id: String, + /// Present on a physical order. Stripe collected it on its own page, so + /// this is the first time the address reaches this server. + #[serde(default)] + shipping_details: Option<ShippingDetails>, +} + +#[derive(Deserialize)] +struct ShippingDetails { + #[serde(default)] + name: Option<String>, + #[serde(default)] + address: Option<ShippingAddress>, +} + +#[derive(Deserialize)] +struct ShippingAddress { + #[serde(default)] + line1: Option<String>, + #[serde(default)] + line2: Option<String>, + #[serde(default)] + city: Option<String>, + #[serde(default)] + state: Option<String>, + #[serde(default)] + postal_code: Option<String>, + #[serde(default)] + country: Option<String>, +} + +impl ShippingDetails { + /// One readable block for whoever packs the parcel. Stored as text rather + /// than as columns because nothing queries the parts of an address, and + /// splitting it would only invite assumptions about how addresses work in + /// countries that do not work that way. + fn to_label(&self) -> String { + let a = self.address.as_ref(); + [ + self.name.clone(), + a.and_then(|a| a.line1.clone()), + a.and_then(|a| a.line2.clone()), + a.and_then(|a| a.city.clone()), + a.and_then(|a| a.state.clone()), + a.and_then(|a| a.postal_code.clone()), + a.and_then(|a| a.country.clone()), + ] + .into_iter() + .flatten() + .filter(|part| !part.trim().is_empty()) + .collect::<Vec<_>>() + .join("\n") + } } async fn webhook( @@ -394,6 +551,28 @@ async fn webhook( .await?; } } + "merch" => { + // Nothing is unlocked by buying a mug. What the payment produces + // is an order for somebody to pack, so the address is stored and + // shipped_at is left null until it goes out. + let address = event + .data + .object + .shipping_details + .as_ref() + .map(|d| d.to_label()) + .filter(|a| !a.is_empty()); + if address.is_none() { + // Worth knowing about: it means an order was paid for with + // nowhere to send it, which needs a human either way. + tracing::error!("paid merch order {purchase_id} arrived with no shipping address"); + } + sqlx::query("UPDATE purchases SET shipping_address = $1 WHERE id = $2") + .bind(address) + .bind(&purchase_id) + .execute(&mut *tx) + .await?; + } "supporter" => { // Extends from whichever is later, so renewing early does not // throw away the time already paid for. diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs index de70950..ee9a50d 100644 --- a/crates/server/src/cosmetics.rs +++ b/crates/server/src/cosmetics.rs @@ -16,6 +16,7 @@ pub fn router() -> Router<Arc<AppState>> { .route("/api/cosmetics", get(list_catalog)) .route("/api/cosmetics/me", get(my_cosmetics)) .route("/api/cosmetics/bundles", get(list_bundles)) + .route("/api/merch", get(list_merch)) .route("/api/cosmetics/:id/equip", post(equip)) .route("/api/cosmetics/unequip", post(unequip)) } @@ -61,6 +62,34 @@ struct MyCosmetics { is_supporter: bool, } +/// What is for sale in the physical store. +async fn list_merch(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> { + let rows = sqlx::query( + "SELECT id, name, description, price_cents, kind, variants, shipping_cents + FROM merch WHERE available ORDER BY sort_order", + ) + .fetch_all(&state.db) + .await?; + + let items: Vec<serde_json::Value> = rows + .iter() + .map(|r| { + let variants: Vec<String> = r.try_get("variants").unwrap_or_default(); + serde_json::json!({ + "id": r.try_get::<String, _>("id").unwrap_or_default(), + "name": r.try_get::<String, _>("name").unwrap_or_default(), + "description": r.try_get::<Option<String>, _>("description").unwrap_or(None), + "price_cents": r.try_get::<i32, _>("price_cents").unwrap_or(0), + "shipping_cents": r.try_get::<i32, _>("shipping_cents").unwrap_or(0), + "kind": r.try_get::<String, _>("kind").unwrap_or_default(), + "variants": variants, + }) + }) + .collect(); + + Ok(Json(items)) +} + /// Bundles, with the items each contains so the store can show what is in one /// and what the buyer already owns. async fn list_bundles(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> { |