srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/server/src
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-28 01:17:00 +0200
committersrdusr <[email protected]>2026-02-28 01:17:00 +0200
commit5af72f55bb7fb620af740c9da394e093d7f1280a (patch)
tree81fb6ae1830886a25b0f81912a1a12636493ba51 /crates/server/src
parentd7c1c7b35e9d0995eed41d437404d5255529cf17 (diff)
downloadtyperpunk-5af72f55bb7fb620af740c9da394e093d7f1280a.tar.gz
typerpunk-5af72f55bb7fb620af740c9da394e093d7f1280a.zip
Sell merchandise, put the sprites on the track, and add the page margins
Merchandise. Shirts, mugs and deskmats, sold through the same hosted checkout. Physical goods differ from cosmetics in three ways the schema had to carry: they have a size to choose, they need an address, and payment unlocks nothing. What a paid order produces is an obligation to pack and post something. The address is collected by Stripe on its own page and arrives here on the webhook, so no postal detail is ever entered on this site. Postage is a separate line item rather than folded into the price. A size is checked against the sizes the item actually comes in, on the server as well as in the browser, so a request naming anything else is refused rather than quietly posted as a medium. /api/admin/orders lists what has been paid for and not yet sent, and the Contribute screen shows an administrator the same list with the address and a button to mark each one posted. Without that the list of what is owed lives only in the processor's dashboard. Shipping is limited to 21 countries, which is a list of places somebody is willing to post to, not a technical limit. The race view. The sprite sat still at the left of a 6px bar, which made the one thing a player owns and can see the least visible part of the race. It rides the track now, moving with progress, on a dashed line that reads as road ahead with the trail behind it. The field is 1040px wide rather than 680, the sprites are 30px rather than 18, and each row carries its own percentage as well as its speed. Advertising. Two 160x600 rails in the page margins, shown only above 1424px, which is the width at which they fit beside the widest content column without crowding it. Below that they do not exist. They follow the same rules as the banner: never while typing, never for a supporter. --border-color was used in five places and defined in none. An undefined custom property invalidates the whole declaration at computed-value time, so every one of those borders fell back to currentColor: bundle cards, merch cards, the size buttons and the settings checkbox all had borders that were either invisible or faint text-coloured lines. It is defined now, derived from each theme's neutral so it tracks the palette. .settings-hint caps itself at 340px, which is right under a heading and wrong for a line introducing a full-width grid. Verified against a real database: the catalogue endpoint, a shirt refused without a size, a shirt refused with a size it does not come in, a valid size and a no-variant mug both reaching checkout, 404 on an unknown item, 401 unauthenticated, and 401 on the admin orders list without the role. 24 Rust tests, 3 browser tests, and all 21 screen and viewport combinations clean.
Diffstat (limited to 'crates/server/src')
-rw-r--r--crates/server/src/admin.rs95
-rw-r--r--crates/server/src/billing.rs189
-rw-r--r--crates/server/src/cosmetics.rs29
3 files changed, 308 insertions, 5 deletions
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> {