* feat(sca): expire inactive saved cards * fix(sca): address card ttl review feedback - Correct sql typos breaking GET /cards and /view/init (used_id, id_default) - Stop swallowing backfillCardStorageExpiration error in Migrate - Wrap expireStoredCard local writes in a transaction and run stripe detach + audit only after commit, preventing double-fire on retry - Drop redundant ttl refresh in ConvertCart; webhook is authoritative - Rename validation_expired_at metric label to validation_card_expired - Fix CardStorageExpiresAt doc comment and test failure message typos - Add handler-level tests for expireStoredCards (user scoping + cart deref) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(sca): move card expiration to periodic reconciler Read paths (GetCards, GetViewInit) now filter expired cards from query results instead of detaching them inline, so request handlers no longer block on Stripe API calls. Physical deletion runs in a new ReconcileExpiredCards goroutine on an hourly ticker, mirroring the existing ReconcileOrders / ReconcileUnshipped jobs. Per-card action sites (SetCard, SetDefaultCard, ConvertCart) still expire synchronously since they operate on one row the user is acting on. ViewHandler's stripeKey dependency is dropped now that GetViewInit no longer touches Stripe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sca): address card ttl review feedback - guard zero order.CardID before refreshing card storage ttl in payment_intent.succeeded webhook to avoid noisy warn logs on payment paths that did not use a saved card - delete unused Card.RefreshStorageTTL model method; the SQL-only refreshCardStorageTTL helper is the single canonical path - clarify duplicate-card webhook path: storage_expires_at reset is a card-add refresh, not a payment-success refresh, so last_used_at intentionally stays untouched Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(sca): cover card ttl read paths and expired-card sync sites --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Terminal Coffee Shop
ssh sshops.uk
demo
https://github.com/user-attachments/assets/daf895ac-2c01-4323-bf27-8d7829b9ddd5
it's a coffee shop but in your terminal. you ssh in, browse the menu, chuck stuff in your cart, put in your address and card, and buy coffee without ever touching a browser. your ssh key is your account so there's no login or signup or any of that.
made it because i thought it would be fun to see if you could build a real e-commerce flow that lives entirely in a terminal. turns out you can. heavily inspired by terminal.shop.
what it actually does
ssh in and you get a TUI. browse coffee, add to cart, save addresses, save cards. checkout charges your card via stripe and creates a real order. addresses get validated against shippo (US) or bring (Norway) before they're allowed in. cards are tokenized server side so the secret key never touches your terminal session. adding cards through the tui is coming soon, i just need to register a business and i have no money. webhooks from stripe reconcile order state if anything goes weird mid charge, and a cron job catches orders that got charged but never recorded because the server crashed.
it's a real shop. you can actually buy coffee.
the stack
TUI + SSH. Go with Bubbletea for the terminal UI, Wish for the ssh server. every ssh session spawns its own bubbletea program. lipgloss for styling.
API. Go again, Chi for routing. JWT auth where the token is exchanged for an ssh fingerprint, so the TUI authenticates by proving it has the same key the user ssh'd in with. no passwords anywhere.
Database. SQLite via GORM. yeah just a file. it's fine. i back it up nightly with restic and the SQLite backup API so it's WAL safe even if a write is in flight. when traffic actually demands postgres i'll switch. the DSN prefix logic in pkg/database/db.go already handles both drivers.
Payments. Stripe. cards are tokenized server side, never raw card data on disk. webhooks handle async confirmations like 3DS, deferred charges, refunds. idempotency keys tied to order IDs so retries can't double charge.
Address validation. Shippo for US addresses, Bring for Norway. invalid addresses get rejected before they hit the cart.
where it runs
production lives on a single Hetzner VPS in Helsinki, behind Caddy which handles auto TLS so there's no nginx config to babysit. everything in docker compose. the API, caddy, and the whole observability stack run as separate compose stacks on the same box. public DNS only resolves the API and the marketing site, everything else is tailnet only.
backups go to a second machine sitting in my house. that machine runs Linux From Scratch with a hand compiled kernel and a hand rolled ports repo i maintain myself with scratchpkg. no docker, no apt, every binary built from a recipe i wrote. it also runs my self hosted Gitea. it's got like 4 GB of RAM so it has to run lean. picked it because i wanted to learn how a linux box actually fits together when nobody's holding your hand.
backup pipeline runs nightly. cron on the Hetzner box snapshots the SQLite db via the backup API, ships it over Tailscale SFTP to the LFS box with restic, encrypted and deduped. dual dead man switches, healthchecks.io and a Uptime Kuma push monitor, both go off if the cron silently dies.
observability
probably the over engineered part. honestly built it because i wanted to learn the stack.
Prometheus scrapes the API for the usual RED metrics plus custom counters like cart conversions, auth attempts, stripe events, log levels, orders created.
Grafana sits in front of everything. tailnet only, no public DNS.
Loki + Promtail for logs. promtail auto discovers every container via docker labels so anything new on the box is logged for free.
Tempo for traces. the API exports OTLP via otelchi on the inbound side and otelhttp on outbound calls to stripe, shippo, bring. the GORM tracing plugin emits a span per SQL query.
Uptime Kuma for blackbox checks. API health, TLS, backup target reachable.
Alerts route through Grafana to a ntfy webhook to my phone.
end to end, a request to /api/v1/cart shows up as one trace with the chi handler as the root, gorm queries as children, and outbound HTTP to stripe as a sibling. all one tempo span tree.
local dev
git clone https://github.com/marcusDenslow/terminalShop
cd terminalShop
cp .env.example .env # fill in stripe test keys
go run main.go # ssh server on :23457
go run api/main.go # API on :8080
ssh localhost -p 23457
uses SQLite by default so there's nothing to spin up.
inspiration
terminal.shop for the original idea and most of the patterns in pkg/tui/. their source is open and worth reading.