srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/server
diff options
context:
space:
mode:
Diffstat (limited to 'crates/server')
-rw-r--r--crates/server/migrations/0017_merch.sql47
-rw-r--r--crates/server/src/admin.rs95
-rw-r--r--crates/server/src/billing.rs189
-rw-r--r--crates/server/src/cosmetics.rs29
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> {