146 Commits

Author SHA1 Message Date
3a3c7ee80b feat(sca): persist redirect tokens to sqlite with 10min ttl (#22) 2026-05-30 14:12:57 +02:00
7c8a2b562f feat(sca): handle 3ds checkout end to end with qr poll and race fix (#21)
* feat(sca): handle 3ds checkout end to end with qr poll and race fix

* fix(sca): drop unused pollOrderStatusCmd helper
2026-05-28 12:41:36 +02:00
1c5765d25b fix(sca): pass payment method on reconfirm so 3ds next_action surfaces (#20) 2026-05-28 12:20:35 +02:00
e6301e662f fix(sca): re-confirm payment intent on-session to surface 3ds next_action (#19) 2026-05-28 12:05:41 +02:00
8b2889a524 fix(sca): re-fetch payment intent when error response omits next_action (#18) 2026-05-28 11:53:54 +02:00
c2b2b6e834 fix: allow redirect next_action so 3ds challenge url surfaces (#17) 2026-05-28 11:37:55 +02:00
bba5a0dfa0 feat(sca): return 202 + 3ds redirect token when checkout requires auth (#16) 2026-05-28 11:20:16 +02:00
a8e45bc82b feat(sca): map stripe error codes to user-facing copy in tui (#15) 2026-05-28 10:28:47 +02:00
95b9fa91b1 feat(sca): add order status endpoint adn SCA error code mapping (#14)
* feat(sca): add order status endpoint adn SCA error code mapping

adds server-side infra for the upcoming SCA / 3DS challenge flow im
working on. No behavior change to existing checkout paths.

* fix(sca): suppress unused lint on SCA scaffolding helpers
2026-05-27 19:23:29 +02:00
0fe013c399 feat(cart): pass off_session=true on PaymentIntent for SCA exemption eligibility (#13) 2026-05-27 18:09:50 +02:00
d9404ab31e style(tui): update refund page ui (#12) 2026-05-25 23:18:42 +02:00
a21485f91d fix(tui): way to much to write (#11) 2026-05-25 22:34:25 +02:00
bce94b6d10 feat(refund): add refund request flow (#10)
* feat(refund): add refund request flow

- tui modal composer with reason select + free-form message
  (pkg/tui/refund.go)
- POST /api/v1/orders/{id}/refund-request posts to orders slack thread
- 15 min server side cooldown via Order.LastRefundRequestAt (429 +
  retry-after)
- shared refundrequestreasons + can requestrefund hoisted to pkg/models
- slack message includes customer name, email, order total, quoted body
- tests over invalid-state, missing-message, and cooldown paths

* style(tui): run gofmt to fix formatting
2026-05-25 22:04:03 +02:00
dee6172875 refactor(tui): port to charm v2 (#9)
* refactor(tui): port to charm v2

bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly
mechanical chur, new import paths, vewport getter/setter api, view()
returns tea.view now, huh.theme became an interface so the theme package
needed a small reshape. Render plumbing is gone (no more lipgloss.Render
field on the Model or MakeRenderer in main.go). All pages still work
over ssh. setting up for the refund composer next

* style(tui): gofmt main.go and menu.go
2026-05-25 19:09:16 +02:00
b404303f84 feat(notify): daily slack reminder for paid orders awaiting labels (#8)
so the ain was that older paid orders were getting buried in the #orders
channel in slack and i would just forget about them. Pin/unpin from
earlier helped, but if i missed pinning one or the channel got noisy, it
was still easy to lose track.

this adds a 24h cron that queries any paid order older than 24h with no
label yet and dumps them all into one #orders message. Each line shows
the order id, how old it is, total, where it is going, and a permalink
back to the orders slack thread so i can hop in and hit Buy Label

Ports the idea from terminal.shops cron/unshipped.ts but out stuch state
is different( we auto-promote paid->shipped on label-buy so
PRE_TRANSIT-stuck can not happen), so the query is "paid + no tracking
number + >24h" instead.

NO orders get a "Bring blocked" tag since i dont have the norwegian org
set up yet and can not actually ship those today. Also fires once 40s
after startup so a deploy does not black out the reminder for a full
day.
2026-05-25 16:45:15 +02:00
48d65c48fa feat(tui): render customer-facing event timeline in order detail veiew (#7)
Pulls events off the new order_events table and shows them as a timeline
  block in the order detail view: Paid, Label created, In transit,
  Delivered (etc) with timestamps. Filters out ops-only events like
  payment_critical, label_orphaned, and tracking_marked_manually so the
  customer never sees internal plumbing.

  DisplayLabel on OrderEvent decides what's customer-visible and what's
  redacted, so the same order_events rows can later power an admin view
  with no filter.
2026-05-24 20:14:14 +02:00
c78021f520 docs(comments): add some comments (#6) 2026-05-24 18:57:58 +02:00
c429c1966e docs(comments): add some comments (#5)
this is the subject of the commit
2026-05-24 18:44:25 +02:00
50e1ba32f3 docs(comments): add some comments (#4) 2026-05-24 12:26:34 +02:00
85b0c53c0a docs(comments): cleanup comments (#3) 2026-05-24 12:02:50 +02:00
366d80a9ee test(dillydallying): testing the ci workflow (#2) 2026-05-23 22:12:46 +02:00
60f52f9851 add ci (#1)
* ci(checks): add pull request workflow and golangci-lint config

* refactor(lint): resolve golangci-lint findings

* style(tui): drop traling blank line for gofmt

* fix(cards): handle crypto/rand.Read error in storeRedirect
2026-05-23 21:43:30 +02:00
a4c446ba3c fix(tui): change the stripe abandoned payment polling duration from 2 to 10 mins 2026-05-23 13:15:15 +02:00
491f70e935 fix: add 2-minute timeout to browser payment poll so abandoned stripe checkout sessions stop polling 2026-05-23 12:50:35 +02:00
153e487155 chore: add reconcile-cards script to detach orphan stripe pms from customers 2026-05-23 11:34:27 +02:00
1752ca534f chore(tui): update some typage 2026-05-22 22:18:44 +02:00
b2bd6ed623 fix(cards): advance tui after duplicate card detection by pollin on updated_at instead of count 2026-05-22 22:09:55 +02:00
96400ede14 fix(cards): dedupe saved cards via local-db last4+brand+exp match in checkout.sessions.completed webhook 2026-05-22 21:38:18 +02:00
30cf46370c fix(cards): duplicate card entries after adding card through stripe webhook fixed 2026-05-22 21:21:02 +02:00
31f0698d63 chore(gitignore): update gitignore 2026-05-22 21:19:32 +02:00
910b7f3141 chore(gitignore): update .gitignore with the claude skill to map codebase 2026-05-22 20:05:20 +02:00
19e493a3d5 feat(bring): send X-Bring-Client-URL header on booking reqs, defaulting to https://sshops.uk and overridable via BRING_CLIENT_URL env var 2026-05-22 20:02:43 +02:00
2e6dda190f feat(audit): persist every order-scoped audit.X(...) call to a new order_events table alongside the existing slog line, builds the foundation for an admin order timeline view without changing the slog primary path 2026-05-22 20:01:29 +02:00
96e76f7e5b feat(slack): add slack mark status buttons for bring orders, post-label thread now block kit, synthesize shippo webhook over loopback to reuse state-date guard and delivered transition 2026-05-20 18:09:34 +02:00
9d3ac9761d feat(tui): add tracking redirect endpoint, render the short uril in the tui order detail instead of the old broken stuff ive been trying. this change is made by claude code and has not been reviewed carefully, this might break 2026-05-20 15:26:38 +02:00
e49fa01ca9 feat(tui): use BEL terminator for tracking url osc 8 link - hopefully this works 2026-05-20 15:12:56 +02:00
d9e9895913 revert(tui): render raw tracking url, last change wouldnt render the url for some reason 2026-05-20 15:07:14 +02:00
f73e1d18da feat(tui): make tracking url clickable osc 8 hyperlink 2026-05-20 14:55:32 +02:00
29f57000a7 feat(notify): pin paid order messages in the #orders channel in slack, unpin on label purchase 2026-05-20 14:01:28 +02:00
1aaf82d7d6 feat(tui): poll orders every 10 seconds so that the order status refreshes live in the tui without logging out and in 2026-05-19 20:12:03 +02:00
9515603b32 feat(tui): split account orders into active and history tabls 2026-05-19 19:50:21 +02:00
bfd183bfbf fix(ci): remove go module cache step since its redundant 2026-05-15 15:34:21 +02:00
e43040f702 feat(notify): post order vents into per-order slack threads - made by claude 2026-05-15 12:25:45 +02:00
2966474ff5 feat(notify): render slack order notification as block kit receipt with line items 2026-05-15 12:11:16 +02:00
0e4d267adb fix(orders): write NULL instead of empty string for unpopulated shipp_transaction_id 2026-05-15 11:27:00 +02:00
c1b3ccf302 feat(notify): add buy label button via slack 2026-05-15 10:57:23 +02:00
f6eed76456 feat(notify): post Slack message when an order is paid 2026-05-15 08:54:23 +02:00
e5ec247a6b feat(orders): customer-facking tracking labels and webhook response body
the big colored status on the order detail page used to just say
"Shipped" the moment i bought the label, which didnt really feel right
since the carrier hadnt actually picked anything up yet. now it walks
through "Label created" -> "In transit" -> "Delivered" as real scans
come in, and keeps the "Delivery Issue" / "Returned to sender" labels
for unhappy paths

also wired up the existing tracking_status_details column to the tui. i
skipped it before but the response actually includes a description of
the delivery, that is now subtext under the colored statu.

and the shippo webhook now returns a small json body instead of an empty
200. this is just for the operator that runs the curls commands to
     update the shipment, which makes debugging a lot less guesswork
2026-05-14 21:17:50 +02:00
e29dd6b30d fix(middleware): redact query strings from access logs 2026-05-14 13:47:20 +02:00
ee6e14a424 feat(shipping): shippo webhook fro tracking status updates 2026-05-14 13:30:37 +02:00
387758aa13 feat(shipping): add Bring booking client and country dispatch for label purchase 2026-05-13 22:14:32 +02:00
ff9615b75a fix(shippo): fix rate-filter 2026-05-13 08:58:09 +02:00
a2afc47f32 refactor(shipping): check ALREADY_LABELED before INVALID_STATE in PurchaseLabel 2026-05-12 21:47:06 +02:00
37ad51949b fix(shipping): require sender email on shippo address_from
Shippo's transaction validation rejects labels where the from-address
has no email — fails at the poll step with
"Attribute address_from.email must not be empty."

Add Email to Address, plumb it into addrPayload, and read
SHIP_FROM_EMAIL alongside the other SHIP_FROM_* env vars.
2026-05-12 21:17:32 +02:00
99f34788ea fix(shipping): round weights before posting to shippo
Shippo's /v1/orders endpoint rejects >2 decimal places on parcel
weight and >4 on line_items[].weight. Converting ounces to kg
(oz * 0.0283495) routinely produces 6-decimal values, so the first
real label purchase 400'd with "Ensure that there are no more than
N decimal places".

Round at the payload boundary in createShippoOrder rather than in
the caller — keeps the precision of the source (Coffee.Ounces) and
only loses it when the wire format demands it.
2026-05-12 21:12:31 +02:00
c22b64d6fa feat(shipping): shippo label purchase endpoint
Adds POST /api/v1/orders/{id}/label (admin-only) that buys a shipping
  label through Shippo and writes carrier + tracking + label fields back
  onto the order, transitioning paid -> shipped.
2026-05-12 20:57:11 +02:00
a71f72ad71 chore: update typing 2026-05-10 21:08:57 +02:00
700ad4926b feat(models): derive order state from status + tracking 2026-05-09 16:50:33 +02:00
c5f4e492c0 refactor(orders): embed tracking on orders, drop shipments table
realized per-shipment table is kinda bad, switched to single embedded tracking blob. since im only
shipping from my house  no split is needed. adds tracking_status, tracking_status_details,
tracking_status_updated_at columns. reverse-backfills any historical shipments rows back onto orders
(idempotent, table-guarded). UpdateTracking rewritten to write to otder columns direcly
2026-05-09 10:08:32 +02:00
f88fe1d8d3 fix: preload shipments on view init endpoint 2026-05-08 15:15:53 +02:00
c6ff75f2a5 chore. ignore local DB backups and prod backups 2026-05-08 14:59:47 +02:00
add6e573b3 feat: extract shipments to dedicated table 2026-05-08 14:54:17 +02:00
9b45fb5138 updating 2026-05-08 13:43:03 +02:00
1849e324fc feat: add admin tracking updates 2026-05-08 10:47:57 +02:00
c90dadf9ad test was failing after some AI change. this should fix it 2026-05-06 13:53:38 +02:00
38cdda3d54 cleanup 2026-05-06 13:41:23 +02:00
c037a6e9f9 removing unused param 2026-05-05 22:05:29 +02:00
ffb476ec2d updating variable to camelcase 2026-05-05 22:04:24 +02:00
177b080117 some change 2026-05-05 21:04:02 +02:00
9bf57b4048 perf(loadtest): retune overnight scenarios under rate limits
Document and respect per-IP rate limits (200/min global, 20/min auth,
60/min webhooks) by holding scenarios well below the ceilings so the
histogram captures real handler latency instead of 429s. Total combined
~148 req/min over ~10h. Adds summaryTrendStats so reports include
p95/p99/max alongside avg.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 09:51:27 +02:00
c96f922c70 refactor(tui): rebuild header with lipgloss/table
Replaces the manual string-concat + separator layout with a
lipgloss/table row, mirroring the reference TUI structure. Drops the
parallel bold-vs-gray styling for a single-weight accent/base scheme,
extracts a small tab() closure to remove the duplicated active/inactive
branches, and uses m.renderer.NewStyle() for the border so per-session
color profiles render correctly under SSH.

Cart label still tracks inCartFlow() (covers shipping/payment/review/
confirm), which the reference doesn't need since it has no multi-step
checkout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 09:51:21 +02:00
4e28b812bd chore: gitignore runtime artifacts, drop orphan 00 and AI roadmap
Add loadtest/run.pid and loadtest/run.err to .gitignore so k6 runtime
state stops tagging along on git add. Also ignore the orphan /00 file
(stray redirect of pkg/observability/tracing.go) and ROADMAP.md (AI-
synthesised audit doc, not part of the project canon).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 09:51:14 +02:00
7b92636b64 add video 2026-05-02 18:16:21 +02:00
17df27ae20 some updates 2026-05-02 17:48:07 +02:00
52d58583e9 db telemetry 2026-05-02 16:29:07 +02:00
327bb9c867 cleanup 2026-05-02 11:28:28 +02:00
9a5fff81bf fix: defer component logger creation past package init
Package-level slog.With() vars are evaluated at init time, before
observability.InitLogger() runs in main. They captured the original default
slog handler and bypassed the countingHandler that increments
log_messages_total — so warn/error calls from these packages weren't being
counted.

Convert to functions so each call resolves slog.Default() at use time and
inherits the active counting handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:26:03 +02:00
1c10cf4390 feat: structured logging with component tags + severity levels
Convert log.Printf calls in webhook, reconcile, addresses, and auth handlers
to slog with a consistent `component` attribute. Severity now reflects
operational urgency:

- warn: signature failures, declined cards, unknown fingerprints, vendor
  validation rejections, reconciler patching a missed charge
- error: DB write failures, malformed Stripe payloads, token generation
  failures
- info: successful order/card events, normal reconciler ticks

Lets Loki/Prometheus filter by component (e.g. {component="webhook"}) and
gives the log-level dashboard panel real warn/error series instead of a
flat info line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:14:06 +02:00
ae7eaa8e97 feat: route audit + recovery through slog levels
Audit events now emit at Info/Warn/Error so the log-level counter has variety
instead of being pinned to info. Panic recovery uses structured slog.Error
with method/path/stack attrs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 13:55:54 +02:00
8ca209a4cc feat: add cart/auth/stripe metrics and slog-based log-level counter
Adds Prometheus instrumentation across the checkout funnel, auth flow,
Stripe calls, rate limiters, and HTTP middleware (inflight gauge, request
and response sizes). Introduces a slog handler that counts log lines by
level so the dashboard can show error/warn rates; existing log.Printf
calls are routed through slog and counted as info.

Backs the new "terminalshop — Service Overview" Grafana dashboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 13:38:24 +02:00
9e9a8325a8 feat: track created orders metric 2026-04-30 14:37:46 +02:00
179c8011ca added logger 2026-04-30 12:07:21 +02:00
9d4549a32d feat: add OpenTelemetry tracing 2026-04-30 11:35:10 +02:00
31b8e82e47 fix: widen histogram buckets to resolve sub-5ms latency
Default prometheus.DefBuckets bottoms out at 5ms, which made every
route's p95 collapse to 4.75ms (histogram_quantile interpolation
within the smallest bucket). Custom buckets from 100µs to 5s now
distinguish microsecond-fast routes (health) from slower ones
(stripe-backed cart endpoints).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 08:44:56 +02:00
67c8a6bafb feature: expose /metrics with prometheus client_golang
Adds Counter (http_requests_total) and Histogram
(http_request_duration_seconds) labeled by method, chi route pattern,
and status. Wired through a new Metrics middleware (after Recovery so
panics-turned-500s are still counted) and an unauthenticated /metrics
endpoint at the root for Prometheus to scrape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:40:39 +02:00
67f01f4a38 feature: accpet HEAD on /health and /ping for monitoring probes 2026-04-29 15:49:26 +02:00
f47c10ed8a refactor: structured error handling + CreateBox/formatListItem in checkout
Replace ErrorMsg string with typed VisibleError struct. Add catch-all
error handler at top of Update() that auto-recovers cart on shop/cart
errors. ESC now dismisses error banners. Refactor shipping address and
payment card checkout lists to use CreateBox + formatListItem for
consistent bordered cards with radio indicators.

Written with Claude Code (claude.ai/code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 20:52:52 +02:00
b35103657d refactor: add formatListItem helper and apply address/card lists 2026-04-27 20:13:25 +02:00
42abf11d04 refactor: extract reusable CreateBox component for bordered card styling
replacing duplicated border/selection logic in
  account_orders.go and cart.go. Also fixes a latent SSH rendering bug
  in cart.go where lipgloss.NewStyle() was used instead of theme.Base().
2026-04-27 15:04:00 +02:00
96c9be61ff updating the about section 2026-04-27 10:54:58 +02:00
80166ae8d0 lets try this instead 2026-04-27 10:48:30 +02:00
6bcf651afd fix: my last change was very wrong, i think i made it in my sleep. this should do the trick though 2026-04-27 10:40:41 +02:00
cbb3e59878 update: update the not-selected address items in th address list 2026-04-27 08:15:10 +02:00
00dd63668b fix: fix address not appending to address list in account page after adding it 2026-04-26 13:16:14 +02:00
bd93989036 fix: all keybinds becoming unreactive after completing an order 2026-04-26 12:50:51 +02:00
4083495704 fix: fixed payment not going through if no address and card were saved 2026-04-26 12:21:16 +02:00
7bebdeebed fix: fixed card and address list having duplicate items, and a checkout flow infinate loop condition 2026-04-26 11:59:24 +02:00
e0ff8f5f76 update: update the FAQ section 2026-04-26 11:12:05 +02:00
011bf3a4ff fix: use theme.Base() for order card border so colors render over SSH
lipgloss.NewStyle() uses the default renderer which can't output colors
over SSH. Switching to m.theme.Base() uses the session renderer, fixing
BorderForeground. Also reverted to NormalBorder since color now works.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 22:39:36 +02:00
d51912917e fix: use DoubleBorder for selected order card
BorderForeground color wasn't rendering differently over SSH. Use
DoubleBorder (╔═╗║╚═╝) for selected vs NormalBorder (┌─┐│└─┘) for
unselected — visually distinct regardless of color support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 22:31:17 +02:00
c8a5f4806d fix: hardcode steel blue border and bold for selected order card
Use explicit #4682B4 with bold to ensure the selected border is
visually distinct on all terminal themes including gruvbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 22:24:31 +02:00
e2dc2854b7 fix: add 1-item scroll buffer and use highlight color for selected border
Scroll triggers when the next item would be off-screen, not when the
current item hits the edge. Selected border now uses highlight color
(steel blue) instead of accent (white) for visibility on all themes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 22:18:43 +02:00
f9885e2d5e fix: use fixed-height order cards and correct viewport height
Simplify order list cards to 2 content lines (matching reference) so
itemHeight is always 4. Remove extra margin/buffer from viewport height
calc since account is a viewport page with no margins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 22:11:49 +02:00
34bb7a7c7f fix: correct scroll height estimates and improve order selection visibility
The scroll-to-item calculations used wrong item heights (7/5 instead of
9/7) and ignored the 5-line title header offset, causing the viewport to
under-scroll. Selected order cards now light up in accent color for
better visibility across terminal themes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 22:00:44 +02:00
79c8238f1b fix: scrolling stopped working in last commit. this change is made by claude so it just might break again 2026-04-25 21:48:33 +02:00
5c38c7e726 update scroll handling 2026-04-25 11:41:20 +02:00
4024cf4b40 fix: fixed the border on order history currently selected item not showing up for some terminal themes 2026-04-25 11:01:45 +02:00
c78f5eb12b refactor: remove putting ssh key in memory at all, just use it for auth
changed the ssh key to just do fingerprinting and not store it at all, and not be able to edit it either in the account page
2026-04-25 10:50:19 +02:00
c8af9cb551 change: change the proceed warning to red to make it easier to catch 2026-04-24 17:02:05 +02:00
d7b172843e resolve splash screen deadlock and auth 500 soft-deleted ssh keys
Auth handler used FirstOrCreate which couldn't see soft-deleted keys
  (GORM filters by deleted_at IS NULL) but also couldn't insert (unique
  index still covers deleted rows), returning 500 on every login after
  a key deletion. Use Unscoped lookup and restore soft-deleted keys.

  Splash screen also got permanently stuck when auth failed because
  splashViewInitCmd was never fired, leaving splash.dataReady false.
2026-04-24 16:51:19 +02:00
d66b41eed3 refactor: refactor the footer handling 2026-04-24 12:13:26 +02:00
6db7748d9f change the BuildAccountView inline sub-views to use 4 different files to make it better 2026-04-24 11:37:42 +02:00
abe11853f6 refactor: add helper functions for subtotal, cart empty, and cart item count 2026-04-24 10:29:15 +02:00
280585c2d0 sdfsdf 2026-04-24 10:17:46 +02:00
8a86965981 update .gitignore 2026-04-24 10:07:03 +02:00
edc032d6d4 bug fixes: various small bug fixes and typos 2026-04-24 10:04:31 +02:00
53c1b5e254 update: removed stale errors
when encountering an error, the error would never be removed, i just reset the error msg in the switchPage function, this will have to be redone later to make error messages dissapear after a while even on the same page
2026-04-24 09:45:32 +02:00
1bc03822b1 security: remove SSH public key from TUI memory
replace SSHPublicKeyStr field on Model with a closure-based authCmd that
fires, the public key lives only in the goroutine stack not on the
long-lived Model struct

Prevents key material exposure in heap dumps if the process crashed
2026-04-24 09:39:26 +02:00
6006f9617d refactor: add dedicated *Switch() methods
added dedicated *Switch() methods (ShopSwitch, CartSwitc, AccountSwitch, ...) for page transitions and remove the catch-all resetPageState(). Each switch owns its pages zero-init, viewport setup, and async data fetching
2026-04-24 09:25:31 +02:00
1c3ebf789c refactor: update the flat model struct with nested structs 2026-04-24 08:57:04 +02:00
bef7f85bda rewrite: update the slice sort to use built-in lib sort 2026-04-19 14:15:14 +02:00
fb5881f26f docs: make README actually sound human
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:40:36 +02:00
7a774ceee7 docs: rewrite README as a project intro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:39:17 +02:00
a624037f65 docs: update README with live URL and hosting info
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:35:29 +02:00
d111dafd62 chore: untrack terminal-shop-source reference repo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:33:12 +02:00
cb048bc3dc chore: untrack binaries and update README
Remove compiled binaries (main, terminalShop, api/main, api/api-server)
and server.go.old from git tracking. Add them to .gitignore. Update
README to reflect current project state accurately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:32:21 +02:00
22ab7438e1 yay 2026-04-17 11:28:52 +02:00
4403138f74 just some updates to gitignore 2026-04-17 11:26:39 +02:00
2d86e1d23f chore: bump version to trigger CI 2026-04-16 18:00:38 +02:00
b92132474b various error checks and formatting 2026-04-14 15:26:36 +02:00
8157789e66 update: some updates to the deploy action 2026-04-13 14:48:20 +02:00
7a8f7e9c2a update: add some config to the deploy action 2026-04-13 14:39:12 +02:00
44a9de0209 ci: add github actions deploy 2026-04-13 14:29:51 +02:00
7882667e35 update: commented out unused country codes 2026-04-13 14:08:47 +02:00
a22ba382c2 feat: add ssh key management to account page 2026-04-13 13:33:19 +02:00
7cbefeae40 fix: some fixes 2026-04-12 12:35:05 +02:00
e11e708095 fix: shit was NOT working from last commit 2026-04-12 12:22:06 +02:00
6559c4dd8f fix and feature: made checkout page cleaner, and added card and address modification to account page
the checkout flow was a bit wonky because it would display the same info twice which looked weird and confusing. this is now changed to only show once and update one of the pages independantly.

Now added the possibility to modify card and address list in the account page and not in the checkout flow
2026-04-12 12:11:04 +02:00
157a752540 fix: added user verification to continue checkout after adding card
when the user added their card it would instantly complete the order. this could be unwanted so now i have added an extra input requirement to confirm the payment after adding the card
2026-04-11 20:03:17 +02:00
de9120d1eb fix: Caddyfile domain and Stripe webhook API version mismatch
- Update Caddyfile from placeholder api.yourdomain.com to api.sshops.uk
- Use ConstructEventWithOptions with IgnoreAPIVersionMismatch to handle
  Stripe account API version (2026-01-28.clover) vs stripe-go v78 (2024-04-10)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:10:06 +02:00
77a044e11d bruh 2026-04-11 11:37:11 +02:00
40dccc8139 something 2026-04-11 11:10:37 +02:00
ac04676abb adding catppuccin theme 2026-04-11 11:05:16 +02:00
53e443e93d updating 2026-04-09 19:31:55 +02:00
6dba7c6dc6 fix: output api binary to /tmp to avoid conflict with api/ directory 2026-04-09 19:15:58 +02:00
c12b0dcbc2 update 2026-04-09 19:11:39 +02:00
110 changed files with 7098 additions and 5025 deletions

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: CI
on:
pull_request:
branches: [master]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Format check
run: |
unformatted=$(gofmt -l $(go list -f '{{.Dir}}' ./...))
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.4.0
- name: Test (race detector)
run: go test -race ./...

27
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Deploy
on:
push:
branches: [master]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run tests
run: go test ./...
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
cd ~/terminalShop
git pull
docker compose -f docker-compose.prod.yml up -d --build

38
.gitignore vendored
View File

@@ -3,6 +3,15 @@
.env.local
.env.production
.env.example
.env.prod
docs/sca-psd2-compliance.md
accept-a-payment/
FULFILLMENT.md
.tmp-gocache/
.junie/
# Build artifacts
*.exe
@@ -11,8 +20,13 @@
*.so
*.dylib
terminalshop
terminalShop
server_bin
terminalshop_*
server.go.old
main
api/main
api/api-server
# Go test/coverage files
*.test
@@ -32,6 +46,8 @@ vendor/
.DS_Store
.claude/
.understand-anything/
# ssh config
.ssh/
@@ -58,7 +74,9 @@ temp/
# OS-specific
Thumbs.db
.DS_Store
CONTRIBUTING.md
# random stuff like ai and shit
@@ -67,18 +85,32 @@ AGENTS.md
MODELS.md
REFACTORING.md
TODO.md
NEXT.md
test_stripe.go
REFACTOR_PLAN.md
codebase_graph.html
# Don't commit the cloned reference repo (optional - you might want to keep it)
# terminal-shop-source/
terminal-shop-source/
# API documentation (auto-generated)
api/docs/
docs/
# Migration state (if using a migration tool that creates state files)
migrations/*.state
gh-dash/
loadtest/run.pid
loadtest/run.err
/00
ROADMAP.md
*.bak
terminalshop.prod.*

16
.golangci.yml Normal file
View File

@@ -0,0 +1,16 @@
version: "2"
run:
timeout: 5m
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
exclusions:
paths:
- terminal-shop-source

153
AGENTS.md
View File

@@ -1,153 +0,0 @@
# AGENTS.md
Guidance for AI coding agents working in this repository.
## Project Overview
Terminal Coffee Shop is a terminal-based e-commerce app for ordering coffee, built with Go and the Bubbletea TUI framework. It runs as an SSH server (Wish framework) with a REST API backend (Chi router), SQLite database (GORM), and JWT authentication.
## Build & Run Commands
```bash
# Build and run SSH server (TUI)
go build -o terminalshop && ./terminalshop
# Connect: ssh localhost -p 23457
# Quick run without building
go run main.go
# Run API server (separate process, port 8080)
go run main.go # from api/ directory
```
## Test Commands
```bash
# Run all tests
go test ./...
# Run a single test by name
go test ./api/handlers/ -run TestGetHealth
# Run tests in a specific package
go test ./pkg/database/
# Run tests with verbose output
go test -v ./...
# Run tests with coverage
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
```
## Project Structure
- `main.go` -- SSH server entry point (Wish framework)
- `api/` -- REST API server: `handlers/`, `middleware/`, `routes/`
- `pkg/api/` -- API client used by TUI
- `pkg/auth/` -- JWT + SSH key authentication
- `pkg/config/` -- Environment configuration
- `pkg/database/` -- SQLite connection, migrations, seeding
- `pkg/models/` -- GORM data models (Coffee, User, CartItem)
- `pkg/tui/` -- Bubbletea TUI components
- `pkg/utils/` -- Standard API response helpers
- `pkg/validate/` -- Generic validators; `pkg/validation/` -- request validation
- `terminal-shop-source/` -- Reference implementation (read-only, do not modify)
## Code Style
### Imports
Three groups separated by blank lines: (1) stdlib, (2) external packages, (3) internal packages.
```go
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
```
### Naming Conventions
- **Types/exports**: `PascalCase` (`ProductHandler`, `JWTManager`)
- **Local variables**: `camelCase`
- **Constructors**: `New<Type>` (`NewModel`, `NewJWTManager`, `NewClient`)
- **Files**: `snake_case.go` (`ssh_auth.go`, `shipping_form.go`)
- **JSON tags**: `snake_case` (`json:"roast_type"`, `json:"created_at"`)
- **Error codes**: `PascalCase` constants (`ErrCodeValidation`)
### Error Handling
- Messages are lowercase, no trailing punctuation
- Wrap errors with `%w`: `fmt.Errorf("failed to create user: %w", err)`
- Use guard clauses with early returns in handlers
- API errors use: `utils.RespondError(w, statusCode, "ERROR_CODE", "message", details)`
- API success uses: `utils.RespondSuccess(w, statusCode, data)`
### Comments
- Doc comments start with the name of the thing: `// GenerateToken generates a new JWT token for a user`
- Complete sentences with proper punctuation
- Every exported type and function should have a doc comment
### Struct Tags
Always include `json` tags. Use GORM tags for database models.
```go
type Coffee struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
RoastType string `gorm:"size:50;not null" json:"roast_type"`
Price float64 `gorm:"not null" json:"price"`
}
```
### Function Ordering in Files
1. Exported types and constants
2. Constructors (`New...`)
3. Public methods (alphabetically)
4. Private methods
5. Helpers
### Context
Always pass `context.Context` as the first parameter in functions that need it.
## Testing Patterns
- Use stdlib `testing` package with `t.Errorf`/`t.Fatalf` (no testify)
- HTTP tests use `httptest.NewRequest` and `httptest.NewRecorder`
- Test DB setup: create temp SQLite file, connect, migrate, seed, defer cleanup
- Always `defer os.Remove(testDB)` and `defer database.ResetForTesting()`
- Test functions named `Test<FunctionName>` or `Test<FunctionName><Scenario>`
## TUI Architecture (Bubbletea / Elm Architecture)
- All state lives in `Model` struct in `pkg/tui/model.go`
- Views follow **left-panel + right-panel** layout with `lipgloss.JoinHorizontal()`
- Right panel auto-updates based on cursor position (no Enter/ESC to view details)
- Left panel width is 18 chars; right panel fills remaining space
- Each view has a builder: `BuildShopView()`, `BuildCartView()`, `BuildAccountView()`
- Styling: active=#FFFFFF bold, inactive=#666666, accent=#4682B4
- Cart uses `map[int]*CartItem` -- always sort via `GetCartItemsSlice()` before display
- Reset `ScrollOffset = 0` when switching views
## Commit Messages
Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`.
## Important Notes
- The `terminal-shop-source/` directory is a read-only reference -- do not modify it
- Environment config goes in `.env` (copy from `.env.example`); never commit `.env`
- Read CLAUDE.md for detailed architecture decisions and common gotchas
- Read MODELS.md for complete API specs, database schema, and design patterns

202
CLAUDE.md
View File

@@ -1,202 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Terminal Coffee Shop is a terminal-based e-commerce application for ordering coffee, built with Go and the Bubbletea TUI framework. It's designed to eventually run as an SSH server similar to terminal.shop, with a backend API, PostgreSQL database, and Stripe payment integration.
**Current Status**: Phase 1 (Enhanced UI & Navigation) - The TUI is functional with shop, cart, and account views. Backend API has not been implemented yet.
## Essential Reading
Before making any changes, read these files in order:
1. **README.md** - Complete roadmap and current phase status
2. **MODELS.md** - Technical specifications, architecture, code standards, error handling patterns
3. **CONTRIBUTING.md** - Workflow guidance for AI-assisted development
## Architecture
### Current Structure (TUI-only)
```
terminalShop/
├── main.go # SSH server entry point (Wish framework)
├── pkg/
│ ├── tui/ # Bubbletea TUI components
│ │ ├── model.go # Main model with state management
│ │ ├── views.go # View rendering and layout
│ │ ├── shop.go # Shop view (left: coffee list, right: details)
│ │ ├── cart.go # Cart view with scrolling
│ │ ├── account.go # Account view (auto-updating right panel)
│ │ ├── header.go # Tab navigation header
│ │ ├── footer.go # Keybind hints
│ │ └── breadcrumbs.go # Checkout flow breadcrumbs (cart only)
│ └── models/
│ └── coffee.go # Coffee and CartItem models
└── .ssh/ # SSH host keys
```
### Planned Structure (Backend)
The `api/` directory structure from MODELS.md will be created in Phase 2 with:
- Chi router for HTTP endpoints
- GORM models for PostgreSQL
- JWT authentication middleware
- Stripe payment integration
## Key Architectural Patterns
### TUI State Management (Bubbletea/Elm Architecture)
The `Model` struct in `pkg/tui/model.go` contains all application state:
- Navigation state: `ViewingCart`, `ViewingAccount`, `CheckoutStep`
- Cursor positions: `Cursor` (shop), `CartCursor` (cart), `AccountCursor` (account)
- Data: `Coffees` (product list), `Cart` (map[int]*CartItem)
- Scroll: `ScrollOffset` for handling overflow
**Critical Pattern**: All views (shop, cart, account) follow a **left-panel + right-panel layout**:
- Left panel: List of items with cursor navigation (j/k)
- Right panel: Details that auto-update based on cursor position
- No Enter/ESC navigation - hovering updates the right panel immediately
### View Rendering Flow
1. `views.go:View()` - Main entry point, handles vertical centering and scrolling
2. Calls view builders: `BuildShopView()`, `BuildCartView()`, `BuildAccountView()`
3. Each view uses `lipgloss.JoinHorizontal()` for left+right layout
4. Fixed content height calculated dynamically to prevent UI shifting
### Cart Management
**Important**: Cart uses `map[int]*models.CartItem` where the key is the coffee index in `m.Coffees`. This enables fast lookups but requires sorting in `GetCartItemsSlice()` because Go maps have randomized iteration order.
### Scrolling System
Implemented for cart view to handle long lists on small terminals:
- `ScrollOffset` tracks current scroll position (line-based, not item-based)
- Each cart item is ~5 lines tall (including borders)
- Auto-scrolls when navigating with j/k to keep selected item visible
- Manual scroll with pgup/pgdn
## Build and Run Commands
```bash
# Build and run SSH server
go build -o terminalshop && ./terminalshop
# Server listens on localhost:23457
# Connect with: ssh localhost -p 23457
# Quick run without building
go run main.go
# Add dependencies
go get <package>
go mod tidy
```
## Development Workflow
### Adding a New View Component
1. Create new file in `pkg/tui/` (e.g., `shipping.go`)
2. Add build function following the pattern: `func (m Model) BuildShippingView() string`
3. Use left-panel + right-panel layout with `lipgloss.JoinHorizontal()`
4. Update `views.go:View()` to conditionally render the new view
5. Add navigation state to `Model` struct in `model.go`
6. Add keybinds in `model.go:Update()`
7. Update `footer.go` with new keybind hints
### Modifying Existing Views
The shop, cart, and account views are split across multiple files but all follow the same pattern:
- Use `lipgloss.NewStyle()` for styling
- Fixed widths for panels (leftWidth typically 18, rightWidth calculated)
- Cursor-based selection with colored highlights
- Responsive to `WindowWidth` and `WindowHeight`
### Working with the Model
When adding state to the Model struct:
1. Add field to struct definition in `model.go`
2. Initialize in `NewModel()` constructor
3. Reset appropriately when changing views (see `case "s"`, `case "c"`, `case "a"`)
4. Update in `Update()` function based on user input
## Important Implementation Details
### Styling Conventions
- Active/selected: White (#FFFFFF), bold
- Inactive: Gray (#666666)
- Accent color: Steel blue (#4682B4) - used for cart breadcrumbs and highlights
- Coffee colors: Use individual coffee's `.Color` field (hex values like "#8B4513")
### Window Size Handling
`views.go` automatically adjusts content height based on terminal size:
- Calculates available space after header, breadcrumbs, footer, margins
- Minimum content height of 3 lines
- Margins scale down on small windows (< 25 lines height)
- Truncates content and enables scrolling when needed
### Breadcrumbs
Only visible in cart view, showing checkout flow: `cart / shipping / payment / confirmation`
- Current step (tracked in `CheckoutStep`) is white/bold
- Future steps are gray
- Will be used when implementing checkout pages in later phases
### SSH Server
`main.go` uses Wish (Charm's SSH framework) to serve the TUI over SSH:
- Host key in `.ssh/term_info_ed25519`
- Port 23457
- Each SSH session gets its own Bubbletea program instance
- Username passed to `tui.NewModel(username)`
## Common Gotchas
1. **Map iteration order**: Always sort cart items in `GetCartItemsSlice()` to prevent random reordering
2. **Scroll offset**: When adding scrolling to new views, update viewport height calculation (currently hardcoded as `-9` for cart with breadcrumbs)
3. **Content height**: Fixed height prevents UI shifting between pages but must account for all UI elements (header, breadcrumbs, footer, margins)
4. **Panel widths**: Left panel is 18 chars, right panel is `WindowWidth - leftWidth - leftMargin - 4`
5. **Reset scroll**: Always reset `ScrollOffset = 0` when switching views
## Code Style (from MODELS.md)
- Imports: stdlib, external deps, internal packages (separated by blank lines)
- Error messages: lowercase, no punctuation, use `%w` for wrapping
- Comments: Start with function/type name, complete sentences
- Struct tags: Always include `json` tags with snake_case
- Context: Always first parameter in functions
## Reference Implementation
`terminal-shop-source/` contains the full terminal.shop codebase (TypeScript/Go hybrid):
- `packages/go/pkg/tui/*.go` - TUI patterns and components
- `packages/go/cmd/ssh/main.go` - SSH server setup
- Study patterns, don't copy verbatim (different tech stack)
## Next Steps (from Roadmap)
Current phase is wrapping up. Next major phase:
- **Phase 2**: Backend API Foundation with Chi router, PostgreSQL, GORM models
- See MODELS.md for complete API specification and database schema
- See README.md Phase 2 tasks for implementation order
## Environment Setup
Copy `.env.example` to `.env` and configure:
- `DATABASE_URL` - PostgreSQL connection (for Phase 2)
- `JWT_SECRET` - Auth token secret (for Phase 3)
- `STRIPE_SECRET_KEY` / `STRIPE_PUBLIC_KEY` - Payment integration (for Phase 5)
- `SSH_PORT` - Default 2222 (currently using 23457)
## Testing
Currently no tests. Testing standards defined in MODELS.md for future API implementation:
- Unit tests for business logic
- Integration tests for API endpoints
- Use testify for assertions
- Table-driven test pattern

View File

@@ -1,265 +0,0 @@
# Contributing with Claude Code
This project is designed to be built incrementally with AI assistance (Claude Code). This guide explains how to effectively collaborate with Claude Code on this project.
## Quick Start for Claude Code Sessions
When starting a new Claude Code session, provide this context:
```
I'm working on a terminal-based coffee shop app in Go. Please read README.md and MODELS.md to understand the project structure, current status, and technical standards before making any changes.
```
## Project Documents - Read These First!
1. **README.md** - Project overview, roadmap, current status, and what to build next
2. **MODELS.md** - Technical specs, architecture, code standards, error handling
3. **terminal-shop-source/** - Reference implementation of terminal.shop (when stuck)
## How to Ask Claude Code for Help
### ✅ Good Prompts
**When starting a new feature:**
```
I want to implement Phase 1, Task 1.1 from the README - adding the account page view.
Can you check the current model.go structure and add the account view following the
patterns already established for shop and cart views?
```
**When stuck on something:**
```
The cart isn't syncing properly between views. Can you check the cart state management
in model.go and compare it to how terminal.shop handles this in their cart.go file?
```
**When you want to follow best practices:**
```
I need to create the API endpoint for adding items to cart. Can you implement this
following the error handling patterns and response format defined in MODELS.md?
```
### ❌ Avoid Vague Prompts
```
"Make the cart better" // Too vague - better what? Performance? UI? Features?
"Add payment stuff" // Be specific - which phase? What functionality exactly?
"Fix the bug" // What bug? Where? What's the expected behavior?
```
## Workflow for Building Features
### Step 1: Identify the Task
Look at README.md and find the current phase and next unchecked task:
```
## **PHASE 1: Enhanced UI & Navigation** (Current Phase)
### Tasks
#### 1.1 Add Account Page View
- [ ] Add `viewingAccount bool` to model struct in `model.go`
```
### Step 2: Reference the Specs
Check MODELS.md for:
- Code style rules
- Error handling patterns
- Similar examples
- Architecture decisions
### Step 3: Ask Claude Code
```
I want to implement [specific task from README]. I've checked MODELS.md for
the code standards. Can you implement this following the established patterns?
```
### Step 4: Test and Verify
```
Run the app and test the new feature. If issues:
"The account view isn't showing up when I press 'a'. Can you debug this?"
```
### Step 5: Update Documentation
```
The account view is working! Can you check off the completed tasks in README.md?
```
## Common Scenarios
### Starting a New Feature
1. Read the next task in README.md
2. Check if there are related patterns in MODELS.md
3. Look at terminal-shop-source for reference
4. Ask Claude Code to implement following the patterns
**Example:**
```
I'm ready to start Phase 2 - Backend API Foundation. Let's begin with task 2.1
"Project Setup". Can you create the directory structure and initialize the Chi
router following the organization defined in MODELS.md?
```
### Debugging Issues
Always provide context:
- What you were trying to do
- What you expected
- What actually happened
- Any error messages
**Example:**
```
When I add items to the cart and switch to the cart view, the items aren't showing.
I expected to see the cart items listed. The console shows no errors. Can you check
the cart state management in model.go?
```
### Refactoring Code
Reference the style guide:
```
The buildShopView function is getting long (300+ lines). Can you refactor it following
the code organization principles in MODELS.md? Split it into smaller helper functions.
```
### Adding API Endpoints
Be specific about the endpoint:
```
I need to add the POST /api/v1/cart/items endpoint from the API specs in MODELS.md.
Can you implement this with proper error handling and validation?
```
## Using the Reference Implementation
The `terminal-shop-source/` directory contains terminal.shop's code. Use it when:
### When to Reference
1. **You need UI inspiration** - Look at their TUI components
2. **You're stuck on Bubbletea patterns** - See how they handle state
3. **You want to understand payment flow** - Check their payment.go
4. **You need form examples** - Look at how they use `huh`
### How to Ask Claude Code to Reference It
```
Can you look at how terminal.shop implements the payment form in
terminal-shop-source/packages/go/pkg/tui/payment.go and create a similar
pattern for our checkout flow?
```
```
I want to add breadcrumb navigation like terminal.shop has. Can you check their
breadcrumbs.go implementation and adapt it to our project structure?
```
## Best Practices for AI-Assisted Development
### 1. Be Incremental
Build one small feature at a time. Don't try to implement entire phases at once.
✅ "Let's add the account view to the TUI"
❌ "Implement the entire authentication system"
### 2. Verify After Each Change
Run and test the code after each feature:
```bash
go run server.go model.go
```
### 3. Keep Context Clear
Start sessions with context, reference documents frequently:
```
Working on the cart API endpoints. I've checked MODELS.md for the API spec and
error handling patterns. Ready to implement POST /api/v1/cart/items.
```
### 4. Follow the Roadmap
Stick to the phases in README.md. Don't skip ahead or work on random features.
### 5. Update Documentation
When tasks are completed, update the checkboxes in README.md:
```
Can you mark task 1.1 "Add Account Page View" as complete in README.md?
```
## Troubleshooting Common Issues
### "Claude Code made changes that don't match the style guide"
```
I noticed the error messages aren't following the format in MODELS.md (they start
with capitals). Can you fix them to match our error handling standards?
```
### "The code doesn't follow the project structure"
```
The new handler was added to main.go but according to MODELS.md it should be in
api/handlers/. Can you reorganize this to match our structure?
```
### "I want to try a different approach"
```
Instead of using the pattern from MODELS.md, I want to try [your approach].
Can you implement it this way and we'll compare?
```
## Git Workflow
### Before Committing
- [ ] Code follows MODELS.md standards
- [ ] Tests pass (when applicable)
- [ ] Documentation updated (README checkboxes)
- [ ] No sensitive data (check .gitignore)
### Commit Messages
Use conventional commits:
```bash
feat: add account page view to TUI
fix: cart items not displaying correctly
refactor: split buildShopView into helper functions
docs: update README with completed tasks
```
## Tips for Effective Sessions
1. **Start each session by providing context** about what you're working on
2. **Reference the docs** (README.md, MODELS.md) frequently
3. **Ask Claude Code to check the reference implementation** when uncertain
4. **Build incrementally** - one feature at a time
5. **Test frequently** - run the app after each change
6. **Update the README** - check off completed tasks
7. **Follow the roadmap** - stick to the phases in order
## Example Full Session Flow
```
Session start:
"I'm working on Terminal Coffee Shop. Last session we completed the cart view.
Today I want to start Phase 1, Task 1.1 - adding the account page. Can you read
README.md to see what needs to be done?"
Implementation:
"Great! Now implement the account view following the same pattern as buildShopView
and buildCartView in model.go. Add the viewingAccount bool and the keybind handling."
Testing:
"I ran it but pressing 'a' doesn't switch to account view. Can you debug?"
Completion:
"Perfect! It's working now. Can you update README.md to check off all the tasks
we completed for 1.1?"
```
## Resources
- [Bubbletea Tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials)
- [Lipgloss Examples](https://github.com/charmbracelet/lipgloss/tree/master/examples)
- [Chi Router Guide](https://go-chi.io/#/pages/getting_started)
- [GORM Documentation](https://gorm.io/docs/)
- [Stripe Go SDK](https://github.com/stripe/stripe-go)
---
**Remember**: The goal is to build this incrementally and learn along the way. Don't rush through phases. Take time to understand each pattern before moving on!

View File

@@ -1,3 +1,3 @@
api.yourdomain.com {
api.sshops.uk {
reverse_proxy api:8080
}

View File

@@ -4,10 +4,10 @@ RUN apk add --no-cache gcc musl-dev
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o api ./api/main.go
RUN CGO_ENABLED=1 GOOS=linux go build -o /tmp/api ./api/main.go
FROM alpine:3.21
RUN apk add --no-cache libc6-compat
WORKDIR /root/
COPY --from=builder /app/api .
COPY --from=builder /tmp/api ./api
CMD ["./api"]

1214
MODELS.md

File diff suppressed because it is too large Load Diff

113
README.md
View File

@@ -1,62 +1,73 @@
# Terminal Coffee Shop
A terminal-based coffee shop, accessible over SSH. Built with Go, Bubbletea, Wish, Chi, PostgreSQL, Stripe, and Shippo.
```bash
ssh localhost -p 23456
ssh sshops.uk
```
## Running locally
## 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](https://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](https://github.com/charmbracelet/bubbletea) for the terminal UI, [Wish](https://github.com/charmbracelet/wish) for the ssh server. every ssh session spawns its own bubbletea program. lipgloss for styling.
**API.** Go again, [Chi](https://github.com/go-chi/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](https://stripe.com). 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](https://goshippo.com) for US addresses, [Bring](https://www.bring.com) 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](https://caddyserver.com) 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](https://github.com/emmett1/scratchpkg). no docker, no apt, every binary built from a recipe i wrote. it also runs my self hosted [Gitea](https://gitea.com). 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](https://restic.net), encrypted and deduped. dual dead man switches, [healthchecks.io](https://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](https://github.com/riandyrn/otelchi) on the inbound side and [otelhttp](https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/net/http/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
```bash
# Copy and fill in env vars
cp .env.example .env
# Start the API server
go run api/main.go
# Start the SSH server
go run main.go
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
```
## Stack
uses SQLite by default so there's nothing to spin up.
| Layer | Tech |
|-------|------|
| TUI | Bubbletea + Wish (SSH) |
| HTTP API | Chi router |
| Database | PostgreSQL + GORM |
| Auth | SSH key fingerprint + JWT |
| Payments | Stripe |
| Shipping | Shippo |
## inspiration
## What's working
- SSH server — key-based auth, auto user creation
- Shop page — browse products with live detail panel
- Cart — add/remove items, synced to API
- Checkout flow — shipping address → payment → confirmation
- Saved addresses and cards
- Order history in account view
- FAQ page
- Responsive layout (small/medium/large terminal sizes)
- Token refresh
## TODO
**Features missing vs reference (terminal.shop):**
- [ ] Splash/loading screen on connect
- [ ] Animated logo with blinking cursor
- [ ] API token management page
- [ ] OAuth app management page
- [ ] About page
- [ ] Theme system — currently 40+ hardcoded hex colors scattered across files
- [ ] Viewport-based scrolling — currently manual scroll math with hardcoded heights
**Known bugs:**
- [ ] Address delete slice bug in `shipping_form.go:386` — `[m.AddressCursor+1:]...` missing
- [ ] Account page has a placeholder "something" menu item in `models/coffee.go`
**Code quality:**
- [ ] Nested state structs (reference uses `state.shop`, `state.cart`, etc. vs flat Model)
- [ ] Centralized styles file
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.

View File

@@ -1,141 +0,0 @@
# Refactoring Complete!
The codebase has been refactored from a single `model.go` file (~650 lines) into a clean, modular structure.
## New Structure
```
terminalShop/
├── main.go # Entry point (~20 lines)
├── pkg/
│ ├── models/
│ │ └── coffee.go # Data models (~25 lines)
│ └── tui/
│ ├── model.go # Core model & state (~160 lines)
│ ├── views.go # Main View() function (~60 lines)
│ ├── shop.go # Shop view (~90 lines)
│ ├── cart.go # Cart view (~90 lines)
│ ├── account.go # Account view (~75 lines)
│ ├── header.go # Header rendering (~95 lines)
│ └── footer.go # Footer rendering (~75 lines)
├── model.go.old # Backup of old file
└── server.go.old # Backup of old file
```
## What Changed
### Before
- **1 file**: `model.go` with 650+ lines
- All code mixed together
- Hard to navigate and maintain
- Difficult to find specific features
### After
- **8 focused files** averaging ~70 lines each
- Clear separation of concerns
- Easy to find and modify features
- Follows Go best practices
## File Responsibilities
| File | Purpose | Key Functions |
|------|---------|---------------|
| `main.go` | App entry point | `main()` |
| `pkg/models/coffee.go` | Data structures | `Coffee`, `CartItem`, `AccountMenuItems` |
| `pkg/tui/model.go` | State management | `Model`, `Init()`, `Update()`, `NewModel()` |
| `pkg/tui/views.go` | Layout assembly | `View()` |
| `pkg/tui/shop.go` | Shop view | `BuildShopView()` |
| `pkg/tui/cart.go` | Cart view | `BuildCartView()` |
| `pkg/tui/account.go` | Account views | `BuildAccountView()`, `buildAccountSubpage()` |
| `pkg/tui/header.go` | Header/tabs | `BuildHeader()` |
| `pkg/tui/footer.go` | Footer/keybinds | `BuildFooter()` |
## Benefits
1. **Easier to Navigate**: Find shop code in `shop.go`, cart code in `cart.go`, etc.
2. **Easier to Test**: Each component can be tested independently
3. **Easier to Extend**: Add new views by creating new files
4. **Better Organization**: Related code stays together
5. **Cleaner Git Diffs**: Changes to cart don't affect shop code
6. **Follows Standards**: Matches Go project conventions
## Running the App
Same as before:
```bash
# Run the app
go run main.go
# Or build and run
go build -o terminalshop main.go
./terminalshop
```
## Adding New Features
### Adding a New View (e.g., "Settings")
1. Create `pkg/tui/settings.go`:
```go
package tui
func (m Model) BuildSettingsView() string {
// Your settings view code
}
```
2. Update `model.go`:
- Add `viewingSettings bool` to Model struct
- Add keybind case in Update()
3. Update `views.go`:
- Add condition for settings view
4. Update `header.go` and `footer.go`:
- Add settings tab/keybinds
### Adding New Data Models
Add to `pkg/models/` directory:
```go
// pkg/models/order.go
package models
type Order struct {
ID string
Items []CartItem
Total float64
}
```
## Migration Notes
- Old files backed up as `.old`
- All functionality preserved
- No behavior changes
- Imports use `terminalShop/pkg/...`
## Next Steps
Now that the code is organized, you can easily:
- Add new views (checkout, payment, orders)
- Implement API client in `pkg/api/`
- Add database models in `pkg/models/`
- Create tests for each component
- Continue with Phase 2 of the roadmap (Backend API)
## Rollback
If needed, restore the old structure:
```bash
mv model.go.old model.go
mv server.go.old server.go
rm -rf pkg/
# Edit main.go to use InitialModel again
```
---
**Refactored on**: 2026-02-12
**Lines of code**: Same functionality, better organized!

80
TODO.md
View File

@@ -1,80 +0,0 @@
# TODO
## Done
- [x] Cart persistence across sessions
- [x] Token refresh — auto-refresh JWT before expiry
- [x] Help screen — press ? to show keybindings
- [x] Order detail view — press Enter on an order to see full details
- [x] FAQ page
- [x] About page
- [x] Splash screen — logo + auth + data preload on connect
- [x] Bulk data preload — /view/init endpoint fetches all user data at once
- [x] Stripe webhook — payment_intent.succeeded/failed sync order status
- [x] PCI-safe card tokenization — publishable key in SSH process, raw card data never reaches backend
- [x] Rate limiting — per-IP and per-user limits on sensitive endpoints
- [x] Audit logging — structured JSON logs for all financial events
- [x] Shop page — dual viewport (menu left, detail right), matches reference shop.go
- [x] Cart page — single viewport with proper scroll
- [x] Shipping page — single viewport
- [x] Payment page — single viewport
- [x] Confirm page — single viewport (uses DefaultKeyMap for pgup/pgdn scrolling)
---
## Do First (meaningful gaps vs reference)
### Product variants
The reference supports multiple variants per product (roast type, grind, size) with
separate prices. We treat every product as single-variant. Without this the shop is
very limited once you have real products.
- [ ] Variant model + DB migration
- [ ] API: return variants in /products response
- [ ] TUI: show variant selector in shop detail panel
### User profile editing
Users currently cannot update their name or email from inside the app.
- [ ] PUT /api/v1/profile endpoint
- [ ] TUI: editable name/email fields in account view
### Missing API endpoints
- [ ] PUT /api/v1/addresses/:id — update a saved address
- [ ] GET /api/v1/orders/:id — single order (needed for better order detail view)
---
## Polish (noticeable but not blocking)
- [ ] Animated cursor — reference has a blinking block cursor on the splash/logo
(cursor.go, CursorTickMsg, 700ms interval). Small but gives the app personality.
- [ ] Themed UI — reference has a theme/ package; we use hardcoded hex colors everywhere.
Makes it hard to change the look later.
- [ ] Shipping timeframe — Shippo returns estimated delivery window; show it on confirm page
- [ ] Loading spinner — replace plain "loading..." text with animated spinner
- [ ] HTTPS/QR payment option — third card-entry method, generates a one-time URL + QR code
so users can enter card details in their browser instead of over SSH.
Reference: payment.go paymentHttpsView, PollPaymentInitMsg/StatusMsg/CompleteMsg.
- [ ] Per-field card validation — use huh .Validate() on each form field so errors
show inline as user tabs through, instead of a single error on submit.
---
## Testing
- [ ] API client tests — pkg/api/client.go has no tests
- [ ] Card handler tests — api/handlers/cards.go
- [ ] Order handler tests — api/handlers/orders.go
- [ ] Address handler tests — api/handlers/addresses.go (partial)
- [ ] Webhook handler tests — api/handlers/webhook.go
- [ ] TUI payment form tests — validatePaymentForm() is pure and easy to unit test
---
## Post-Launch
- [ ] Shippo webhook — order tracking status updates pushed from Shippo
- [ ] Email system — order confirmation + shipped notification
- [ ] Subscription model — DB table, CRUD endpoints, TUI flow (select → shipping → payment → confirm)
- [ ] Subscription management — list, detail, cancel with confirmation in account view
- [ ] Personal access tokens — model, CRUD API, TUI management in account view
- [ ] OAuth app management — model, CRUD API, TUI create/list/delete
- [ ] Region-based product filtering — IP geolocation, press 'r' to toggle
- [ ] Cron jobs — subscription processing, inventory tracking, unshipped alerts

Binary file not shown.

View File

@@ -2,7 +2,7 @@ package handlers
import (
"encoding/json"
"log"
"log/slog"
"net/http"
"strconv"
@@ -16,6 +16,8 @@ import (
"terminalShop/pkg/utils"
)
func addressLog() *slog.Logger { return slog.With("component", "address") }
type AddressHandler struct {
shippoKey string
bringAPIUID string
@@ -33,7 +35,7 @@ func NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey string) *AddressHa
// Retrieve all saved addresses for a user
func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
@@ -46,7 +48,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
}
func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
@@ -76,11 +78,11 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
switch req.Country {
case "US":
if h.shippoKey == "" {
utils.RespondError(w, http.StatusBadRequest, "CONFIG_ERROR", "shippo not configured for US address validation", nil)
utils.RespondError(w, http.StatusInternalServerError, "CONFIG_ERROR", "shippo not configured for US address validation", nil)
return
}
client := shippo.NewClient(h.shippoKey)
validated, err := client.ValidateAddress(shippo.Address{
validated, err := client.ValidateAddress(r.Context(), shippo.Address{
Name: req.Name,
Street1: req.Street,
Street2: req.Street2,
@@ -91,7 +93,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
Phone: req.Phone,
})
if err != nil {
log.Printf("shippo address validation failed: %v", err)
addressLog().Warn("shippo address validation failed", "error", err, "country", "US")
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
return
}
@@ -112,7 +114,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
return
}
client := bring.NewClient(h.bringAPIUID, h.bringAPIKey)
validated, err := client.ValidateAddress(bring.Address{
validated, err := client.ValidateAddress(r.Context(), bring.Address{
Name: req.Name,
Street1: req.Street,
Street2: req.Street2,
@@ -122,7 +124,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
Phone: req.Phone,
})
if err != nil {
log.Printf("bring address validation failed: %v", err)
addressLog().Warn("bring address validation failed", "error", err, "country", "NO")
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
return
}
@@ -141,6 +143,12 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
return
}
// remove existing addres with the same street addres
var existing models.Address
if err := db.Where("user_id = ? AND street = ?", userID, address.Street).First(&existing).Error; err == nil {
db.Delete(&existing)
}
if err := db.Create(&address).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create address", nil)
return
@@ -153,7 +161,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
// DeleteAddress deltes a saved address
func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
@@ -166,12 +174,14 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
var address models.Address
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&address).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ADDRESS NOT FOUND", "address not found", nil)
utils.RespondError(w, http.StatusNotFound, "ADDRESS_NOT_FOUND", "address not found", nil)
return
}
// ownership is guaranteed by the WHERE clause above
db.Delete(&address)
if err := db.Delete(&address).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete address", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address successfully deleted",
@@ -181,7 +191,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
// Mark an address as the users default address
func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
@@ -197,10 +207,16 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
return
}
db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false)
if err := db.Model(&models.Address{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update address", nil)
return
}
address.IsDefault = true
db.Save(&address)
if err := db.Save(&address).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save address", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"address": address,

View File

@@ -13,7 +13,7 @@ import (
func setupAddressTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_addresses.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) {
func TestGetAddressesEmpty(t *testing.T) {
testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewAddressHandler("", "", "")
@@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) {
func TestGetAddressesIsolation(t *testing.T) {
testDB, user1 := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) {
Addresses []models.Address `json:"addresses"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Addresses) != 1 {
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses))
@@ -117,7 +117,7 @@ func TestGetAddressesIsolation(t *testing.T) {
func TestGetAddressesWithData(t *testing.T) {
testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -158,4 +158,3 @@ func TestGetAddressesWithData(t *testing.T) {
t.Errorf("expected 2 addresses, got: %d", len(resp.Data.Addresses))
}
}

View File

@@ -2,8 +2,10 @@ package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"terminalShop/api/middleware"
"terminalShop/pkg/auth"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
@@ -12,6 +14,8 @@ import (
"gorm.io/gorm"
)
func authLog() *slog.Logger { return slog.With("component", "auth") }
// AuthHandler handles authentication-related requests
type AuthHandler struct {
jwtManager *auth.JWTManager
@@ -42,22 +46,26 @@ type TokenResponse struct {
}
func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
var req TokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
middleware.RecordAuthAttempt("invalid_json")
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
// validate shared secret
if req.ClientSecret != h.authFingerprintKey {
middleware.RecordAuthAttempt("invalid_secret")
authLog().Warn("invalid client secret", "remote", r.RemoteAddr)
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SECRET", "invalid client secret", nil)
return
}
// Return if no fingerprint is found
if req.Fingerprint == "" {
middleware.RecordAuthAttempt("missing_fingerprint")
utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint is required", nil)
return
}
@@ -67,33 +75,44 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error
if err == gorm.ErrRecordNotFound {
if req.SSHPublicKey == "" {
middleware.RecordAuthAttempt("user_not_found")
authLog().Warn("user not found and no public key for registration", "fingerprint", req.Fingerprint)
utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil)
return
}
user = models.User{
SSHKeyFingerprint: req.Fingerprint,
SSHPublicKey: req.SSHPublicKey,
SSHPublicKey: req.SSHPublicKey,
}
if err := db.Create(&user).Error; err != nil {
middleware.RecordAuthAttempt("error")
authLog().Error("failed to create user", "error", err)
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil)
return
}
middleware.RecordAuthAttempt("registered")
authLog().Info("user registered", "user_id", user.ID)
} else if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "database error", nil)
middleware.RecordAuthAttempt("error")
authLog().Error("failed to look up user", "error", err)
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to look up user", nil)
return
}
token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name)
if err != nil {
middleware.RecordAuthAttempt("error")
authLog().Error("failed to generate token", "user_id", user.ID, "error", err)
utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil)
return
}
middleware.RecordAuthAttempt("success")
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"access_token": token,
"token_type": "Bearer",
"expires_in": 1800, // 30 minutes in seconds
"user": user.ToPublic(),
"user": user.ToPublic(),
})
}
@@ -108,7 +127,7 @@ type RegisterRequest struct {
// RegisterWithSSHKey registers a new user with their SSH public key
// No username required - following terminal.shop pattern
func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -161,7 +180,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
// GetUserBySSHKey retrieves a user by their SSH key fingerprint
func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
fingerprint := r.URL.Query().Get("fingerprint")
if fingerprint == "" {

View File

@@ -22,7 +22,7 @@ const testClientSecret = "test-client-secret"
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
t.Helper()
testDB := "test_auth.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -62,7 +62,7 @@ func createAuthTestUser(t *testing.T, fingerprint string) models.User {
func TestGetTokenSuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
user := createAuthTestUser(t, "SHA256:testfingerprint123")
@@ -108,7 +108,7 @@ func TestGetTokenSuccess(t *testing.T) {
func TestGetTokenBadSecret(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -128,7 +128,7 @@ func TestGetTokenBadSecret(t *testing.T) {
func TestGetTokenMissingFingerprint(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -147,7 +147,7 @@ func TestGetTokenMissingFingerprint(t *testing.T) {
func TestGetTokenUnknownFingerprint(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -167,7 +167,7 @@ func TestGetTokenUnknownFingerprint(t *testing.T) {
func TestGetTokenInvalidJSON(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json"))
@@ -185,7 +185,7 @@ func TestGetTokenInvalidJSON(t *testing.T) {
func TestRegisterWithSSHKeySuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -207,7 +207,7 @@ func TestRegisterWithSSHKeySuccess(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
createAuthTestUser(t, "SHA256:existingfp")
@@ -229,7 +229,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
body, _ := json.Marshal(map[string]string{
@@ -250,7 +250,7 @@ func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
func TestGetUserBySSHKeySuccess(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
createAuthTestUser(t, "SHA256:lookupfp")
@@ -267,7 +267,7 @@ func TestGetUserBySSHKeySuccess(t *testing.T) {
func TestGetUserBySSHKeyNotFound(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil)
@@ -282,7 +282,7 @@ func TestGetUserBySSHKeyNotFound(t *testing.T) {
func TestGetUserBySSHKeyMissingParam(t *testing.T) {
testDB, _, handler := setupAuthHandlerTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)

View File

@@ -7,7 +7,7 @@ import (
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
@@ -24,33 +24,43 @@ import (
stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
)
// payRedirects maps short tokens to full Stripe checkout URLs.
var (
payRedirects = map[string]string{}
payRedirectsMu sync.Mutex
)
const RedirectTTL = 10 * time.Minute
func storeRedirect(stripeURL string) string {
b := make([]byte, 4)
rand.Read(b)
func storeRedirect(stripeURL, purpose string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
payRedirectsMu.Lock()
payRedirects[token] = stripeURL
payRedirectsMu.Unlock()
return token
now := time.Now()
rec := models.PayRedirect{
Token: token,
URL: stripeURL,
Purpose: purpose,
CreatedAt: now,
ExpiresAt: now.Add(RedirectTTL),
}
if err := database.GetDB().Create(&rec).Error; err != nil {
return "", err
}
return token, nil
}
// PayRedirect resolves a short token and redirects to the Stripe checkout URL.
func PayRedirect(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
payRedirectsMu.Lock()
url, ok := payRedirects[token]
payRedirectsMu.Unlock()
if !ok {
db := database.GetDB().WithContext(r.Context())
var rec models.PayRedirect
if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
http.NotFound(w, r)
return
}
http.Redirect(w, r, url, http.StatusFound)
if time.Now().After(rec.ExpiresAt) {
db.Where("token = ?", rec.Token).Delete(&models.PayRedirect{})
http.NotFound(w, r)
return
}
http.Redirect(w, r, rec.URL, http.StatusFound)
}
// CardHandler handles saved payment method CRUD.
@@ -66,7 +76,7 @@ func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
// GetCards returns all saved cards for the authenticated user.
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var cards []models.Card
@@ -81,7 +91,7 @@ func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
// GetCard returns a single saved card by ID.
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
@@ -103,7 +113,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
}
func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
@@ -119,10 +129,16 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return
}
db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false)
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
return
}
card.IsDefault = true
db.Save(&card)
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"card": card,
@@ -130,7 +146,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
}
func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var user models.User
@@ -160,7 +176,11 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return
}
token := storeRedirect(sess.URL)
token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})
@@ -178,7 +198,7 @@ type SaveCardRequest struct {
// fingerprint, attaches to the user's Stripe customer, and saves the
// card metadata locally. No raw card data is stored at any point.
func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var req SaveCardRequest
@@ -242,6 +262,13 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
return
}
// remove existing local card with the same last4 + brand + expiry
var existingCard models.Card
if err := db.Where("user_id = ? AND last4 = ? AND brand = ? AND exp_month = ? AND exp_year = ?",
user.ID, pm.Card.Last4, string(pm.Card.Brand), pm.Card.ExpMonth, pm.Card.ExpYear).First(&existingCard).Error; err == nil {
db.Delete(&existingCard)
}
// Persist only the safe card metadata — no raw card data is stored.
card := models.Card{
UserID: user.ID,
@@ -267,7 +294,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
// DeleteCard removes a saved card. If the card is currently set on the user's
// cart, the cart's card selection is cleared.
func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
@@ -285,7 +312,10 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
// Clear the card from any cart that references it.
cardID := uint(id)
db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil)
if err := db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to clear card from cart", nil)
return
}
// Detach from Stripe (best-effort; don't block deletion on Stripe errors).
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
@@ -294,7 +324,10 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
}
// Soft-delete the local record.
db.Delete(&card)
if err := db.Delete(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete card", nil)
return
}
audit.CardDeleted(userID, card.ID)
@@ -325,6 +358,8 @@ func ensureStripeCustomer(gdb *gorm.DB, user *models.User) error {
return err
}
user.StripeCustomerID = cust.ID
gdb.Save(user)
if err := gdb.Save(user).Error; err != nil {
return fmt.Errorf("failed to save stripe customer: %w", err)
}
return nil
}

View File

@@ -1,34 +1,40 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"gorm.io/gorm"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod"
"gorm.io/gorm"
"go.opentelemetry.io/otel"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"terminalShop/pkg/utils"
)
type CartHandler struct {
stripeKey string
appURL string
}
// NewCartHandler creates a new cart handler with the Stripe secret key
func NewCartHandler(stripeSecretKey string) *CartHandler {
return &CartHandler{stripeKey: stripeSecretKey}
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
}
// getOrCreateCart finds the users cart or creates one.
func getOrCreateCart(userID uint) (*models.Cart, error) {
db := database.GetDB()
func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
db := database.GetDB().WithContext(ctx)
var cart models.Cart
err := db.Where("user_id = ?", userID).First(&cart).Error
if err != nil {
@@ -40,8 +46,8 @@ func getOrCreateCart(userID uint) (*models.Cart, error) {
return &cart, nil
}
func cartResponse(cart *models.Cart) map[string]interface{} {
db := database.GetDB()
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
db := database.GetDB().WithContext(ctx)
var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
@@ -73,14 +79,14 @@ func cartResponse(cart *models.Cart) map[string]interface{} {
func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromContext(r.Context())
cart, err := getOrCreateCart(userID)
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cart": cartResponse(cart),
"cart": cartResponse(r.Context(), cart),
})
}
@@ -90,7 +96,7 @@ type SetItemRequest struct {
}
func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var req SetItemRequest
@@ -110,14 +116,17 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
return
}
cart, err := getOrCreateCart(userID)
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
if req.Quantity <= 0 {
db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{})
if err := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
return
}
} else {
var item models.CartItem
result := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).First(&item)
@@ -127,14 +136,20 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
CoffeeID: req.CoffeeID,
Quantity: req.Quantity,
}
db.Create(&item)
if err := db.Create(&item).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create item", nil)
return
}
} else {
db.Model(&item).Update("quantity", req.Quantity)
if err := db.Model(&item).Update("quantity", req.Quantity).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update quantity", nil)
return
}
}
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"cart": cartResponse(cart),
"cart": cartResponse(r.Context(), cart),
})
}
@@ -143,7 +158,7 @@ type SetAddressRequest struct {
}
func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var req SetAddressRequest
@@ -158,13 +173,16 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return
}
cart, err := getOrCreateCart(userID)
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
db.Model(cart).Update("address_id", req.AddressID)
if err := db.Model(cart).Update("address_id", req.AddressID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set address", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "address set",
@@ -176,7 +194,7 @@ type SetCardRequest struct {
}
func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var req SetCardRequest
@@ -191,13 +209,16 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return
}
cart, err := getOrCreateCart(userID)
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
db.Model(cart).Update("card_id", req.CardID)
if err := db.Model(cart).Update("card_id", req.CardID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to set card", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "card set",
@@ -205,16 +226,19 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
}
func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
cart, err := getOrCreateCart(userID)
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
if err := db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete cart item", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "cart cleared",
@@ -222,11 +246,16 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
}
func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
cart, err := getOrCreateCart(userID)
ctx, span := otel.Tracer("api").Start(r.Context(), "cart.convert")
defer span.End()
r = r.WithContext(ctx)
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil {
middleware.RecordCartConversion("cart_lookup_failed")
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return
}
@@ -235,34 +264,40 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
if len(items) == 0 {
middleware.RecordCartConversion("validation_empty_cart")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
return
}
if cart.AddressID == nil {
middleware.RecordCartConversion("validation_missing_address")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "shipping address is required", nil)
return
}
if cart.CardID == nil {
middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
return
}
var address models.Address
if err := db.First(&address, *cart.AddressID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_address")
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "shipping address not found", nil)
return
}
var card models.Card
if err := db.First(&card, *cart.CardID).Error; err != nil {
middleware.RecordCartConversion("validation_invalid_card")
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
middleware.RecordCartConversion("user_not_found")
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
@@ -271,6 +306,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
// Ensure the user has a Stripe customer (shared helper with cards handler).
if err := ensureStripeCustomer(db, &user); err != nil {
middleware.RecordCartConversion("stripe_customer_failed")
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return
}
@@ -297,7 +333,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
db.Save(&card)
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
}
var subtotal int
@@ -316,6 +355,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
total := subtotal + cart.ShippingCost
if total < 50 {
middleware.RecordCartConversion("validation_min_amount")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
return
}
@@ -342,6 +382,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
}
if err := db.Create(&order).Error; err != nil {
middleware.RecordCartConversion("db_create_order_failed")
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil)
return
}
@@ -351,16 +392,15 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
// Charge the card. The idempotency key is tied to the order ID so retries
// never produce a double charge.
piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
Enabled: stripe.Bool(true),
AllowRedirects: stripe.String("never"),
},
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID),
PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(true),
PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Shipping: &stripe.ShippingDetailsParams{
Name: stripe.String(address.Name),
Address: &stripe.AddressParams{
@@ -378,27 +418,45 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
"order_id": fmt.Sprintf("%d", order.ID),
}
stripeStart := time.Now()
pi, err := paymentintent.New(piParams)
stripeDur := time.Since(stripeStart).Seconds()
if err != nil {
db.Model(&order).Update("status", models.OrderStatusFailed)
middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur)
if stripeErr, ok := err.(*stripe.Error); ok {
if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil {
h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent)
return
}
db.Model(&order).Update("status", models.OrderStatusFailed)
switch stripeErr.Type {
case stripe.ErrorTypeCard:
middleware.RecordCartConversion("card_declined")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusPaymentRequired, "CARD_DECLINED", stripeErr.Msg, nil)
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
case stripe.ErrorTypeInvalidRequest:
middleware.RecordCartConversion("stripe_invalid_request")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil)
default:
middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, stripeErr.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
}
return
}
db.Model(&order).Update("status", models.OrderStatusFailed)
middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, err.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
return
}
middleware.ObserveStripeRequest("payment_intent_create", "success", stripeDur)
if pi.Status == stripe.PaymentIntentStatusRequiresAction {
h.respondRequiresAction(w, &order, paymentMethodID, pi)
return
}
// Commit payment ID and clear cart in a single transaction.
txErr := db.Transaction(func(tx *gorm.DB) error {
@@ -420,17 +478,116 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
// most dangerous failure mode: the customer paid but has no order.
// The reconciliation job will catch this within 5 minutes.
// The audit log entry below must be treated as a critical alert.
middleware.RecordCartConversion("payment_critical")
audit.PaymentCritical(order.ID, pi.ID, txErr)
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order — please contact support", nil)
return
}
audit.OrderPaid(userID, order.ID, total, pi.ID)
middleware.RecordOrderCreated(string(order.Status))
middleware.RecordCartConversion("success")
middleware.ObserveOrderValueCents(total)
db.Preload("Items").First(&order, order.ID)
if err := db.Preload("Items").First(&order, order.ID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to preload items", nil)
return
}
go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order": order,
})
}
// respondRequiresAction handles a PaymentIntent that needs 3DS auth
// The order stays pending. the webhook flips it to paid once the customer
// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status
func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) {
db := database.GetDB()
// After an off-session authentication_required decline, the PI sits in
// status=requires_payment_method with payment_method detached. Re-confirm
// on-session with the same PM so Stripe pushes the PI into requires_action
// and populates next_action.redirect_to_url with the 3DS challenge URL.
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
confirmed, cerr := paymentintent.Confirm(pi.ID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(paymentMethodID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
if cerr == nil && confirmed != nil {
pi = confirmed
}
}
if pi.NextAction == nil || pi.NextAction.RedirectToURL == nil || pi.NextAction.RedirectToURL.URL == "" {
middleware.RecordCartConversion("sca_missing_next_action")
db.Model(order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(order.UserID, order.ID, order.Total, "stripe returned requires_action without redirect url")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "stripe did not return a 3ds url", nil)
return
}
// Persist the paymentIntent id AND reset status to pending. The
// payment_intent.payment_failed webhook from the initial off-session decline
// often arrives before this response and flips the order to failed —
// override it back so the 3DS-in-flight order shows the correct state.
if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID,
"status": models.OrderStatusPending,
}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return
}
token, err := storeRedirect(pi.NextAction.RedirectToURL.URL, models.RedirectPurposeCheckout3DS)
if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to allocate redirect token", nil)
return
}
middleware.RecordCartConversion("requires_action")
audit.OrderRequiresAction(order.UserID, order.ID, pi.ID)
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"payment_intent_id": pi.ID,
"status": "requires_action",
"redirect_token": token,
"redirect_url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
})
}
func mapStripeCardErrCode(code stripe.ErrorCode) string {
switch code {
case stripe.ErrorCodeAuthenticationRequired:
return "AUTHENTICATION_REQUIRED"
case "authentication_not_supported":
return "AUTHENTICATION_NOT_SUPPORTED"
case "card_declined", "":
return "CARD_DECLINED"
case "expired_card":
return "CARD_EXPIRED"
case "incorrect_cvc":
return "CARD_CVC_FAILED"
case "insufficient_funds":
return "CARD_INSUFFICIENT_FUNDS"
case "processing_error":
return "CARD_PROCESSING_ERROR"
}
return "CARD_DECLINED"
}
//nolint:unused // wired into Convert cart later
func appURLForHandler(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := r.Host
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
host = fh
}
return fmt.Sprintf("%s://%s", scheme, host)
}

View File

@@ -20,7 +20,7 @@ import (
func setupCartTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_cart.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -62,10 +62,10 @@ func authRequest(method, url string, body []byte, userID uint) *http.Request {
func TestGetCartEmpty(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
w := httptest.NewRecorder()
@@ -101,10 +101,10 @@ func TestGetCartEmpty(t *testing.T) {
func TestSetItemAndGetCart(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
// Add item (CoffeeID=1, Quantity=2)
body, _ := json.Marshal(map[string]interface{}{
@@ -157,10 +157,10 @@ func TestSetItemAndGetCart(t *testing.T) {
func TestSetItemUpdateQuantity(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
// Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
@@ -190,7 +190,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items))
@@ -202,10 +202,10 @@ func TestSetItemUpdateQuantity(t *testing.T) {
func TestSetItemRemove(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
// Add item
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
@@ -232,7 +232,7 @@ func TestSetItemRemove(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 0 {
t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items))
@@ -241,10 +241,10 @@ func TestSetItemRemove(t *testing.T) {
func TestSetItemInvalidProduct(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -258,10 +258,10 @@ func TestSetItemInvalidProduct(t *testing.T) {
func TestSetItemMissingCoffeeID(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
@@ -275,10 +275,10 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
func TestClearCart(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
// Add two items
for _, coffeeID := range []uint{1, 2} {
@@ -310,7 +310,7 @@ func TestClearCart(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 0 {
t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items))
@@ -319,7 +319,7 @@ func TestClearCart(t *testing.T) {
func TestSetAddress(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) {
}
db.Create(&addr)
handler := NewCartHandler("")
handler := NewCartHandler("", "")
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -358,7 +358,7 @@ func TestSetAddress(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID {
t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID)
@@ -367,7 +367,7 @@ func TestSetAddress(t *testing.T) {
func TestSetAddressWrongUser(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) {
}
db.Create(&addr)
handler := NewCartHandler("")
handler := NewCartHandler("", "")
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
@@ -402,7 +402,7 @@ func TestSetAddressWrongUser(t *testing.T) {
func TestSetCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) {
}
db.Create(&card)
handler := NewCartHandler("")
handler := NewCartHandler("", "")
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -440,7 +440,7 @@ func TestSetCard(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID {
t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID)
@@ -449,7 +449,7 @@ func TestSetCard(t *testing.T) {
func TestSetCardWrongUser(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) {
}
db.Create(&card)
handler := NewCartHandler("")
handler := NewCartHandler("", "")
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
@@ -482,10 +482,10 @@ func TestSetCardWrongUser(t *testing.T) {
func TestConvertCartMissingAddress(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
// Add item but no address or card
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
@@ -505,10 +505,10 @@ func TestConvertCartMissingAddress(t *testing.T) {
func TestConvertCartMissingCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
db := database.GetDB()
// Add item and address
@@ -536,10 +536,10 @@ func TestConvertCartMissingCard(t *testing.T) {
func TestConvertCartEmpty(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
// Empty cart convert
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
@@ -553,10 +553,10 @@ func TestConvertCartEmpty(t *testing.T) {
func TestMultipleItems(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewCartHandler("")
handler := NewCartHandler("", "")
// Add three different items
for i := uint(1); i <= 3; i++ {
@@ -583,7 +583,7 @@ func TestMultipleItems(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 3 {
t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items))
@@ -595,7 +595,7 @@ func TestMultipleItems(t *testing.T) {
func TestCartIsolation(t *testing.T) {
testDB, user1 := setupCartTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) {
}
db.Create(&user2)
handler := NewCartHandler("")
handler := NewCartHandler("", "")
r := chi.NewRouter()
r.Put("/cart/item", handler.SetItem)
r.Get("/cart", handler.GetCart)
@@ -638,7 +638,7 @@ func TestCartIsolation(t *testing.T) {
} `json:"cart"`
} `json:"data"`
}
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items))
@@ -655,7 +655,7 @@ func TestCartIsolation(t *testing.T) {
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
json.NewDecoder(w.Body).Decode(&resp)
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Cart.Items) != 1 {
t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items))

View File

@@ -1,44 +1,177 @@
package handlers
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
"terminalShop/pkg/bring"
"terminalShop/pkg/notify"
"terminalShop/pkg/shipping"
"terminalShop/pkg/shippo"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/refund"
"gorm.io/gorm"
)
type OrderHandler struct {
stripeKey string
stripeKey string
bringAPIUID string
bringAPIKey string
bringCustomerNumber string
}
func NewOrderHandler(stripeSecretKey string) *OrderHandler {
return &OrderHandler{stripeKey: stripeSecretKey}
type refundRequestPayload struct {
Reason string `json:"reason"`
Message string `json:"message"`
}
// refundRequestCooldown is the minimum interval between customer-initiated
// refund requests for the same order. Prevents accidental double-clicks and
// repeated submissions from spamming the operator's Slack channel.
const refundRequestCooldown = 15 * time.Minute
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
return &OrderHandler{
stripeKey: stripeSecretKey,
bringAPIUID: bringAPIUID,
bringAPIKey: bringAPIKey,
bringCustomerNumber: bringCustomerNumber,
}
}
// CreateRefundRequest posts a customer refund request into the order's Slack
// thread. It does not issue a Stripe refund.
func (h *OrderHandler) CreateRefundRequest(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
var req refundRequestPayload
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
req.Reason = strings.TrimSpace(req.Reason)
req.Message = strings.TrimSpace(req.Message)
if !models.IsValidRefundReason(req.Reason) {
utils.RespondError(w, http.StatusBadRequest, "INVALID_REASON", "invalid refund reason", nil)
return
}
if req.Reason == models.RefundRequestReasonOther && req.Message == "" {
utils.RespondError(w, http.StatusBadRequest, "MESSAGE_REQUIRED", "message is required when reason is other", nil)
return
}
var order models.Order
if err := db.Preload("User").Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
if !order.CanRequestRefund() {
utils.RespondError(w, http.StatusBadRequest, "INVALID_STATE",
"only paid, shipped, or delivered orders can request refunds", nil)
return
}
if order.LastRefundRequestAt != nil {
elapsed := time.Since(*order.LastRefundRequestAt)
if elapsed < refundRequestCooldown {
retryAfter := int((refundRequestCooldown - elapsed).Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED",
fmt.Sprintf("another refund request was submitted recently; try again in %d min", retryAfter/60+1),
nil)
return
}
}
text := fmt.Sprintf(":money_with_wings: *Refund request* — %s\n*From:* %s <%s>\n*Total:* $%.2f",
req.Reason, order.User.Name, order.User.Email, order.TotalInDollars())
if req.Message != "" {
quoted := "> " + strings.ReplaceAll(req.Message, "\n", "\n> ")
text += "\n\n" + quoted
}
if err := notify.SlackPostToOrderThreadBroadcast(order.ID, text); err != nil {
utils.RespondError(w, http.StatusBadGateway, "SLACK_ERROR", "failed to send refund request", nil)
return
}
// Record the timestamp after a successful Slack post so a Slack outage
// doesn't consume the user's cooldown — they can retry immediately.
if err := db.Model(&order).Update("last_refund_request_at", time.Now()).Error; err != nil {
slog.Warn("refund request: failed to record timestamp", "order_id", order.ID, "error", err)
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"status": "sent",
})
}
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
db.Where("user_id = ?", userID).
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at desc").Find(&orders)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"orders": orders,
})
}
func (h *OrderHandler) GetOrderStatus(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
var order models.Order
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"id": order.ID,
"status": string(order.Status),
})
}
// RefundOrder issues a full refund for an order via Stripe and marks it as
// refunded. Only orders in paid or shipped status can be refunded.
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
@@ -93,3 +226,181 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
"status": "refunded",
})
}
// updateTrackingRequest is the JSON body accepted by UpdateTracking.
// shipped_at defaults to now if not otherwise specified
type updateTrackingRequest struct {
Carrier string `json:"carrier"`
TrackingNumber string `json:"tracking_number"`
TrackingURL string `json:"tracking_url"`
ShippedAt *time.Time `json:"shipped_at"`
}
// UpdateTracking sets carrier metadata on the order. Transitions paid orders
// to shipped automatically. Admin only, gated by RequireAdmin in routes.go,
// not RequireAuth, so no user JWT is needed.
func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
var req updateTrackingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
if req.Carrier == "" || req.TrackingNumber == "" {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELDS", "carrier and or tracking_number fields are required", nil)
return
}
var order models.Order
if err := db.Where("id = ?", id).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
shippedAt := req.ShippedAt
if shippedAt == nil {
now := time.Now()
shippedAt = &now
}
updates := map[string]any{
"carrier": req.Carrier,
"tracking_number": req.TrackingNumber,
"tracking_url": req.TrackingURL,
"shipped_at": shippedAt,
}
if order.Status == models.OrderStatusPaid {
updates["status"] = models.OrderStatusShipped
}
if err := db.Model(&order).Updates(updates).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to record shipment", nil)
return
}
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"order_id": order.ID,
"carrier": req.Carrier,
"tracking_number": req.TrackingNumber,
"status": order.Status,
})
}
// PurchaseLabel buys a shipping label from shippo for the order, writes
// the carrier, tracking, and label fields onto the order, and changes the status
// from paid to shipped
func (h *OrderHandler) PurchaseLabel(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
var order models.Order
if err := db.Preload("Items.Coffee").Preload("User").Where("id = ?", id).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
if order.TrackingNumber != "" {
utils.RespondError(w, http.StatusConflict, "ALREADY_LABELED", "order already has a tracking number", nil)
return
}
if order.Status != models.OrderStatusPaid {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "only paid orders can be labeled", nil)
return
}
const ozToKg = 0.0283495
const defaultOunces = 12.0
totalKg := 0.0
bringItems := make([]bring.LineItem, 0, len(order.Items))
shippoItems := make([]shippo.LineItem, 0, len(order.Items))
for _, item := range order.Items {
oz := float64(item.Coffee.Ounces)
if oz <= 0 {
oz = defaultOunces
}
kg := oz * ozToKg
totalKg += float64(item.Quantity) * kg
bringItems = append(bringItems, bring.LineItem{Title: item.Name, Quantity: item.Quantity, WeightKg: kg})
shippoItems = append(shippoItems, shippo.LineItem{Title: item.Name, Quantity: item.Quantity, WeightKg: kg})
}
var result *shipping.LabelResult
switch order.ShippingCountry {
case "US":
to := shippo.Address{
Name: order.ShippingName, Street1: order.ShippingStreet, Street2: order.ShippingStreet2,
City: order.ShippingCity, State: order.ShippingState, Country: order.ShippingCountry,
Zip: order.ShippingZip, Phone: order.ShippingPhone,
}
client := shippo.NewClient(os.Getenv("SHIPPO_API_KEY"))
result, err = client.CreateLabel(r.Context(), to, order.User.Email, shippoItems)
if err != nil {
utils.RespondError(w, http.StatusBadGateway, "SHIPPO_ERROR", err.Error(), nil)
return
}
case "NO":
to := bring.BookingAddress{
Name: order.ShippingName, Street1: order.ShippingStreet, Street2: order.ShippingStreet2,
City: order.ShippingCity, Country: order.ShippingCountry, Zip: order.ShippingZip,
Phone: order.ShippingPhone,
}
testMode := os.Getenv("ENVIRONMENT") != "production"
client := bring.NewBookingClient(h.bringAPIUID, h.bringAPIKey, h.bringCustomerNumber, testMode)
result, err = client.CreateLabel(r.Context(), to, order.User.Email, bringItems)
if err != nil {
utils.RespondError(w, http.StatusBadGateway, "BRING_ERROR", err.Error(), nil)
return
}
default:
utils.RespondError(w, http.StatusBadRequest, "UNSUPPORTED_DESTINATION", "no label vendor configured for this country", nil)
return
}
now := time.Now()
updates := map[string]any{
"carrier": result.Carrier,
"tracking_number": result.TrackingNumber,
"tracking_url": result.TrackingURL,
"shipped_at": &now,
"status": models.OrderStatusShipped,
"shippo_transaction_id": result.TransactionID,
"label_url": result.LabelURL,
"label_cost_cents": result.CostCents,
}
if err := db.Model(&order).Updates(updates).Error; err != nil {
audit.LabelOrphaned(order.ID, result.TransactionID, result.TrackingNumber, err)
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "label purchased but failed to record on order; ops will reconcile", nil)
return
}
audit.LabelPurchased(order.ID, result.Carrier, result.TrackingNumber, result.TransactionID, result.CostCents)
go notify.SlackUnpinOrder(order.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"order_id": order.ID,
"carrier": result.Carrier,
"service": result.ServiceLevel,
"tracking_number": result.TrackingNumber,
"tracking_url": result.TrackingURL,
"label_url": result.LabelURL,
"cost_cents": result.CostCents,
"status": models.OrderStatusShipped,
})
}

170
api/handlers/orders_test.go Normal file
View File

@@ -0,0 +1,170 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/go-chi/chi/v5"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
func setupOrderTestDB(t *testing.T) models.User {
t.Helper()
testDB := filepath.Join(t.TempDir(), "orders.db")
database.ResetForTesting()
t.Cleanup(database.ResetForTesting)
db, err := database.Connect(testDB)
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
if err := database.Seed(db); err != nil {
t.Fatalf("failed to seed: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:ordertestfingerprint",
SSHPublicKey: "ssh-ed25519 AAAA ordertestkey",
Name: "Order Test User",
Email: "orders@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
return user
}
func orderRequest(method, url string, body []byte, userID uint, orderID string) *http.Request {
req := authRequest(method, url, body, userID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", orderID)
return req.WithContext(contextWithRoute(req.Context(), routeCtx))
}
func contextWithRoute(ctx context.Context, routeCtx *chi.Context) context.Context {
return context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
}
func TestCreateRefundRequestRequiresMessageForOther(t *testing.T) {
user := setupOrderTestDB(t)
handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Other","message":" "}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, "1")
w := httptest.NewRecorder()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "MESSAGE_REQUIRED" {
t.Errorf("expected MESSAGE_REQUIRED, got %q", resp.Error.Code)
}
}
func TestCreateRefundRequestRejectsInvalidOrderState(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
order := models.Order{
UserID: user.ID,
CardID: 1,
Status: models.OrderStatusFailed,
Subtotal: 100,
ShippingCost: 0,
Total: 100,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("failed to create order: %v", err)
}
handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "INVALID_STATE" {
t.Errorf("expected INVALID_STATE, got %q", resp.Error.Code)
}
}
func TestCreateRefundRequestEnforcesCooldown(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
recent := time.Now().Add(-1 * time.Minute)
order := models.Order{
UserID: user.ID,
CardID: 1,
Status: models.OrderStatusPaid,
Subtotal: 100,
ShippingCost: 0,
Total: 100,
LastRefundRequestAt: &recent,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("failed to create order: %v", err)
}
handler := NewOrderHandler("", "", "", "")
body := []byte(`{"reason":"Quality issue","message":""}`)
req := orderRequest("POST", "/api/v1/orders/1/refund-request", body, user.ID, fmt.Sprintf("%d", order.ID))
w := httptest.NewRecorder()
handler.CreateRefundRequest(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected status 429, got %d", w.Code)
}
if got := w.Header().Get("Retry-After"); got == "" {
t.Errorf("expected Retry-After header, got empty")
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Error.Code != "RATE_LIMITED" {
t.Errorf("expected RATE_LIMITED, got %q", resp.Error.Code)
}
}

View File

@@ -20,7 +20,7 @@ func NewProductHandler() *ProductHandler {
// GetProducts returns all products from the database
func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
var products []models.Coffee
@@ -37,7 +37,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
// GetProduct returns a single product by ID
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
// Get ID from URL parameter
idStr := chi.URLParam(r, "id")
@@ -57,4 +57,3 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
"product": product,
})
}

View File

@@ -16,7 +16,7 @@ func setupTestDB(t *testing.T) string {
testDB := "test_products.db"
// Remove if exists
os.Remove(testDB)
_ = os.Remove(testDB)
// Reset singleton
database.ResetForTesting()
@@ -39,7 +39,7 @@ func setupTestDB(t *testing.T) string {
func TestGetProduct(t *testing.T) {
testDB := setupTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewProductHandler()
@@ -78,7 +78,7 @@ func TestGetProduct(t *testing.T) {
func TestGetProductNotFound(t *testing.T) {
testDB := setupTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
handler := NewProductHandler()
@@ -98,7 +98,7 @@ func TestGetProductNotFound(t *testing.T) {
func TestGetProductsEmptyDatabase(t *testing.T) {
testDB := "test_empty.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
// Create database but don't seed

View File

@@ -2,17 +2,20 @@ package handlers
import (
"fmt"
"log"
"log/slog"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent"
"gorm.io/gorm"
)
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
// the case where the server crashed after charging but before the DB transaction
@@ -30,7 +33,7 @@ func ReconcileOrders(stripeKey string) {
return
}
log.Printf("[reconcile] checking %d pending orders", len(orders))
reconcileLog().Info("checking pending orders", "count", len(orders))
for _, order := range orders {
params := &stripe.PaymentIntentSearchParams{
@@ -42,7 +45,8 @@ func ReconcileOrders(stripeKey string) {
iter := paymentintent.Search(params)
for iter.Next() {
pi := iter.PaymentIntent()
log.Printf("[reconcile] found succeeded PI %s for order %d — patching", pi.ID, order.ID)
reconcileLog().Warn("found succeeded payment intent for unrecorded order — patching",
"order_id", order.ID, "pi", pi.ID)
txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{
@@ -64,11 +68,34 @@ func ReconcileOrders(stripeKey string) {
}).Error
})
if txErr != nil {
log.Printf("[reconcile] failed to patch order %d: %v", order.ID, txErr)
reconcileLog().Error("failed to patch order", "order_id", order.ID, "error", txErr)
}
}
if err := iter.Err(); err != nil {
log.Printf("[reconcile] stripe search error for order %d: %v", order.ID, err)
reconcileLog().Error("stripe search error", "order_id", order.ID, "error", err)
}
}
}
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() {
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour)
err := db.Where(
"status = ? AND (tracking_number = '' OR tracking_number IS NULL) AND created_at < ?",
models.OrderStatusPaid, cutoff,
).Order("created_at ASC").Find(&orders).Error
if err != nil {
reconcileLog().Error("unshipped query failed", "error", err)
return
}
if len(orders) == 0 {
return
}
reconcileLog().Info("unshipped orders found", "count", len(orders))
notify.SlackUnshippedReminder(orders)
}

280
api/handlers/slack.go Normal file
View File

@@ -0,0 +1,280 @@
package handlers
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"terminalShop/pkg/utils"
"time"
)
type SlackHandler struct {
signingSecret string
adminKey string
apiPort string
shippoSecret string
httpClient *http.Client
}
func NewSlackHandler(signingSecret, adminKey, apiPort, shippoWebhookSecret string) *SlackHandler {
return &SlackHandler{
signingSecret: signingSecret,
adminKey: adminKey,
apiPort: apiPort,
shippoSecret: shippoWebhookSecret,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
type slackAction struct {
ActionID string `json:"action_id"`
Value string `json:"value"`
}
type slackInteractivePayload struct {
Type string `json:"type"`
ResponseURL string `json:"response_url"`
Actions []slackAction `json:"actions"`
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
}
func (h *SlackHandler) HandleInteractivity(w http.ResponseWriter, r *http.Request) {
if h.signingSecret == "" {
utils.RespondError(w, http.StatusServiceUnavailable, "SLACK_NOT_CONFIGURED", "slack signing secret not set", nil)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "READ_FAILED", "could not read body", nil)
return
}
if !h.verifySignature(r, body) {
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "slack signature mismatch", nil)
return
}
form, err := url.ParseQuery(string(body))
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "BAD_FORM", "could not parse form body", nil)
return
}
rawPayload := form.Get("payload")
if rawPayload == "" {
utils.RespondError(w, http.StatusBadRequest, "MISSING_PAYLOAD", "no payload field", nil)
return
}
var p slackInteractivePayload
if err := json.Unmarshal([]byte(rawPayload), &p); err != nil {
utils.RespondError(w, http.StatusBadRequest, "BAD_JSON_PAYLOAD", "could not parse payload", nil)
return
}
if len(p.Actions) == 0 {
w.WriteHeader(http.StatusOK)
return
}
action := p.Actions[0]
w.WriteHeader(http.StatusOK)
go h.dispatch(action, p)
}
func (h *SlackHandler) dispatch(action slackAction, p slackInteractivePayload) {
switch action.ActionID {
case "buy_label":
h.handleBuyLabel(action.Value, p)
case "mark_pre_transit":
h.handleMarkStatus(action.Value, p, "PRE_TRANSIT")
case "mark_in_transit":
h.handleMarkStatus(action.Value, p, "TRANSIT")
case "mark_delivered":
h.handleMarkStatus(action.Value, p, "DELIVERED")
default:
slog.Warn("slack interactivity: unknown action", "action_id", action.ActionID)
}
}
func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePayload) {
orderID64, err := strconv.ParseUint(orderIDStr, 10, 32)
if err != nil {
slog.Warn("slack buy_label: invalid order id", "value", orderIDStr)
return
}
orderID := uint(orderID64)
url := fmt.Sprintf("http://localhost:%s/api/v1/orders/%d/label", h.apiPort, orderID)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, url, nil)
req.Header.Set("X-Admin-Key", h.adminKey)
resp, err := h.httpClient.Do(req)
if err != nil {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
return
}
defer func() { _ = resp.Body.Close() }()
var result struct {
Data struct {
TrackingNumber string `json:"tracking_number"`
Carrier string `json:"carrier"`
LabelURL string `json:"label_url"`
CostCents int `json:"cost_cents"`
} `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
bodyBytes, _ := io.ReadAll(resp.Body)
_ = json.Unmarshal(bodyBytes, &result)
if resp.StatusCode >= 300 {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
return
}
notify.SlackPostLabelPurchased(orderID, result.Data.LabelURL, result.Data.CostCents)
}
func (h *SlackHandler) handleMarkStatus(orderIDStr string, p slackInteractivePayload, status string) {
orderID64, err := strconv.ParseUint(orderIDStr, 10, 32)
if err != nil {
slog.Warn("slack mark_status: invalid order id", "value", orderIDStr)
return
}
orderID := uint(orderID64)
var order models.Order
if err := database.GetDB().Select("id", "tracking_number", "carrier").Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("slack mark_status: order lookup failed", "order_id", orderID, "error", err)
return
}
if order.TrackingNumber == "" {
_ = notify.SlackPostToOrderThread(orderID, ":x: Cannot mark status, the order has no tracking number yet.")
return
}
detail := defaultDetailFor(status)
payload := map[string]any{
"event": "track_updated",
"data": map[string]any{
"tracking_number": order.TrackingNumber,
"carrier": strings.ToLower(order.Carrier),
"tracking_status": map[string]any{
"status": status,
"status_date": time.Now().UTC().Format(time.RFC3339),
"status_details": detail,
},
},
}
body, _ := json.Marshal(payload)
webhookURL := fmt.Sprintf("http://localhost:%s/api/v1/webhooks/shippo?token=%s", h.apiPort, url.QueryEscape(h.shippoSecret))
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed: %v", status, err))
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
_ = notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Mark %s failed (HTTP %d)", status, resp.StatusCode))
return
}
audit.TrackingMarkedManually(orderID, status, p.User.Username)
h.editClearActions(p.ResponseURL, status, p.User.Username)
}
func defaultDetailFor(status string) string {
switch status {
case "PRE_TRANSIT":
return "Marked pre-transit by operator"
case "TRANSIT":
return "Marked transit by operator"
case "DELIVERED":
return "Marked delivered by operator"
}
return "Marked by operator"
}
func (h *SlackHandler) editClearActions(responseURL, status, actor string) {
if responseURL == "" {
return
}
stamp := fmt.Sprintf(":white_check_mark: Marked %s by @%s at %s", strings.ToLower(status), actor, time.Now().UTC().Format("15:04 MST"))
body, _ := json.Marshal(map[string]any{
"replace_original": true,
"text": stamp,
"blocks": []map[string]any{
{"type": "context", "elements": []map[string]any{
{"type": "mrkdwn", "text": stamp},
}},
},
})
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, responseURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
slog.Warn("slack mark_status: response_url edit failed", "error", err)
return
}
_ = resp.Body.Close()
}
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {
tsHeader := r.Header.Get("X-Slack-Request-Timestamp")
sigHeader := r.Header.Get("X-Slack-Signature")
if tsHeader == "" || sigHeader == "" {
return false
}
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-ts) > 60*5 {
return false
}
mac := hmac.New(sha256.New, []byte(h.signingSecret))
_, _ = fmt.Fprintf(mac, "v0:%d:%s", ts, body)
expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sigHeader))
}
func abs(x int64) int64 {
if x < 0 {
return -x
}
return x
}
var _ = os.Getenv

33
api/handlers/tracking.go Normal file
View File

@@ -0,0 +1,33 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// TrackingRedirect resolves an order ID and 302s to the carrier's tracking URL.
// Public (no auth) so the URL stays short enough to copy from any TUI width.
func TrackingRedirect(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
http.Error(w, "bad order id", http.StatusBadRequest)
return
}
var order models.Order
if err := database.GetDB().Select("tracking_url").Where("id = ?", uint(id)).First(&order).Error; err != nil {
http.NotFound(w, r)
return
}
if order.TrackingURL == "" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, order.TrackingURL, http.StatusFound)
}

View File

@@ -7,6 +7,8 @@ import (
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"gorm.io/gorm"
)
type ViewHandler struct{}
@@ -16,7 +18,7 @@ func NewViewHandler() *ViewHandler {
}
func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var user models.User
@@ -38,7 +40,10 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("user_id = ?", userID).Find(&cards)
var orders []models.Order
db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders)
db.Where("user_id = ?", userID).
Preload("Items").
Preload("Events", func(tx *gorm.DB) *gorm.DB { return tx.Order("created_at ASC") }).
Order("created_at DESC").Find(&orders)
var cartItems []models.CartItem
if cart.ID != 0 {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)

View File

@@ -1,10 +1,14 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"strings"
"time"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentmethod"
@@ -15,18 +19,22 @@ import (
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/notify"
"terminalShop/pkg/utils"
)
func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
// WebhookHandler handles inbound Stripe webhook events.
type WebhookHandler struct {
webhookSecret string
stripeKey string
shippoSecret string
}
// NewWebhookHandler creates a new webhook handler.
func NewWebhookHandler(webhookSecret string, stripeKey string) *WebhookHandler {
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey}
func NewWebhookHandler(webhookSecret, stripeKey, shippoSecret string) *WebhookHandler {
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey, shippoSecret: shippoSecret}
}
// HandleStripe validates the Stripe signature and routes the event.
@@ -45,10 +53,13 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" {
// No secret configured — log a loud warning but still process in dev.
log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification")
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
} else {
sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEvent(body, sig, h.webhookSecret); err != nil {
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
}); err != nil {
webhookLog().Warn("signature verification failed", "error", err)
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil)
return
}
@@ -63,35 +74,35 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
switch event.Type {
case "checkout.session.completed":
h.handleCheckoutSessionCompleted(event)
h.handleCheckoutSessionCompleted(r.Context(), event)
// payment_intent.succeeded fires when a charge completes asynchronously
// (e.g. 3DS, bank redirects). For confirm=true intents this is normally
// already handled by ConvertCart, but the webhook is the authoritative source.
case "payment_intent.succeeded":
h.handlePaymentIntentSucceeded(event)
h.handlePaymentIntentSucceeded(r.Context(), event)
// payment_intent.payment_failed fires when a payment fails after initial
// confirmation (e.g. bank decline on a deferred 3DS flow).
case "payment_intent.payment_failed":
h.handlePaymentIntentFailed(event)
h.handlePaymentIntentFailed(r.Context(), event)
// Sync local card state when Stripe's side changes.
case "payment_method.attached",
"payment_method.detached",
"payment_method.updated":
// Handled by the reconcile job and direct API calls; log only.
log.Printf("[webhook] %s received for payment method", event.Type)
webhookLog().Info("payment method event received", "event_type", string(event.Type))
}
// Always return 200 so Stripe doesn't retry.
w.WriteHeader(http.StatusOK)
}
func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event stripe.Event) {
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
log.Printf("[webhook] failed to unmarshal payment_intent: %v", err)
webhookLog().Error("failed to unmarshal payment_intent", "error", err, "event", "payment_intent.succeeded")
return
}
@@ -101,10 +112,10 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
return
}
db := database.GetDB()
db := database.GetDB().WithContext(ctx)
var order models.Order
if err := db.Where("id = ?", orderIDStr).First(&order).Error; err != nil {
log.Printf("[webhook] order %s not found for pi %s", orderIDStr, pi.ID)
if err := db.Preload("Items").Where("id = ?", orderIDStr).First(&order).Error; err != nil {
webhookLog().Warn("order not found for payment intent", "order_id", orderIDStr, "pi", pi.ID)
return
}
@@ -125,25 +136,29 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil {
return nil // Cart already gone.
}
tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return fmt.Errorf("failed to delete cart item: %w", err)
}
return tx.Model(&cart).Updates(map[string]interface{}{
"address_id": nil,
"card_id": nil,
}).Error
})
if txErr != nil {
log.Printf("[webhook] failed to record paid status for order %s (pi=%s): %v", orderIDStr, pi.ID, txErr)
webhookLog().Error("failed to record paid status",
"order_id", orderIDStr, "pi", pi.ID, "error", txErr)
return
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
log.Printf("[webhook] order %s marked paid via payment_intent.succeeded (pi=%s)", orderIDStr, pi.ID)
go notify.SlackOrderPaid(&order)
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
}
func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) {
func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event stripe.Event) {
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
log.Printf("[webhook] failed to unmarshal payment_intent: %v", err)
webhookLog().Error("failed to unmarshal payment_intent", "error", err, "event", "payment_intent.payment_failed")
return
}
@@ -152,21 +167,33 @@ func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) {
return
}
db := database.GetDB()
db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed)
// authentication_required is recoverable — the cart handler re-confirms the
// PI on-session and the customer completes 3DS via redirect. Marking the
// order failed here would race with the recovery path and surface a
// momentarily-broken state in the TUI.
if pi.LastPaymentError != nil && string(pi.LastPaymentError.Code) == "authentication_required" {
webhookLog().Info("ignoring authentication_required payment_failed; handled via 3ds redirect",
"order_id", orderIDStr, "pi", pi.ID)
return
}
db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
Update("status", models.OrderStatusFailed).Error; err != nil {
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
}
errMsg := "unknown"
if pi.LastPaymentError != nil {
errMsg = pi.LastPaymentError.Msg
}
log.Printf("[webhook] payment failed for order %s (pi=%s): %s", orderIDStr, pi.ID, errMsg)
webhookLog().Warn("payment failed", "order_id", orderIDStr, "pi", pi.ID, "reason", errMsg)
}
func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, event stripe.Event) {
var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
log.Printf("[webhook] failed to unmarshal checkout session: %v", err)
webhookLog().Error("failed to unmarshal checkout session", "error", err)
return
}
@@ -175,7 +202,7 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
}
if sess.SetupIntent == nil || sess.Customer == nil {
log.Printf("[webhook] checkout session missing setup_intent or customer")
webhookLog().Warn("checkout session missing setup_intent or customer")
return
}
@@ -183,34 +210,60 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
si, err := setupintent.Get(sess.SetupIntent.ID, nil)
if err != nil {
log.Printf("[webhook] failed to get setup intent %s: %v", sess.SetupIntent.ID, err)
webhookLog().Error("failed to get setup intent", "setup_intent", sess.SetupIntent.ID, "error", err)
return
}
if si.PaymentMethod == nil {
log.Printf("[webhook] setup intent %s has no payment method", si.ID)
webhookLog().Warn("setup intent has no payment method", "setup_intent", si.ID)
return
}
pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil)
if err != nil {
log.Printf("[webhook] failed to get payment method %s: %v", si.PaymentMethod.ID, err)
webhookLog().Error("failed to get payment method", "payment_method", si.PaymentMethod.ID, "error", err)
return
}
db := database.GetDB()
db := database.GetDB().WithContext(ctx)
var user models.User
if err := db.Where("stripe_customer_id = ?", sess.Customer.ID).First(&user).Error; err != nil {
log.Printf("[webhook] user not found for customer %s: %v", sess.Customer.ID, err)
webhookLog().Warn("user not found for customer", "customer", sess.Customer.ID, "error", err)
return
}
// Idempotency: skip if this payment method is already saved.
// Idempotency: skip if this exact PaymentMethod is already in use
var existing models.Card
if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil {
return
}
// dedup against local cards table, matching last4 + brand + expiry means
// the user is re-adding the same physical card. detach the new pm from
// stripe and skip the local insert. Local DB is the source of truth
// the stripe customers paymentmethods list may contain orphans from before
// this fix, which would cause false-positive dedup hits
if pm.Card != nil {
var dup models.Card
err := db.Where(
"user_id = ? AND last4 = ? AND brand = ? AND exp_month = ? AND exp_year = ?",
user.ID, pm.Card.Last4, string(pm.Card.Brand),
int(pm.Card.ExpMonth), int(pm.Card.ExpYear),
).First(&dup).Error
if err == nil {
if _, derr := paymentmethod.Detach(pm.ID, nil); derr != nil {
webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
}
// Bump UpdatedAt so the TUI poll signal can detect that the webhook
// fired even though no new row was inserted.
if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil {
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
}
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
return
}
}
card := models.Card{
UserID: user.ID,
StripePaymentID: pm.ID,
@@ -221,10 +274,175 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
}
if err := db.Create(&card).Error; err != nil {
log.Printf("[webhook] failed to save card for user %d: %v", user.ID, err)
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)
return
}
audit.CardAdded(user.ID, card.ID, card.Brand, card.Last4)
log.Printf("[webhook] card saved via checkout.session.completed for user %d", user.ID)
webhookLog().Info("card saved via checkout.session.completed", "user_id", user.ID)
}
func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if h.shippoSecret == "" || token != h.shippoSecret {
webhookLog().Warn("shippo webhook: token missing or invalid")
utils.RespondError(w, http.StatusUnauthorized, "INVALID_TOKEN", "token missing or invalid", nil)
return
}
const maxBodyBytes = 65536
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "READ_ERROR", "could not read request body", nil)
return
}
var payload struct {
Event string `json:"event"`
Data struct {
TrackingNumber string `json:"tracking_number"`
Carrier string `json:"carrier"`
TrackingStatus struct {
Status string `json:"status"`
StatusDetails string `json:"status_details"`
StatusDate time.Time `json:"status_date"`
} `json:"tracking_status"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "could not parse event", nil)
return
}
if payload.Event != "track_updated" {
webhookLog().Info("shippo webhook: ignoring no-tracking event", "event", payload.Event)
writeShippoResponse(w, map[string]any{
"received": true,
"skipped": "non-tracking event",
"event": payload.Event,
})
return
}
newStatus, known := mapShippoStatus(payload.Data.TrackingStatus.Status)
if !known {
webhookLog().Warn("shippo webhook: unrecognized status, skipping",
"status", payload.Data.TrackingStatus.Status,
"tracking_number", payload.Data.TrackingNumber)
writeShippoResponse(w, map[string]any{
"received": true,
"skipped": "unrecognized status",
"raw_status": payload.Data.TrackingStatus.Status,
})
return
}
db := database.GetDB().WithContext(r.Context())
var order models.Order
if err := db.Where("tracking_number = ?", payload.Data.TrackingNumber).First(&order).Error; err != nil {
webhookLog().Warn("shippo webhook: order not found",
"tracking_number", payload.Data.TrackingNumber)
writeShippoResponse(w, map[string]any{
"received": true,
"skipped": "order not found",
"tracking_number": payload.Data.TrackingNumber,
})
return
}
if order.TrackingStatusUpdatedAt != nil && !payload.Data.TrackingStatus.StatusDate.After(*order.TrackingStatusUpdatedAt) {
writeShippoResponse(w, map[string]any{
"received": true,
"skipped": "stale status_date",
"order_id": order.ID,
"current_tracking_status": string(order.TrackingStatus),
})
return
}
statusDate := payload.Data.TrackingStatus.StatusDate
previousTrackingStatus := order.TrackingStatus
updates := map[string]any{
"tracking_status": newStatus,
"tracking_status_details": payload.Data.TrackingStatus.StatusDetails,
"tracking_status_updated_at": &statusDate,
}
deliveredTransition := newStatus == models.TrackingStatusDelivered &&
order.Status != models.OrderStatusDelivered
if deliveredTransition {
updates["status"] = models.OrderStatusDelivered
}
if err := db.Model(&order).Updates(updates).Error; err != nil {
webhookLog().Error("shippo webhook: failed to persist tracking update",
"order_id", order.ID, "error", err)
utils.RespondError(w, http.StatusInternalServerError, "DB_ERROR", "failed to persist", nil)
return
}
if previousTrackingStatus != newStatus {
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
go func() {
_ = notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
}()
}
if deliveredTransition {
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
}
writeShippoResponse(w, map[string]any{
"received": true,
"order_id": order.ID,
"tracking_status": string(newStatus),
"previous_tracking_status": string(previousTrackingStatus),
"lifecycle_status": string(order.Status),
"delivered_transition": deliveredTransition,
"status_details": payload.Data.TrackingStatus.StatusDetails,
})
}
func formatTrackingUpdate(status models.TrackingStatus, details string) string {
var icon, label string
switch status {
case models.TrackingStatusPreTransit:
icon, label = ":label:", "Label created"
case models.TrackingStatusTransit:
icon, label = ":truck:", "In transit"
case models.TrackingStatusDelivered:
icon, label = ":white_check_mark:", "Delivered"
case models.TrackingStatusReturned:
icon, label = ":leftwards_arrow_with_hook:", "Returned to sender"
case models.TrackingStatusFailure:
icon, label = ":warning:", "Delivery exception"
default:
icon, label = ":package:", string(status)
}
if details != "" {
return fmt.Sprintf("%s *%s* — %s", icon, label, details)
}
return fmt.Sprintf("%s *%s*", icon, label)
}
func mapShippoStatus(s string) (models.TrackingStatus, bool) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "UNKNOWN":
return models.TrackingStatusUnknown, true
case "PRE_TRANSIT":
return models.TrackingStatusPreTransit, true
case "TRANSIT":
return models.TrackingStatusTransit, true
case "DELIVERED":
return models.TrackingStatusDelivered, true
case "RETURNED":
return models.TrackingStatusReturned, true
case "FAILURE":
return models.TrackingStatusFailure, true
}
return models.TrackingStatusUnknown, false
}
func writeShippoResponse(w http.ResponseWriter, payload map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(payload)
}

BIN
api/main

Binary file not shown.

View File

@@ -11,15 +11,22 @@ import (
"time"
"terminalShop/api/handlers"
"terminalShop/api/middleware"
"terminalShop/api/routes"
"terminalShop/pkg/audit"
"terminalShop/pkg/auth"
"terminalShop/pkg/config"
"terminalShop/pkg/database"
"terminalShop/pkg/observability"
"terminalShop/pkg/stripeclient"
)
const version = "v0.1.0"
func main() {
observability.InitLogger()
middleware.SetBuildInfo(version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Load configuration
@@ -33,23 +40,38 @@ func main() {
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
defer func() { _ = database.Close() }()
// Run migrations
if err := database.Migrate(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
audit.SetDB(db)
// Seed database with initial data
if err := database.Seed(db); err != nil {
log.Fatalf("Failed to seed database: %v", err)
}
otlpEndpoint := os.Getenv("OTLP_ENDPOINT")
if otlpEndpoint == "" {
otlpEndpoint = "tempo:4318"
}
shutdown, err := observability.InitTracing(context.Background(), "terminalshop-api", otlpEndpoint)
if err != nil {
log.Printf("tracing init failed (non-fatal): %v", err)
} else {
defer func() { _ = shutdown(context.Background()) }()
}
stripeclient.InitOTel()
// Init JWT manager (same secret + duration as SSH server)
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.AppURL)
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort)
// Create HTTP server
server := &http.Server{
@@ -91,6 +113,21 @@ func main() {
}
}()
go func() {
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
}
}()
log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received

25
api/middleware/admin.go Normal file
View File

@@ -0,0 +1,25 @@
package middleware
import (
"net/http"
"os"
"terminalShop/pkg/utils"
)
// RequireAdmin gates a handler behind X-Admin-Key matching the ADMIN_API_KEY
// environment variable. if ADMIN_API_KEY is not set the endpoint returns 503, admin actions
// must be explicitly enabled, never be the default option
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expected := os.Getenv("ADMIN_API_KEY")
if expected == "" {
utils.RespondError(w, http.StatusServiceUnavailable, "ADMIN_DISABLED", "admin endpoints are disabled", nil)
return
}
if r.Header.Get("X-Admin-Key") != expected {
utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid admin api key", nil)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -4,16 +4,19 @@ import (
"log"
"net/http"
"time"
"go.opentelemetry.io/otel/trace"
)
// responseWriter wraps http.ResponseWriter to capture status code
// responseWriter wraps http.ResponseWriter to capture status code and response size.
type responseWriter struct {
http.ResponseWriter
statusCode int
statusCode int
bytesWritten int64
}
func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{w, http.StatusOK}
return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
}
func (rw *responseWriter) WriteHeader(code int) {
@@ -21,6 +24,12 @@ func (rw *responseWriter) WriteHeader(code int) {
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytesWritten += int64(n)
return n, err
}
// Logger logs HTTP requests
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -29,14 +38,21 @@ func Logger(next http.Handler) http.Handler {
wrapped := newResponseWriter(w)
next.ServeHTTP(wrapped, r)
sc := trace.SpanContextFromContext(r.Context())
tid := ""
if sc.HasTraceID() {
tid = sc.TraceID().String()
}
duration := time.Since(start)
log.Printf(
"%s %s %d %s",
"%s %s %d %s trace_id=%s",
r.Method,
r.RequestURI,
r.URL.Path,
wrapped.statusCode,
duration,
tid,
)
})
}

194
api/middleware/metrics.go Normal file
View File

@@ -0,0 +1,194 @@
package middleware
import (
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests processed, labeled by method, route, and status",
},
[]string{"method", "route", "status"},
)
httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency in seconds, labeled by method and route",
Buckets: []float64{.0001, .0005, .001, .0025, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
},
[]string{"method", "route"},
)
httpInflightRequests = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "http_inflight_requests",
Help: "Number of HTTP requests currently being handled",
},
)
httpRequestSize = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_size_bytes",
Help: "HTTP request body size in bytes",
Buckets: prometheus.ExponentialBuckets(64, 4, 8),
},
[]string{"method", "route"},
)
httpResponseSize = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_response_size_bytes",
Help: "HTTP response body size in bytes",
Buckets: prometheus.ExponentialBuckets(64, 4, 8),
},
[]string{"method", "route"},
)
ordersCreatedTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "orders_created_total",
Help: "Total orders successfully created, labeled by status",
},
[]string{"status"},
)
cartConversionTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cart_conversion_total",
Help: "Cart checkout outcomes (success, validation_failed, card_declined, stripe_failed, db_failed, payment_critical)",
},
[]string{"outcome"},
)
orderValueDollars = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "order_value_dollars",
Help: "Distribution of completed order totals, in dollars",
Buckets: []float64{1, 5, 10, 20, 30, 50, 75, 100, 150, 250, 500, 1000},
},
)
stripeRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "stripe_request_duration_seconds",
Help: "Latency of outbound Stripe API calls",
Buckets: prometheus.DefBuckets,
},
[]string{"op", "outcome"},
)
authAttemptsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "auth_attempts_total",
Help: "Authentication attempts, by result (success, invalid_secret, missing_fingerprint, user_not_found, registered, error)",
},
[]string{"result"},
)
rateLimitRejectionsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "rate_limit_rejections_total",
Help: "Number of requests rejected by rate limiters",
},
[]string{"scope"},
)
logMessagesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "log_messages_total",
Help: "Total log lines emitted, labeled by level",
},
[]string{"level"},
)
buildInfo = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "terminalshop_build_info",
Help: "Build metadata; value is always 1, version is on the label",
},
[]string{"version"},
)
)
// Metrics records request count, latency, sizes, and concurrency for every request.
func Metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := newResponseWriter(w)
httpInflightRequests.Inc()
defer httpInflightRequests.Dec()
next.ServeHTTP(wrapped, r)
route := chi.RouteContext(r.Context()).RoutePattern()
if route == "" {
route = "unknown"
}
duration := time.Since(start).Seconds()
status := strconv.Itoa(wrapped.statusCode)
httpRequestsTotal.WithLabelValues(r.Method, route, status).Inc()
httpRequestDuration.WithLabelValues(r.Method, route).Observe(duration)
if r.ContentLength > 0 {
httpRequestSize.WithLabelValues(r.Method, route).Observe(float64(r.ContentLength))
}
httpResponseSize.WithLabelValues(r.Method, route).Observe(float64(wrapped.bytesWritten))
})
}
// MetricsHandler returns the Prometheus scrape endpoint.
func MetricsHandler() http.Handler {
return promhttp.Handler()
}
// SetBuildInfo records build metadata as a gauge of value 1.
func SetBuildInfo(version string) {
buildInfo.WithLabelValues(version).Set(1)
}
// RecordOrderCreated records a successfully created order.
func RecordOrderCreated(status string) {
ordersCreatedTotal.WithLabelValues(status).Inc()
}
// RecordCartConversion records the outcome of a checkout attempt.
func RecordCartConversion(outcome string) {
cartConversionTotal.WithLabelValues(outcome).Inc()
}
// ObserveOrderValueCents records the value of a completed order (cents → dollars).
func ObserveOrderValueCents(cents int) {
orderValueDollars.Observe(float64(cents) / 100.0)
}
// ObserveStripeRequest records latency of a Stripe API call.
func ObserveStripeRequest(op, outcome string, durSec float64) {
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)
}
// RecordAuthAttempt records an authentication attempt outcome.
func RecordAuthAttempt(result string) {
authAttemptsTotal.WithLabelValues(result).Inc()
}
// RecordRateLimitRejection records a request blocked by a rate limiter.
func RecordRateLimitRejection(scope string) {
rateLimitRejectionsTotal.WithLabelValues(scope).Inc()
}
// RecordLogLevel increments the per-level log counter.
func RecordLogLevel(level string) {
logMessagesTotal.WithLabelValues(level).Inc()
}

View File

@@ -4,6 +4,8 @@ import (
"net/http"
"time"
"strconv"
"github.com/go-chi/httprate"
"terminalShop/pkg/utils"
)
@@ -14,6 +16,7 @@ func RateLimitByIP(limit int, window time.Duration) func(http.Handler) http.Hand
limit, window,
httprate.WithKeyFuncs(httprate.KeyByIP),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
RecordRateLimitRejection("ip")
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
}),
)
@@ -29,9 +32,10 @@ func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Ha
// Fall back to IP if no user context (shouldn't happen on protected routes).
return httprate.KeyByIP(r)
}
return http.CanonicalHeaderKey("user") + ":" + string(rune(userID)), nil
return "user:" + strconv.FormatUint(uint64(userID), 10), nil
}),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
RecordRateLimitRejection("user")
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
}),
)

View File

@@ -1,7 +1,7 @@
package middleware
import (
"log"
"log/slog"
"net/http"
"runtime/debug"
@@ -13,7 +13,12 @@ func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC: %v\n%s", err, debug.Stack())
slog.Error("panic recovered",
"error", err,
"method", r.Method,
"path", r.URL.Path,
"stack", string(debug.Stack()),
)
utils.RespondError(
w,

View File

@@ -9,6 +9,8 @@ import (
"terminalShop/api/handlers"
"terminalShop/api/middleware"
"terminalShop/pkg/auth"
"github.com/riandyrn/otelchi"
)
// SetupRoutes configures all API routes
@@ -21,14 +23,21 @@ func SetupRoutes(
shippoAPIKey string,
bringAPIUID string,
bringAPIKey string,
bringCustomerNumber string,
shippoWebhookSecret string,
appURL string,
slackSigningSecret string,
adminAPIKey string,
apiPort string,
) *chi.Mux {
r := chi.NewRouter()
// Global middleware
r.Use(otelchi.Middleware("terminalshop-api", otelchi.WithChiRoutes(r)))
r.Use(middleware.Logger)
r.Use(middleware.CORS())
r.Use(middleware.Recovery)
r.Use(middleware.Metrics)
r.Use(middleware.Auth(jwtManager))
// Broad IP-level rate limit: 200 req/min per IP across all endpoints.
r.Use(middleware.RateLimitByIP(200, time.Minute))
@@ -37,29 +46,45 @@ func SetupRoutes(
healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(stripeSecretKey)
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL)
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
orderHandler := handlers.NewOrderHandler(stripeSecretKey)
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey)
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey, shippoWebhookSecret)
slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
// Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect)
r.Get("/t/{id}", handlers.TrackingRedirect)
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
})
r.Get("/post-3ds", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Authentication complete.</h2><p>Return to your terminal.</p></body></html>`))
})
r.Handle("/metrics", middleware.MetricsHandler())
r.Route("/api/v1", func(r chi.Router) {
// Health
// Health — accept HEAD for cheap probes from monitoring tools.
r.Get("/health", healthHandler.GetHealth)
r.Head("/health", healthHandler.GetHealth)
r.Get("/ping", healthHandler.Ping)
r.Head("/ping", healthHandler.Ping)
// Stripe webhooks — unauthenticated, verified via HMAC signature.
// Apply a generous IP rate limit here (Stripe retries from many IPs).
r.With(middleware.RateLimitByIP(60, time.Minute)).
Post("/webhooks/stripe", webhookHandler.HandleStripe)
r.With(middleware.RateLimitByIP(60, time.Minute)).
Post("/webhooks/shippo", webhookHandler.HandleShippo)
// Slack interactivity — unauthenticated at the HTTP layer; security
// comes from HMAC signature verification inside the handler.
r.With(middleware.RateLimitByIP(60, time.Minute)).
Post("/slack/interactivity", slackHandler.HandleInteractivity)
// Auth — stricter IP rate limit to protect against brute-force.
r.Group(func(r chi.Router) {
@@ -105,6 +130,8 @@ func SetupRoutes(
// Orders
r.Get("/orders", orderHandler.GetOrders)
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
// Addresses
@@ -115,6 +142,13 @@ func SetupRoutes(
r.Put("/{id}/default", addressHandler.SetDefaultAddress)
})
})
// Admin endpoints, gated by ADMIN_API_KEY only, no JWT required
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAdmin)
r.Patch("/orders/{id}/tracking", orderHandler.UpdateTracking)
r.Post("/orders/{id}/label", orderHandler.PurchaseLabel)
})
})
return r

View File

@@ -0,0 +1,73 @@
package main
import (
"flag"
"log"
"terminalShop/pkg/config"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentmethod"
)
func main() {
dryRun := flag.Bool("dry-run", false, "list orphans without detaching")
flag.Parse()
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
if _, err := database.Connect(cfg.DatabaseURL); err != nil {
log.Fatalf("db: %v", err)
}
db := database.GetDB()
stripe.Key = cfg.StripeSecretKey
var users []models.User
if err := db.Where("stripe_customer_id != ''").Find(&users).Error; err != nil {
log.Fatalf("list users: %v", err)
}
totalOrphans, totalDetached := 0, 0
for _, user := range users {
var activeCards []models.Card
if err := db.Where("user_id = ?", user.ID).Find(&activeCards).Error; err != nil {
log.Printf("user %d: list cards: %v", user.ID, err)
continue
}
keep := make(map[string]bool, len(activeCards))
for _, card := range activeCards {
keep[card.StripePaymentID] = true
}
iter := paymentmethod.List(&stripe.PaymentMethodListParams{
Customer: stripe.String(user.StripeCustomerID),
Type: stripe.String("card"),
})
for iter.Next() {
pm := iter.PaymentMethod()
if keep[pm.ID] {
continue
}
totalOrphans++
last4 := ""
if pm.Card != nil {
last4 = pm.Card.Last4
}
log.Printf("orphan: user=%d cus=%s pm=%s last4=%s", user.ID, user.StripeCustomerID, pm.ID, last4)
if *dryRun {
continue
}
if _, err := paymentmethod.Detach(pm.ID, nil); err != nil {
log.Printf(" detach failed: %v", err)
continue
}
totalDetached++
}
if err := iter.Err(); err != nil {
log.Printf("user %d: list pms: %v", user.ID, err)
}
}
log.Printf("done. orphans=%d detached=%d dry_run=%v", totalOrphans, totalDetached, *dryRun)
}

25
cog.toml Normal file
View File

@@ -0,0 +1,25 @@
from_latest_tag = false
ignore_merge_commits = false
ignore_fixup_commits = false
disable_changelog = false
disable_bump_commit = false
generate_mono_repository_global_tag = true
generate_mono_repository_package_tags = true
branch_whitelist = []
pre = "alpha.*"
skip_ci = "[skip ci]"
skip_untracked = false
pre_bump_hooks = []
post_bump_hooks = []
pre_package_bump_hooks = []
post_package_bump_hooks = []
[git_hooks]
[commit_types]
[changelog]
path = "CHANGELOG.md"
authors = []
[bump_profiles]

View File

@@ -1,83 +0,0 @@
# Checkout FLow
## The Problemo
Charging a card and recording the order are two different operations accross
two seperate systems (Stripe and the database). If anything fails betweenj
them, you can end up in a state where a customer was charged but no
order exists in the database.
## How It Works
The ´ConvertCart´ handler (´api/handlers/cart.go´) avoids this by following
a specific order of operations:
1. **Create the order first** written to the DB with ´status = "pending"´
and no Stripe ID yet. This means there is always a DB record before any
money moves.
+2. **Charge Stripe** — using an idempotency key of `order-{id}`. If the
request is retried for any reason, Stripe will return the original result
instead of charging twice.
+3. **On charge failure** — the order is marked `status = "failed"` and the
error is returned to the client. The cart is left untouched.
+4. **On charge success** — a single DB transaction atomically:
- Sets `status = "paid"` and writes the Stripe PaymentIntent ID
- Deletes the cart items
- Clears the address and card from the cart
+5. **If the transaction fails after a successful charge** — this is the
worst case. The charge went through but the DB update failed. A
`[CRITICAL]` log line is emitted with the order ID and PaymentIntent ID
for manual reconciliation. See below.
## Reconciliation
If a `[CRITICAL]` log appears, find the order and patch it manually:
```sql
UPDATE orders
SET status = 'paid', stripe_payment_id = '<pi_id_from_log>'
WHERE id = <order_id_from_log>;
```
Then verify the PaymentIntent status in the Stripe dashboard matches.
A pending order with no ´stripe_payment_id´ after more than a few minutes
indicates a failed charge (status should be ´failed´). A pending order
with a ´stripe_payment_id´ indicates a reconciliation gap.
## Testing
Use Stripe´s test mode with the following card numbers (any future expiry,
any CVC, any ZIP):
the folling table is constructed by ["Card number"]["Behaviour"]
4242 4242 4242 4242 - Success
4000 0000 0000 0002 - Generic decline
4000 0000 0000 9995 - Insufficient funds
4000 0000 0000 0069 - Expired card
4000 0000 0000 0127 - Incorrect CVC
### What to verify on success
- Response is ´200´ with an order object
- Order in DB has ´status = "paid"´ and a non-empty ´stripe_payment_id´
- Cart items for that user are deleted
- Cart ´address_id´ and ´card_id´ are cleared
### What to verify on decline
- Response is `402` with `CARD_ERROR`
- Order in DB has `status = "failed"`
- Cart items are untouched
## Known Limitations
A server crash (not an error — a literal process kill) between the Stripe
charge succeeding and the DB transaction committing will leave the order as
`pending` with no `stripe_payment_id`. The `[CRITICAL]` log won't fire
because the process is dead. The fix is a reconciliation job that
periodically queries Stripe for PaymentIntents and cross-checks them
against orders — not implemented yet.

View File

@@ -1,342 +0,0 @@
# Deployment Plan: Docker + VPS + Domain
**Goal:** Get the app running publicly so anyone can connect with `ssh yourdomain.com`
---
## Overview
Reference uses AWS ECS + SST (overkill). We use:
- **Hetzner VPS** (~€4/month) — Linux server with a real public IP
- **Docker Compose** — runs all services, replaces `dev.sh`/Procfile
- **Caddy** — reverse proxy for the API, handles HTTPS automatically (free Let's Encrypt)
- **Cloudflare** — DNS (free tier, fast propagation)
- **Your domain** — e.g. `yourcoffee.shop`
Port layout on the VPS:
- `:22` → SSH TUI server (users connect here)
- `:443` → API (Caddy terminates TLS, proxies to API container on :8080)
- `:80` → Caddy (redirects to HTTPS)
---
## Step 1: Prerequisites
### 1a. Buy a domain
- Recommended: Cloudflare Registrar (cheapest, DNS included, no upsells)
- Or: Namecheap, then point nameservers to Cloudflare
### 1b. Get a VPS
- **Hetzner Cloud** → Create account → New Server
- Location: closest to you (Falkenstein/Helsinki for EU)
- OS: Ubuntu 24.04
- Type: CX22 (2 vCPU, 4GB RAM, €3.79/month) — plenty for this
- Add your SSH public key during setup so you can SSH in
- Note the public IP address
### 1c. DNS (Cloudflare)
Add these A records pointing to your VPS IP:
| Type | Name | Value |
|------|------|-------|
| A | `@` | `<VPS IP>` |
| A | `www` | `<VPS IP>` |
| A | `api` | `<VPS IP>` |
Set proxy status to **DNS only** (grey cloud) — not proxied. Cloudflare proxying breaks raw TCP (SSH).
---
## Step 2: Files to Create
### `Dockerfile.ssh`
Directly based on reference's `packages/go/Dockerfile`. Two-stage build: compile in Go Alpine, run in minimal Alpine.
The reference uses `CGO_ENABLED=0` but our SQLite driver needs CGO. Fix: switch to the pure-Go SQLite driver (`modernc.org/sqlite`) so we can keep `CGO_ENABLED=0` and get tiny images.
```dockerfile
FROM golang:alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o ssh ./main.go
FROM alpine:3.21
WORKDIR /root/
COPY --from=builder /app/ssh .
CMD TERM=xterm-256color ./ssh
```
### `Dockerfile.api`
```dockerfile
FROM golang:alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o api ./api/main.go
FROM alpine:3.21
WORKDIR /root/
COPY --from=builder /app/api .
CMD ["./api"]
```
### `docker-compose.prod.yml`
```yaml
services:
api:
build:
context: .
dockerfile: Dockerfile.api
restart: unless-stopped
env_file: .env.prod
volumes:
- ./data:/data
expose:
- "8080"
ssh:
build:
context: .
dockerfile: Dockerfile.ssh
restart: unless-stopped
env_file: .env.prod
ports:
- "22:22"
volumes:
- ./.ssh:/root/.ssh
depends_on:
- api
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
```
### `Caddyfile`
```
api.yourdomain.com {
reverse_proxy api:8080
}
```
Caddy automatically gets a Let's Encrypt cert for the domain. No manual SSL setup.
### `.env.prod`
```
# API
API_PORT=8080
DATABASE_URL=/data/terminalshop.db
JWT_SECRET=<generate with: openssl rand -hex 32>
AUTH_FINGERPRINT_KEY=<generate with: openssl rand -hex 32>
ENVIRONMENT=production
APP_URL=https://api.yourdomain.com
# Stripe (live keys in production)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLIC_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=<from stripe dashboard>
# Shipping
SHIPPO_API_KEY=...
BRING_API_UID=...
BRING_API_KEY=...
# SSH server
API_URL=http://api:8080
```
Note: `API_URL` uses `api` (the Docker Compose service name) not `localhost` — containers talk to each other by service name.
### `.dockerignore`
```
.git
.env
.env.*
!.env.example
terminal-shop-source/
*.db
.ssh/
docs/
```
---
## Step 3: Code Changes
### 3a. SSH server host binding
`main.go` currently listens on `localhost`. In Docker it must listen on `0.0.0.0` to accept connections from outside the container.
```go
// Change:
host = "localhost"
port = "23456"
// To (read from env):
host = "0.0.0.0"
port = os.Getenv("SSH_PORT") // default "22" in prod, "23456" in dev
```
Or just hardcode `0.0.0.0` and keep port as env var.
### 3b. SSH host key path
Currently hardcoded to `.ssh/term_info_ed25519`. In Docker this needs to be an absolute path or a path relative to the working directory that gets volume-mounted. The volume mount in `docker-compose.prod.yml` handles this — generate the key once on the server and it persists.
Generate on the VPS before first run:
```bash
mkdir -p .ssh
ssh-keygen -t ed25519 -f .ssh/term_info_ed25519 -N ""
```
### 3c. Switch SQLite driver to pure Go
In `pkg/database/db.go`, swap the import:
```go
// Remove:
_ "github.com/mattn/go-sqlite3" // or however it's imported
// Add:
_ "modernc.org/sqlite"
```
And update go.mod:
```bash
go get modernc.org/sqlite
go mod tidy
```
This lets us use `CGO_ENABLED=0` in both Dockerfiles, keeping images small and the build simple.
---
## Step 4: VPS Setup
SSH into your new server:
```bash
ssh root@<VPS IP>
```
Install Docker:
```bash
curl -fsSL https://get.docker.com | sh
```
Clone your repo:
```bash
git clone https://github.com/yourusername/terminalShop.git
cd terminalShop
```
Create data directory and generate SSH host key:
```bash
mkdir -p data .ssh
ssh-keygen -t ed25519 -f .ssh/term_info_ed25519 -N ""
```
Create `.env.prod` with your production secrets (copy template above, fill in real values).
Open firewall:
```bash
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
**Important:** Port 22 is now used by your app. Change admin SSH to a different port first (e.g. 2222) so you don't lock yourself out:
```bash
# In /etc/ssh/sshd_config, change:
Port 2222
systemctl restart sshd
# Open new terminal, verify: ssh root@<VPS IP> -p 2222
# Then: ufw allow 2222/tcp
```
---
## Step 5: First Deploy
On the VPS:
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
Check logs:
```bash
docker compose -f docker-compose.prod.yml logs -f
```
Test:
```bash
ssh yourdomain.com
```
---
## Step 6: Stripe Webhook
In Stripe Dashboard → Developers → Webhooks:
- Add endpoint: `https://api.yourdomain.com/api/v1/webhooks/stripe`
- Copy the signing secret into `.env.prod` as `STRIPE_WEBHOOK_SECRET`
- No more ngrok needed in production
---
## Step 7: Ongoing Deploys
When you push changes:
```bash
# On VPS:
git pull
docker compose -f docker-compose.prod.yml up -d --build
```
Or automate with a GitHub Action that SSHes into the VPS and runs this on every push to main.
---
## Dev vs Prod Summary
| | Dev | Prod |
|--|-----|------|
| Process manager | Goreman + Procfile | Docker Compose |
| SSH port | 23456 | 22 |
| API URL | localhost:8080 | api.yourdomain.com (HTTPS) |
| Database | `terminalshop.db` (local) | `/data/terminalshop.db` (volume) |
| Stripe | ngrok + test keys | real domain + live keys |
| Webhooks | stripe CLI listener | Stripe dashboard endpoint |
---
## TODO After Deployment
- [ ] Switch from SQLite to PostgreSQL (add `postgres` service to compose, update connection string)
- [ ] Set up GitHub Actions for automatic deploy on push to main
- [ ] Add health check endpoint to Docker Compose so containers restart if app hangs
- [ ] Structured logging (write to file + stdout in prod)

View File

@@ -1,93 +0,0 @@
# Gap Analysis: Terminal Coffee Shop vs Reference (terminal.shop)
**Date:** 2026-04-08
---
## What's Already Working
- SSH server with key-based auth + auto user creation
- Shop page with live detail panel
- Cart (add/remove, synced to API)
- Full checkout flow: shipping → payment → confirmation
- Saved addresses and saved cards
- Order history in account view
- FAQ page
- About page
- Splash screen with blinking cursor animation
- Responsive layout (small/medium/large)
- Token refresh
- Full backend API (Chi router, PostgreSQL, GORM, Stripe webhooks)
- Shippo shipping integration
---
## Missing Features (vs reference)
### 1. API Token Management
- **Reference:** Create, view, and revoke personal API tokens
- **Current:** Not implemented
- **Reference file:** `tokens.go`
- **Task:** Add tokens section under account view with list + create/revoke actions
### 2. OAuth App Management
- **Reference:** Create, edit, delete OAuth apps with redirect URIs; uses `huh` form
- **Current:** Not implemented
- **Reference file:** `apps.go`
- **Task:** Add apps section under account view with form-based CRUD
### 3. Keyboard Interactive SSH Auth (Anonymous Sessions)
- **Reference:** Supports keyboard interactive auth in addition to public key, generates UUID fingerprints for anonymous users
- **Current:** Public key auth only (`WithPublicKeyAuth` in `main.go`)
- **Task:** Add `WithKeyboardInteractiveAuth` to Wish options; generate UUID fingerprint for anonymous sessions
---
## Code Quality Issues
### Theme System
- **Reference:** Centralized `theme.Theme` struct with methods (`Base()`, `TextAccent()`, `Border()`, `Brand()`, `Accent()`)
- **Current:** 40+ hardcoded hex colors scattered across view files (e.g. `#4682B4`, `#FFFFFF`, `#666666`)
- **Task:** Create `pkg/tui/theme/theme.go`, extract all colors and styles, update all view files to reference theme
### State Management
- **Reference:** Nested state substruct: `state.shop`, `state.cart`, `state.account`, etc. — each feature owns its state
- **Current:** Flat `Model` struct with 80+ fields, all mixed together
- **Task:** Refactor into nested substructs per feature area (breaking change — do after features are stable)
### Viewport-Based Scrolling
- **Reference:** Uses `charmbracelet/bubbles/viewport` throughout for scrollable content
- **Current:** Manual scroll offset math with hardcoded heights (e.g. `-9` for cart viewport height)
- **Task:** Replace manual scroll math with `viewport.Model` in cart, account, and FAQ views
---
## Architecture Comparison
| Area | Current | Reference |
|------|---------|-----------|
| State management | Flat `Model` struct | Nested `state` substructs per feature |
| Theme/styling | 40+ hardcoded colors | Centralized `theme.Theme` |
| Scrolling | Manual offset math | `viewport.Model` |
| Backend | Full custom API (Chi + GORM) | External SDK (`terminal-sdk-go`) |
| Auth | SSH pubkey + JWT | SSH pubkey + keyboard interactive |
| Product model | Custom `Coffee` struct | SDK `terminal.Product` with variants |
| Forms | `huh` (partial) | `huh` (comprehensive, with huh theme) |
---
## Recommended Order of Work
1. **Fix known bugs** — address delete bug and placeholder item (quick wins, low risk)
2. **Theme system** — extract colors; unblocks all visual polish
3. **Viewport-based scrolling** — replace manual math, fixes responsiveness
4. **API token management** — useful feature, account section
5. **OAuth app management** — lowest priority unless API access is a goal
6. **Keyboard interactive SSH auth** — enables anonymous browsing
7. **Nested state structs** — last, after features are stable (pure refactor)

View File

@@ -1,80 +0,0 @@
# Custom Card Form with Stripe.js
## Why
The reference (terminal.shop) uses their own branded card input page with Stripe.js
instead of Stripe Hosted Checkout. This means:
- Full control over the UI/branding
- No webhooks needed for card saving (token POSTed directly to API)
- No Stripe Checkout Session required
- Cleaner flow, fewer moving parts
## How it would work
1. TUI calls `POST /api/v1/cards/collect` → gets `https://yourdomain.com/pay/TOKEN`
2. User opens URL → sees your own card form (HTML + Stripe.js)
3. Stripe.js tokenizes card in the browser (client-side, no raw card data APIs needed)
4. Page POSTs token + TOKEN to your API: `POST /api/v1/cards/collect/TOKEN`
5. API saves the card to the database
6. TUI polling detects new card and auto-proceeds
## What to build
### 1. HTML page served at `GET /pay/{token}`
Instead of redirecting to Stripe, serve an HTML page with Stripe Elements.
The TOKEN from the URL is used to associate the card with the right user.
```html
<!DOCTYPE html>
<html>
<head>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<form id="card-form">
<div id="card-element"></div>
<button type="submit">Save Card</button>
</form>
<script>
const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');
const elements = stripe.elements();
const card = elements.create('card');
card.mount('#card-element');
document.getElementById('card-form').addEventListener('submit', async (e) => {
e.preventDefault();
const { token } = await stripe.createToken(card);
await fetch('/api/v1/cards/collect/TOKEN_FROM_URL', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.id })
});
window.location.href = '/card-added';
});
</script>
</body>
</html>
```
### 2. New API endpoint: `POST /api/v1/cards/collect/{token}`
- Looks up the user from the token (stored in DB or memory map)
- Calls Stripe to convert token → PaymentMethod
- Attaches to customer
- Saves card to DB
- No webhook needed
### 3. Remove Stripe Checkout Session logic from CollectCard
`CollectCard` just generates a token, stores user association, returns the URL.
No `stripeSession.New()` call needed.
## Notes
- Requires a publicly accessible HTTPS domain (same as current approach)
- The in-memory token→userID map works fine (same TTL concerns as current approach)
- Could remove the `checkout.session.completed` webhook handler entirely
- Would need to remove the Stripe Checkout Session dependency from `cards.go`

View File

@@ -1,225 +0,0 @@
# Theme System Implementation Reference
> Use this document when implementing or modifying the theme system.
> Every decision and pattern is explained so context loss doesn't stall progress.
---
## What We're Doing
Replacing 40+ scattered `lipgloss.Color("#XXXXXX")` calls across 13 TUI files with a
centralized `Theme` struct in `pkg/tui/theme/`. Copied closely from the reference
implementation in `terminal-shop-source/packages/go/pkg/tui/theme/`.
---
## New Files to Create
```
pkg/tui/theme/theme.go — Theme struct, BasicTheme(), all color/style methods
pkg/tui/theme/huh.go — HuhTheme(), copyFieldStyles(), copyTextStyles()
```
---
## Files to Modify (in order)
| Step | File | Type of change |
|------|------|----------------|
| 1 | `pkg/tui/theme/theme.go` | Create |
| 2 | `pkg/tui/theme/huh.go` | Create |
| 3 | `pkg/tui/model.go` | Add `renderer` + `theme` fields; update constructors |
| 4 | `main.go` | Create renderer from SSH session; pass to NewModelWithAuth |
| 5 | `pkg/tui/views.go` | 3 color swaps |
| 6 | `pkg/tui/splash.go` | 2 color swaps |
| 7 | `pkg/tui/header.go` | 4 color swaps |
| 8 | `pkg/tui/footer.go` | 2 color swaps |
| 9 | `pkg/tui/breadcrumbs.go` | 3 color swaps |
| 10 | `pkg/tui/shop.go` | 6 color swaps |
| 11 | `pkg/tui/cart.go` | 9 color swaps |
| 12 | `pkg/tui/account.go` | 14 color swaps |
| 13 | `pkg/tui/menu.go` | 4 color swaps |
| 14 | `pkg/tui/help.go` | 5 color swaps |
| 15 | `pkg/tui/confirmation.go` | 5 color swaps |
| 16 | `pkg/tui/shipping_form.go` | 4 color swaps + `.WithTheme()` |
| 17 | `pkg/tui/payment_form.go` | 5 color swaps + `.WithTheme()` |
---
## Color Mapping (hardcoded → theme)
| Hardcoded | Semantic role | Text style method | Color accessor |
|-----------|--------------|-------------------|----------------|
| `#FFFFFF` | active / selected text | `m.theme.TextAccent()` | `m.theme.Accent()` |
| `#AAAAAA` | body / regular text | `m.theme.TextBody()` | `m.theme.Body()` |
| `#666666` | hints / inactive / dim text | `m.theme.TextDim()` | `m.theme.Dim()` |
| `#666666` | border foreground | — | `m.theme.Border()` |
| `#4682B4` | highlight / brand / steel blue | `m.theme.TextHighlight()` | `m.theme.Highlight()` |
| `#999999` | form label text | `m.theme.TextLabel()` | `m.theme.Label()` |
| `#00FF00` | success / order placed | `m.theme.TextSuccess()` | `m.theme.Success()` |
| `"196"` | error text | `m.theme.TextError()` | `m.theme.Error()` |
| `"205"` | loading / spinner | `m.theme.TextLoading()` | — |
| `"196"` + `"52"` | error banner bg+fg | `m.theme.PanelError()` | — |
| `"C6DDF0"` | user name (light blue) | `m.theme.TextHighlight()` | — |
| `#CCCCCC` | muted subtext | `m.theme.TextBody()` | — |
**Note:** `#666666` is used for BOTH dim text AND border foreground in this project.
Both `Dim()` and `Border()` return the same value. Use `Border()` in `BorderForeground()`,
use `Dim()` / `TextDim()` for text styling.
**Note:** Dynamic product colors (`coffee.Color` = `"#8B4513"` etc.) are NOT replaced.
Keep those as `lipgloss.Color(coffee.Color)`.
---
## Theme Colors (what BasicTheme sets)
```go
background = AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
border = AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
body = Color("#AAAAAA")
dim = Color("#666666")
accent = AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
highlight = Color("#4682B4")
brand = Color("#4682B4") // same as highlight for this project
label = Color("#999999")
success = Color("#00FF00")
error = Color("196")
```
---
## How the Renderer Works
The reference implementation creates a `*lipgloss.Renderer` **per SSH session**:
```go
// main.go — SSH handler
renderer := bubbletea.MakeRenderer(s) // s = ssh.Session
```
This lets lipgloss detect the correct color profile for each client's terminal
(e.g. TrueColor for Ghostty vs 256-color for basic terminals). All style creation
inside the theme uses `renderer.NewStyle()` instead of the global `lipgloss.NewStyle()`.
In this project:
- SSH sessions → renderer from `bubbletea.MakeRenderer(s)`
- Local / test mode → `lipgloss.DefaultRenderer()`
---
## Usage Pattern in Views
All view functions are methods on `Model`, so `m.theme` is always available.
**Text-only style (before → after):**
```go
// before
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Width(18)
// after
m.theme.TextAccent().Bold(true).Width(18)
```
**Border foreground (before → after):**
```go
// before
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666"))
// after
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Border())
```
**Selected item background (before → after):**
```go
// before
lipgloss.NewStyle().Background(lipgloss.Color("#4682B4")).Foreground(lipgloss.Color("#FFFFFF"))
// after
lipgloss.NewStyle().Background(m.theme.Highlight()).Foreground(m.theme.Accent())
```
**`lipgloss.NewStyle()` stays for layout-only styles** (Width, Margin, Padding, Align
with no color). Only color-setting calls move to the theme.
---
## Huh Form Theme
`HuhTheme()` creates a `*huh.Theme` applied to shipping and payment forms.
Applied by chaining `.WithTheme(m.theme.Form())` on the `huh.NewForm(...)` call:
```go
// shipping_form.go and payment_form.go — in buildShippingForm / buildPaymentForm
f := huh.NewForm(...).
WithShowErrors(false).
WithShowHelp(false).
WithTheme(m.theme.Form()) // ← add this line
```
Form theme styles:
- Focused border: thick left border in accent color (`#FFFFFF`)
- Field title + body text: body color (`#AAAAAA`)
- Cursor: highlight color (`#4682B4`)
- Placeholder text: dim color (`#666666`)
- Error: color `"196"`
- Blurred fields: hidden border (same styles, border invisible)
---
## Constructor Changes (model.go)
**Before:** `NewModel(username string)` initializes everything.
`NewModelWithAuth(fingerprint, pubKey, apiURL, secret, stripeKey)` calls NewModel.
**After:** A private `newModelWithRenderer(username, renderer)` does the heavy init.
Both public constructors call it:
```go
func NewModel(username string) Model {
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
}
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint, pubKey, apiURL, secret, stripeKey string) Model {
m := newModelWithRenderer("", renderer)
m.Fingerprint = fingerprint
// ... etc
return m
}
```
`NewModelWithAuth` signature change: **renderer moves to first parameter**.
Update the call in `main.go` accordingly.
---
## Theme Methods Quick Reference
```go
// Color accessors (lipgloss.TerminalColor — use in .Foreground() / .Background())
m.theme.Accent() // #FFFFFF / #11181C adaptive
m.theme.Body() // #AAAAAA
m.theme.Dim() // #666666
m.theme.Border() // #666666 / #D7DBDF adaptive
m.theme.Highlight() // #4682B4
m.theme.Brand() // #4682B4
m.theme.Label() // #999999
m.theme.Success() // #00FF00
m.theme.Error() // "196"
m.theme.Background() // #000000 / #FBFCFD adaptive
// Style helpers (lipgloss.Style — chain .Bold(), .Width(), .Padding() etc.)
m.theme.Base() // base style, body foreground
m.theme.TextAccent() // white text
m.theme.TextBody() // gray text (#AAAAAA)
m.theme.TextDim() // dim text (#666666)
m.theme.TextHighlight() // steel blue text (#4682B4)
m.theme.TextBrand() // same as TextHighlight
m.theme.TextLabel() // label text (#999999)
m.theme.TextSuccess() // green text (#00FF00)
m.theme.TextError() // error text ("196")
m.theme.TextLoading() // loading pink text ("205")
m.theme.PanelError() // error banner: dark red bg + red fg
m.theme.Form() // *huh.Theme for form styling
```

113
go.mod
View File

@@ -1,73 +1,112 @@
module terminalShop
go 1.25.7
go 1.25.9
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.3
charm.land/wish/v2 v2.0.1
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.48.0
github.com/prometheus/client_golang v1.23.2
github.com/riandyrn/otelchi v0.12.2
github.com/stripe/stripe-go/v78 v78.12.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/crypto v0.50.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
gorm.io/plugin/opentelemetry v0.1.16
rsc.io/qr v0.2.0
)
require (
charm.land/log/v2 v2.0.0 // indirect
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect
github.com/charmbracelet/log v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/keygen v0.5.4 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/conpty v0.1.1 // indirect
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/input v0.3.4 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.0 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c // indirect
github.com/go-chi/httprate v0.15.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/stripe/stripe-go/v78 v78.12.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
rsc.io/qr v0.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
)

302
go.sum
View File

@@ -1,61 +1,69 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s=
charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -63,8 +71,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
@@ -73,10 +81,36 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -91,70 +125,190 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/riandyrn/otelchi v0.12.2 h1:6QhGv0LVw/dwjtPd12mnNrl0oEQF4ZAlmHcnlTYbeAg=
github.com/riandyrn/otelchi v0.12.2/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0vSF5yt4=
github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA=
gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

186
loadtest/overnight.js Normal file
View File

@@ -0,0 +1,186 @@
import http from 'k6/http';
const BASE = __ENV.BASE_URL || 'https://api.sshops.uk';
const SECRET = __ENV.CLIENT_SECRET || '';
// Per-VU JWT cache. JWTs are valid for 30 minutes; we refresh ~5 min early.
let cachedToken = null;
let cachedExpiresAt = 0;
let cachedFingerprint = null;
// SSHPublicKey has a uniqueIndex constraint, so each VU needs a distinct
// (fake but unique) public key string. Suffixing with VU+timestamp keeps
// the full text unique without affecting the rest of the body.
let cachedPubkey = null;
function getToken() {
const now = Date.now();
if (cachedToken && now < cachedExpiresAt) return cachedToken;
if (!cachedFingerprint) {
cachedFingerprint = `SHA256:k6-${__VU}-${now.toString(36)}`;
cachedPubkey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoadTest${__VU}-${now.toString(36)} k6@vu${__VU}`;
}
const res = http.post(
`${BASE}/api/v1/auth/token`,
JSON.stringify({
client_secret: SECRET,
fingerprint: cachedFingerprint,
ssh_public_key: cachedPubkey,
}),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_token_refresh' } },
);
if (res.status !== 200) return null;
const body = JSON.parse(res.body);
cachedToken = body.access_token;
cachedExpiresAt = now + (body.expires_in - 300) * 1000;
return cachedToken;
}
function authHeaders() {
const tok = getToken();
return tok ? { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' } : null;
}
// Rate limits we are operating under (per-IP, single source IP):
// global ........... 200 req/min
// /api/v1/auth/* ... 20 req/min (auth/register, auth/user, auth/token)
// /api/v1/webhooks/ 60 req/min
// All rates below are tuned to stay UNDER these ceilings with headroom so
// nothing gets rate-limited and the histogram captures real handler latency.
// Total ~148 req/min combined. Wall time ~10h.
export const options = {
discardResponseBodies: true,
summaryTrendStats: ['avg', 'p(95)', 'p(99)', 'max'],
scenarios: {
browse: {
// /products, /products/{id}, /health, /ping. Only the global 200/min
// limit applies to these. Hold steady at 100/min all night so we never
// get close to the ceiling no matter what other scenarios do.
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1m',
duration: '10h',
preAllocatedVUs: 5,
maxVUs: 20,
exec: 'browse',
},
err_404: {
// /products/{99xxx} — chi route still matches, returns 404. Counts
// against the global 200/min only.
executor: 'constant-arrival-rate',
rate: 30,
timeUnit: '1m',
duration: '10h',
preAllocatedVUs: 3,
maxVUs: 10,
exec: 'notFound',
},
auth_lookup: {
// GET /auth/user — counts against the /auth/* 20/min group ceiling
// SHARED with auth_warn. We split: 10/min lookup + 8/min warn = 18/min.
executor: 'constant-arrival-rate',
rate: 10,
timeUnit: '1m',
duration: '10h',
preAllocatedVUs: 2,
maxVUs: 5,
exec: 'authLookup',
},
auth_warn: {
// POST /auth/token with bad creds — same 20/min group ceiling as above.
executor: 'constant-arrival-rate',
rate: 8,
timeUnit: '1m',
duration: '10h',
preAllocatedVUs: 2,
maxVUs: 5,
exec: 'authBad',
},
},
};
export default function () {
// Smoke-test entry point for `--vus N --duration T` runs.
browse();
authBad();
authLookup();
notFound();
if (SECRET) {
authedBrowse();
convertFail();
addressBad();
}
}
export function browse() {
const r = Math.random();
if (r < 0.6) http.get(`${BASE}/api/v1/products`, { tags: { name: 'list_products' } });
else if (r < 0.8) http.get(`${BASE}/api/v1/products/1`, { tags: { name: 'get_product' } });
else if (r < 0.95) http.get(`${BASE}/api/v1/health`, { tags: { name: 'health' } });
else http.get(`${BASE}/api/v1/ping`, { tags: { name: 'ping' } });
}
export function authedBrowse() {
const h = authHeaders();
if (!h) return;
const r = Math.random();
if (r < 0.4) http.get(`${BASE}/api/v1/cart`, { headers: h, tags: { name: 'authed_get_cart' } });
else if (r < 0.6) http.get(`${BASE}/api/v1/cards`, { headers: h, tags: { name: 'authed_list_cards' } });
else if (r < 0.8) http.get(`${BASE}/api/v1/addresses`, { headers: h, tags: { name: 'authed_list_addresses' } });
else http.get(`${BASE}/api/v1/orders`, { headers: h, tags: { name: 'authed_list_orders' } });
}
export function convertFail() {
const h = authHeaders();
if (!h) return;
// Reset cart, drop one item in, attempt convert. No card/address selected
// → ConvertCart hits validation_missing_address → cart_conversion_total
// outcome=validation_missing_address + audit.OrderFailed warn.
http.del(`${BASE}/api/v1/cart`, null, { headers: h, tags: { name: 'cart_clear' } });
http.put(
`${BASE}/api/v1/cart/item`,
JSON.stringify({ coffee_id: 1, quantity: 1 }),
{ headers: h, tags: { name: 'cart_add_item' } },
);
http.post(`${BASE}/api/v1/cart/convert`, null, { headers: h, tags: { name: 'cart_convert_fail' } });
}
export function addressBad() {
const h = authHeaders();
if (!h) return;
// Junk US zip + street → shippo validation fails → addressLog.Warn.
http.post(
`${BASE}/api/v1/addresses`,
JSON.stringify({
name: 'Load Test',
street: 'asdfasdfasdf',
city: 'Nowhere',
state: 'XX',
zip: '00000',
country: 'US',
phone: '+15555550000',
}),
{ headers: h, tags: { name: 'address_create_bad' } },
);
}
export function authBad() {
http.post(
`${BASE}/api/v1/auth/token`,
JSON.stringify({ client_secret: 'definitely_wrong', fingerprint: 'SHA256:fake' }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_bad_creds' } },
);
}
export function authLookup() {
const fp = `SHA256:${Math.random().toString(36).slice(2, 14)}`;
http.get(`${BASE}/api/v1/auth/user?fingerprint=${fp}`, { tags: { name: 'auth_user_lookup' } });
}
export function notFound() {
const id = 99000 + Math.floor(Math.random() * 1000);
http.get(`${BASE}/api/v1/products/${id}`, { tags: { name: 'product_not_found' } });
}

BIN
main

Binary file not shown.

30
main.go
View File

@@ -1,3 +1,4 @@
// version: 0.1.1
package main
import (
@@ -10,21 +11,17 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"charm.land/wish/v2"
"charm.land/wish/v2/bubbletea"
"charm.land/wish/v2/logging"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"terminalShop/pkg/auth"
"terminalShop/pkg/config"
"terminalShop/pkg/tui"
)
const (
port = "23456"
)
func listenHost() string {
if h := os.Getenv("SSH_HOST"); h != "" {
return h
@@ -32,13 +29,6 @@ func listenHost() string {
return "localhost"
}
func listenPort() string {
if p := os.Getenv("SSH_PORT"); p != "" {
return p
}
return port
}
var (
apiURL string
authFingerprintKey string
@@ -46,9 +36,6 @@ var (
)
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
renderer := bubbletea.MakeRenderer(s)
// Extract SSH public key from session
pubKey := s.PublicKey()
var fingerprint, pubKeyStr string
@@ -57,9 +44,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey)
}
// Create TUI model with user context (no registration screen needed!)
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, []tea.ProgramOption{tea.WithAltScreen()}
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, nil
}
func main() {
@@ -77,7 +63,7 @@ func main() {
// Create SSH server
host := listenHost()
p := listenPort()
p := cfg.SSHPort
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%s", host, p)),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),

View File

@@ -56,11 +56,11 @@ type TokenResponse struct {
}
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
reqBody := TokenRequest{
Fingerprint: fingerprint,
SSHPublicKey: pubKeyStr,
ClientSecret: clientSecret,
}
@@ -79,7 +79,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
if err != nil {
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
@@ -136,7 +136,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch products: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -166,7 +166,7 @@ func (c *Client) Health() error {
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -216,7 +216,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
if err != nil {
return nil, fmt.Errorf("failed to register user: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var registerResp RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil {
@@ -280,14 +280,31 @@ type CardResponse struct {
}
// ConvertCartResponse represents the cart conversion (checkout) API response.
// 200 carries Order populated; 202 (requires_action / 3DS) carries the
// redirect fields and OrderID instead.
type ConvertCartResponse struct {
Success bool `json:"success"`
Data struct {
Order models.Order `json:"order"`
Order *models.Order `json:"order,omitempty"`
OrderID uint `json:"order_id,omitempty"`
PaymentIntentID string `json:"payment_intent_id,omitempty"`
Status string `json:"status,omitempty"`
RedirectToken string `json:"redirect_token,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
// CheckoutOutcome is the unified result of ConvertCart. RequiresAction=true
// means the customer needs to complete 3DS at RedirectURL; poll
// GetOrderStatus(OrderID) until the webhook flips the order to paid or failed.
type CheckoutOutcome struct {
Order *models.Order
OrderID uint
RequiresAction bool
RedirectURL string
}
// GetCart fetches the user's current cart.
func (c *Client) GetCart() (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
@@ -301,7 +318,7 @@ func (c *Client) GetCart() (*CartData, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
@@ -341,7 +358,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
if err != nil {
return nil, fmt.Errorf("failed to set cart item: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
@@ -380,7 +397,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
if err != nil {
return fmt.Errorf("failed to set cart address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -419,7 +436,7 @@ func (c *Client) SetCartCard(cardID uint) error {
if err != nil {
return fmt.Errorf("failed to set cart card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -449,7 +466,7 @@ func (c *Client) ClearCart() error {
if err != nil {
return fmt.Errorf("failed to clear cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -467,7 +484,9 @@ func (c *Client) ClearCart() error {
}
// ConvertCart converts the cart into an order, charging the saved card.
func (c *Client) ConvertCart() (*models.Order, error) {
// Returns a CheckoutOutcome that either carries the completed Order or
// indicates the customer must complete 3DS authentication via RedirectURL.
func (c *Client) ConvertCart() (*CheckoutOutcome, error) {
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
req, err := http.NewRequest("POST", url, nil)
@@ -479,7 +498,7 @@ func (c *Client) ConvertCart() (*models.Order, error) {
if err != nil {
return nil, fmt.Errorf("failed to convert cart: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var convertResp ConvertCartResponse
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
@@ -493,7 +512,55 @@ func (c *Client) ConvertCart() (*models.Order, error) {
return nil, fmt.Errorf("failed to convert cart")
}
return &convertResp.Data.Order, nil
if convertResp.Data.Status == "requires_action" {
return &CheckoutOutcome{
OrderID: convertResp.Data.OrderID,
RequiresAction: true,
RedirectURL: convertResp.Data.RedirectURL,
}, nil
}
if convertResp.Data.Order == nil {
return nil, fmt.Errorf("malformed convert response: missing order")
}
return &CheckoutOutcome{
Order: convertResp.Data.Order,
OrderID: convertResp.Data.Order.ID,
}, nil
}
type OrderStatusResponse struct {
Success bool `json:"success"`
Data struct {
ID uint `json:"id"`
Status string `json:"status"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) GetOrderStatus(id uint) (string, error) {
url := fmt.Sprintf("%s/api/v1/orders/%d/status", c.BaseURL, id)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return "", fmt.Errorf("failed to fetch order status: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var sResp OrderStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
return "", fmt.Errorf("failed to decode order status response: %w", err)
}
if !sResp.Success {
if sResp.Error != nil {
return "", fmt.Errorf("%s: %s", sResp.Error.Code, sResp.Error.Message)
}
return "", fmt.Errorf("failed to fetch order status")
}
return sResp.Data.Status, nil
}
// GetCards fetches all saved cards for the user.
@@ -509,7 +576,7 @@ func (c *Client) GetCards() ([]models.Card, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch cards: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cardsResp CardsResponse
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
@@ -552,7 +619,7 @@ func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) {
if err != nil {
return nil, fmt.Errorf("failed to save card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var cardResp CardResponse
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
@@ -589,11 +656,11 @@ func (c *Client) CollectCard() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to collect card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
return "", fmt.Errorf("failed to devode collect response: %w", err)
return "", fmt.Errorf("failed to decode collect response: %w", err)
}
if !collectResp.Success {
@@ -618,7 +685,7 @@ func (c *Client) DeleteCard(id uint) error {
if err != nil {
return fmt.Errorf("failed to delete card: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
@@ -635,6 +702,20 @@ type OrderResponse struct {
Error *APIError `json:"error,omitempty"`
}
// RefundRequest is the customer-facing refund request payload.
type RefundRequest struct {
Reason string `json:"reason"`
Message string `json:"message"`
}
type refundRequestResponse struct {
Success bool `json:"success"`
Data struct {
Status string `json:"status"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) GetOrders() ([]models.Order, error) {
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
@@ -647,7 +728,7 @@ func (c *Client) GetOrders() ([]models.Order, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch orders: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var ordersResp OrderResponse
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil {
@@ -663,6 +744,42 @@ func (c *Client) GetOrders() ([]models.Order, error) {
return ordersResp.Data.Orders, nil
}
// CreateRefundRequest posts a refund request for an order.
func (c *Client) CreateRefundRequest(orderID uint, params RefundRequest) error {
url := fmt.Sprintf("%s/api/v1/orders/%d/refund-request", c.BaseURL, orderID)
jsonData, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("failed to marshal refund request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create refund request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to send refund request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var refundResp refundRequestResponse
if err := json.NewDecoder(resp.Body).Decode(&refundResp); err != nil {
return fmt.Errorf("failed to decode refund response: %w", err)
}
if !refundResp.Success {
if refundResp.Error != nil {
return fmt.Errorf("%s: %s", refundResp.Error.Code, refundResp.Error.Message)
}
return fmt.Errorf("failed to send refund request")
}
return nil
}
// The Addresses API response
type AddressResponse struct {
Success bool `json:"success"`
@@ -685,7 +802,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) {
return nil, fmt.Errorf("failed to fetch addresses: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var addrResp AddressResponse
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
@@ -731,7 +848,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e
if err != nil {
return nil, fmt.Errorf("failed to create address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
var result struct {
Success bool `json:"success"`
@@ -768,7 +885,7 @@ func (c *Client) DeleteAddress(id uint) error {
if err != nil {
return fmt.Errorf("failed to delete address: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
@@ -803,7 +920,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) {
if err != nil {
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
}

View File

@@ -1,124 +1,314 @@
// Package audit writes structured JSON logs for financial events.
// Package audit writes structured logs for financial events.
// Every card addition, order creation, payment, and refund is recorded here.
// Pipe this output to your log aggregator (Datadog, Loki, CloudWatch, etc.).
package audit
import (
"encoding/json"
"log"
"time"
"fmt"
"log/slog"
"terminalShop/pkg/models"
"gorm.io/gorm"
)
type eventType string
const (
eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted"
eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid"
eventOrderFailed eventType = "order_failed"
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted"
eventOrderCreated eventType = "order_created"
eventOrderPaid eventType = "order_paid"
eventOrderShipped eventType = "order_shipped"
eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = "order_requires_action"
eventOrderRefunded eventType = "order_refunded"
eventPaymentCritical eventType = "payment_critical"
eventLabelPurchased eventType = "label_purchased"
eventLabelOrphaned eventType = "label_orphaned"
eventTrackingUpdated eventType = "tracking_updated"
eventOrderDelivered eventType = "order_delivered"
eventTrackingMarkedManually eventType = "tracking_marked_manually"
)
type event struct {
Time time.Time `json:"time"`
Event eventType `json:"event"`
UserID uint `json:"user_id,omitempty"`
OrderID uint `json:"order_id,omitempty"`
CardID uint `json:"card_id,omitempty"`
Amount int `json:"amount_cents,omitempty"`
StripeID string `json:"stripe_id,omitempty"`
CardLast4 string `json:"card_last4,omitempty"`
CardBrand string `json:"card_brand,omitempty"`
Error string `json:"error,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
Event eventType
UserID uint
OrderID uint
CardID uint
Amount int
StripeID string
CardLast4 string
CardBrand string
Carrier string
Tracking string
Status string
Error string
Actor string
}
func emit(e event) {
e.Time = time.Now().UTC()
b, err := json.Marshal(e)
if err != nil {
log.Printf("[audit] failed to marshal event: %v", err)
return
func (e event) attrs() []any {
a := []any{"event", string(e.Event)}
if e.UserID != 0 {
a = append(a, "user_id", e.UserID)
}
log.Printf("[AUDIT] %s", b)
if e.OrderID != 0 {
a = append(a, "order_id", e.OrderID)
}
if e.CardID != 0 {
a = append(a, "card_id", e.CardID)
}
if e.Amount != 0 {
a = append(a, "amount_cents", e.Amount)
}
if e.StripeID != "" {
a = append(a, "stripe_id", e.StripeID)
}
if e.CardLast4 != "" {
a = append(a, "card_last4", e.CardLast4)
}
if e.CardBrand != "" {
a = append(a, "card_brand", e.CardBrand)
}
if e.Carrier != "" {
a = append(a, "carrier", e.Carrier)
}
if e.Tracking != "" {
a = append(a, "tracking", e.Tracking)
}
if e.Status != "" {
a = append(a, "status", e.Status)
}
if e.Error != "" {
a = append(a, "error", e.Error)
}
if e.Actor != "" {
a = append(a, "actor", e.Actor)
}
return a
}
// CardAdded records that a user saved a new payment method.
func CardAdded(userID, cardID uint, brand, last4 string) {
emit(event{
e := event{
Event: eventCardAdded,
UserID: userID,
CardID: cardID,
CardBrand: brand,
CardLast4: last4,
})
}
slog.Info("audit", e.attrs()...)
persist(0, eventCardAdded, e)
}
// CardDeleted records that a user removed a saved payment method.
func CardDeleted(userID, cardID uint) {
emit(event{
e := event{
Event: eventCardDeleted,
UserID: userID,
CardID: cardID,
})
}
slog.Info("audit", e.attrs()...)
persist(0, eventCardDeleted, e)
}
// OrderCreated records that an order record was created (before payment).
func OrderCreated(userID, orderID uint, amountCents int) {
emit(event{
e := event{
Event: eventOrderCreated,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
})
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderCreated, e)
}
// OrderPaid records a successful charge.
func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID string) {
emit(event{
e := event{
Event: eventOrderPaid,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
StripeID: stripePaymentIntentID,
})
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderPaid, e)
}
// OrderFailed records a failed charge attempt.
// OrderShipped records that an order has been marked as shipped with carrier metadata
func OrderShipped(userID, orderID uint, carrier, trackingNumber string) {
e := event{
Event: eventOrderShipped,
UserID: userID,
OrderID: orderID,
Carrier: carrier,
Tracking: trackingNumber,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderShipped, e)
}
// OrderFailed records a failed charge attempt. Logged at WARN.
func OrderFailed(userID, orderID uint, amountCents int, reason string) {
emit(event{
e := event{
Event: eventOrderFailed,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
Error: reason,
})
}
slog.Warn("audit", e.attrs()...)
persist(orderID, eventOrderFailed, e)
}
// OrderRefunded records a refund.
func OrderRefunded(userID, orderID uint, amountCents int, stripeRefundID string) {
emit(event{
e := event{
Event: eventOrderRefunded,
UserID: userID,
OrderID: orderID,
Amount: amountCents,
StripeID: stripeRefundID,
})
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRefunded, e)
}
// PaymentCritical records the dangerous state where Stripe charged the card
// but the local database transaction failed. This MUST be investigated and
// manually reconciled. Emit a loud log line that monitoring can alert on.
// manually reconciled. Logged at ERROR so monitoring can alert on it.
func PaymentCritical(orderID uint, stripePaymentIntentID string, err error) {
emit(event{
e := event{
Event: eventPaymentCritical,
OrderID: orderID,
StripeID: stripePaymentIntentID,
Error: err.Error(),
})
// Second line so it's impossible to miss in plain-text log tails.
log.Printf("[AUDIT][CRITICAL] order %d was charged (pi=%s) but DB commit failed: %v — REQUIRES MANUAL RECONCILIATION",
orderID, stripePaymentIntentID, err)
}
slog.Error("audit", e.attrs()...)
persist(orderID, eventPaymentCritical, e)
}
func LabelPurchased(orderID uint, carrier, trackingNumber, shippoTxID string, costCents int) {
e := event{
Event: eventLabelPurchased,
OrderID: orderID,
Carrier: carrier,
Tracking: trackingNumber,
StripeID: shippoTxID,
Amount: costCents,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventLabelPurchased, e)
}
func LabelOrphaned(orderID uint, shippoTxID, trackingNumber string, err error) {
e := event{
Event: eventLabelOrphaned,
OrderID: orderID,
StripeID: shippoTxID,
Tracking: trackingNumber,
Error: err.Error(),
}
slog.Error("audit", e.attrs()...)
persist(orderID, eventLabelOrphaned, e)
}
func TrackingUpdated(orderID uint, carrier, trackingNumber, status string) {
e := event{
Event: eventTrackingUpdated,
OrderID: orderID,
Carrier: carrier,
Tracking: trackingNumber,
Status: status,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventTrackingUpdated, e)
}
func OrderDelivered(orderID uint, trackingNumber string) {
e := event{
Event: eventOrderDelivered,
OrderID: orderID,
Tracking: trackingNumber,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderDelivered, e)
}
// TrackingMarkedManually records operator made tracking status mark
// from the slack interactivity surface. Actor is the slack username that
// clicked the button
func TrackingMarkedManually(orderID uint, status, actor string) {
e := event{
Event: eventTrackingMarkedManually,
OrderID: orderID,
Status: status,
Actor: actor,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventTrackingMarkedManually, e)
}
// db is the optional persistence target. nil = slog-only.
// set once at startup with SetDB, funcs stay slog-primary regardless.
var db *gorm.DB
// SetDB wires the audit package to a database for persistent event rows.
// call once after database.Migrate. Audit funcs still write slog when
// db is nil, so tests don't need to wire this.
func SetDB(d *gorm.DB) { db = d }
func persist(orderID uint, t eventType, e event) {
if db == nil || orderID == 0 {
return
}
actor := e.Actor
if actor == "" {
if e.UserID > 0 {
actor = fmt.Sprintf("user:%d", e.UserID)
} else {
actor = "system"
}
}
payload, err := json.Marshal(attrsToMap(e.attrs()))
if err != nil {
slog.Error("audit persist marshal", "error", err, "event", string(t))
return
}
row := &models.OrderEvent{
OrderID: orderID,
Type: string(t),
Payload: string(payload),
Actor: actor,
}
if err := db.Create(row).Error; err != nil {
slog.Error("audit persist write", "error", err, "event", string(t), "order_id", orderID)
}
}
func attrsToMap(a []any) map[string]any {
m := make(map[string]any, len(a)/2)
for i := 0; i+1 < len(a); i += 2 {
k, ok := a[i].(string)
if !ok {
continue
}
m[k] = a[i+1]
}
return m
}
//nolint:unused // called from ConvertCart in the next update
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrderRequiresAction,
UserID: userID,
OrderID: orderID,
StripeID: paymentIntentID,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRequiresAction, e)
}

View File

@@ -17,7 +17,7 @@ import (
func setupAuthTestDB(t *testing.T) string {
t.Helper()
testDB := "test_ssh_auth.db"
os.Remove(testDB)
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
@@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey {
func TestAuthenticateSSHKeyNewUser(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) {
func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
func TestRegisterWithSSHKey(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestIsSSHKeyRegistered(t *testing.T) {
testDB := setupAuthTestDB(t)
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
@@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) {
}
// After authentication (which auto-creates)
svc.AuthenticateSSHKey(key)
_, _, _ = svc.AuthenticateSSHKey(key)
registered, err = svc.IsSSHKeyRegistered(key)
if err != nil {

253
pkg/bring/booking.go Normal file
View File

@@ -0,0 +1,253 @@
package bring
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"terminalShop/pkg/shipping"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
const bookingBaseURL = "https://api.bring.com/booking-api/api/booking"
type BookingClient struct {
apiUID string
apiKey string
customerNumber string
testMode bool
httpClient *http.Client
}
func NewBookingClient(apiUID string, apiKey string, customerNumber string, testMode bool) *BookingClient {
return &BookingClient{
apiUID: apiUID,
apiKey: apiKey,
customerNumber: customerNumber,
testMode: testMode,
httpClient: &http.Client{
Timeout: 20 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport),
},
}
}
type BookingAddress struct {
Name string
Street1 string
Street2 string
City string
Country string
Zip string
Phone string
Email string
ContactName string
}
type LineItem struct {
Title string
Quantity int
WeightKg float64
}
func (c *BookingClient) CreateLabel(ctx context.Context, to BookingAddress, email string, items []LineItem) (*shipping.LabelResult, error) {
from := fromAddressNO()
if from.Street1 == "" {
return nil, fmt.Errorf("SHIP_FROM_NO_STREET1 not configured")
}
if c.customerNumber == "" {
return nil, fmt.Errorf("BRING_CUSTOMER_NUMBER not configured")
}
totalKg := 0.0
for _, item := range items {
totalKg += float64(item.Quantity) * item.WeightKg
}
if totalKg <= 0 {
return nil, fmt.Errorf("order has zero shipping weight")
}
oslo, err := time.LoadLocation("Europe/Oslo")
if err != nil {
oslo = time.UTC
}
shippingAt := time.Now().In(oslo).Add(1 * time.Hour).Format("2006-01-02T15:04:05-07:00")
payload := map[string]any{
"testIndicator": c.testMode,
"schemaVersion": 1,
"consignments": []map[string]any{{
"shippingDateTime": shippingAt,
"parties": map[string]any{
"sender": partyPayload(from, ""),
"recipient": partyPayload(to, email),
},
"product": map[string]any{
"id": "SERVICEPAKKE",
"customerNumber": c.customerNumber,
},
"packages": []map[string]any{{
"weightInKg": roundN(totalKg, 3),
"goodsDescription": "Roasted coffee beans",
"dimensions": map[string]any{
"heightInCm": 30,
"widthInCm": 30,
"lengthInCm": 30,
},
}},
}},
}
var resp bookingResponse
if err := c.doJSON(ctx, bookingBaseURL, payload, &resp); err != nil {
return nil, err
}
if len(resp.Consignments) == 0 {
return nil, fmt.Errorf("bring returned no consignments")
}
c0 := resp.Consignments[0]
if c0.Confirmation == nil {
return nil, fmt.Errorf("bring booking failed: %s", firstError(c0.Errors))
}
confirmation := c0.Confirmation
if len(confirmation.Packages) == 0 || confirmation.Packages[0].TrackingNumber == "" {
return nil, fmt.Errorf("bring confirmation missing tracking number")
}
labelURL := ""
if confirmation.Links != nil {
labelURL = confirmation.Links.Labels
}
tracking := confirmation.Packages[0].TrackingNumber
trackingURL := fmt.Sprintf("https://sporing.posten.no/sporing/%s", tracking)
return &shipping.LabelResult{
TransactionID: confirmation.ConsignmentNumber,
Carrier: "BRING",
ServiceLevel: "SERVICEPAKKE",
TrackingNumber: tracking,
TrackingURL: trackingURL,
LabelURL: labelURL,
CostCents: 0,
}, nil
}
type bookingResponse struct {
Consignments []struct {
Confirmation *struct {
ConsignmentNumber string `json:"consignmentNumber"`
Links *struct {
Labels string `json:"labels"`
} `json:"links"`
Packages []struct {
CorrelationID string `json:"correlationId"`
TrackingNumber string `json:"trackingNumber"`
} `json:"packages"`
} `json:"confirmation"`
Errors []struct {
Messages []struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"messages"`
} `json:"errors"`
} `json:"consignments"`
}
func firstError(errs []struct {
Messages []struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"messages"`
}) string {
if len(errs) == 0 || len(errs[0].Messages) == 0 {
return "no detail"
}
return errs[0].Messages[0].Message
}
func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Mybring-API-Uid", c.apiUID)
req.Header.Set("X-Mybring-API-Key", c.apiKey)
req.Header.Set("X-Mybring-API-Customer-Number", c.customerNumber)
clientURL := os.Getenv("BRING_CLIENT_URL")
if clientURL == "" {
clientURL = "https://sshops.uk"
}
req.Header.Set("X-Bring-Client-URL", clientURL)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("bring booking %d: %s", resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("parse response: %w", err)
}
return nil
}
func roundN(v float64, n int) float64 {
pow := 1.0
for i := 0; i < n; i++ {
pow *= 10
}
if v >= 0 {
return float64(int64(v*pow+0.5)) / pow
}
return float64(int64(v*pow-0.5)) / pow
}
func partyPayload(a BookingAddress, email string) map[string]any {
m := map[string]any{
"name": a.Name,
"addressLine": a.Street1,
"postalCode": a.Zip,
"city": a.City,
"countryCode": a.Country,
}
if a.Street2 != "" {
m["addressLine2"] = a.Street2
}
if email != "" || a.Phone != "" {
m["contact"] = map[string]any{"name": a.Name, "email": email, "phoneNumber": a.Phone}
}
return m
}
func fromAddressNO() BookingAddress {
return BookingAddress{
Name: os.Getenv("SHIP_FROM_NO_NAME"),
Street1: os.Getenv("SHIP_FROM_NO_STREET1"),
Street2: os.Getenv("SHIP_FROM_NO_STREET2"),
City: os.Getenv("SHIP_FROM_NO_CITY"),
Country: os.Getenv("SHIP_FROM_NO_COUNTRY"),
Zip: os.Getenv("SHIP_FROM_NO_ZIP"),
Phone: os.Getenv("SHIP_FROM_NO_PHONE"),
Email: os.Getenv("SHIP_FROM_NO_EMAIL"),
}
}

View File

@@ -1,6 +1,7 @@
package bring
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -9,6 +10,8 @@ import (
"regexp"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
const baseURL = "https://api.bring.com/address"
@@ -26,7 +29,8 @@ func NewClient(apiUID, apiKey string) *Client {
apiUID: apiUID,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
Timeout: 10 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport),
},
}
}
@@ -42,7 +46,6 @@ type Address struct {
Phone string
}
type validationMatch struct {
StreetName string `json:"street_name"`
HouseNumber int `json:"house_number"`
@@ -80,7 +83,7 @@ func parseStreet(street string) (name string, number string) {
return strings.TrimSpace(street), ""
}
func (c *Client) ValidateAddress(addr Address) (*Address, error) {
func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, error) {
streetName, streetNumber := parseStreet(addr.Street1)
params := url.Values{}
@@ -94,7 +97,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
reqURL := fmt.Sprintf("%s/api/no/validation?%s", baseURL, params.Encode())
req, err := http.NewRequest("GET", reqURL, nil)
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -107,7 +110,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
if err != nil {
return nil, fmt.Errorf("bring request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {

View File

@@ -12,20 +12,24 @@ import (
// Config holds all application configuration
type Config struct {
APIPort string
APIURL string
AppURL string
SSHPort string
Environment string
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
StripeWebhookSecret string
ShippoAPIKey string
AuthFingerprintKey string
BringAPIUID string
BringAPIKey string
APIPort string
APIURL string
AppURL string
SSHPort string
Environment string
DatabaseURL string
JWTSecret string
StripeSecretKey string
StripePublicKey string
StripeWebhookSecret string
ShippoAPIKey string
AuthFingerprintKey string
BringAPIUID string
BringAPIKey string
BringCustomerNumber string
ShippoWebhookSecret string
SlackSigningSecret string
AdminAPIKey string
}
// Load reads configuration from environment variables and validates required secrets.
@@ -58,6 +62,10 @@ func Load() (*Config, error) {
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"),
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
}
if err := cfg.validate(); err != nil {

View File

@@ -11,6 +11,8 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/plugin/opentelemetry/tracing"
)
var (
@@ -42,6 +44,14 @@ func Connect(dsn string) (*gorm.DB, error) {
return
}
if pluginErr := db.Use(tracing.NewPlugin(
tracing.WithoutMetrics(),
tracing.WithoutQueryVariables(),
)); pluginErr != nil {
err = fmt.Errorf("failed to register otel plugin: %w", pluginErr)
return
}
// Configure connection pool for PostgreSQL.
if isPostgres {
sqlDB, poolErr := db.DB()
@@ -91,7 +101,7 @@ func Close() error {
// WARNING: Only use this in tests!
func ResetForTesting() {
if db != nil {
Close()
_ = Close()
db = nil
once = sync.Once{}
}

View File

@@ -10,7 +10,7 @@ import (
func TestConnect(t *testing.T) {
// Use a temporary test database
testDB := "test_terminalshop.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -35,7 +35,7 @@ func TestConnect(t *testing.T) {
func TestMigrate(t *testing.T) {
testDB := "test_migrate.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -57,7 +57,7 @@ func TestMigrate(t *testing.T) {
func TestSeed(t *testing.T) {
testDB := "test_seed.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
@@ -115,7 +115,7 @@ func TestSeed(t *testing.T) {
func TestGetDB(t *testing.T) {
testDB := "test_getdb.db"
defer os.Remove(testDB)
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
// Initialize connection

View File

@@ -4,8 +4,9 @@ import (
"fmt"
"log"
"gorm.io/gorm"
"terminalShop/pkg/models"
"gorm.io/gorm"
)
// Migrate runs database migrations
@@ -15,14 +16,16 @@ func Migrate(db *gorm.DB) error {
// AutoMigrate will create tables, missing columns, and indexes
// It will NOT delete unused columns or change column types
if err := db.AutoMigrate(
&models.Cart{}, // Server-side shipping cart
&models.CartItem{}, // Cart line items
&models.Coffee{},
&models.Cart{}, // Server-side shopping cart
&models.CartItem{}, // Cart line items
&models.Coffee{}, // Coffees
&models.User{}, // SSH key-based authentication
&models.Card{}, // Saved payment methods
&models.Order{}, // Completed purchases
&models.OrderItem{}, // Line items within orders
&models.Address{}, // Saved addresses
&models.OrderEvent{},
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
); err != nil {
return fmt.Errorf("migration failed: %w", err)
}

View File

@@ -11,7 +11,7 @@ type Cart struct {
CardID *uint `json:"card_id,omitempty"`
ShippingCost int `gorm:"default:0" json:"shipping_cost"`
ShippingService string `gorm:"size:255" json:"shipping_service,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
User User `gorm:"foreignKey:UserID" json:"-"`

View File

@@ -26,9 +26,12 @@ func (Coffee) TableName() string {
return "coffees"
}
// AccountMenuItems
// AccountMenuItems
var AccountMenuItems = []string{
"active orders",
"order history",
"addresses",
"cards",
"faq",
"about",
}

View File

@@ -19,32 +19,66 @@ const (
OrderStatusFailed OrderStatus = "failed"
)
type TrackingStatus string
const (
TrackingStatusUnknown TrackingStatus = ""
TrackingStatusPreTransit TrackingStatus = "pre_transit"
TrackingStatusTransit TrackingStatus = "transit"
TrackingStatusDelivered TrackingStatus = "delivered"
TrackingStatusReturned TrackingStatus = "returned"
TrackingStatusFailure TrackingStatus = "failure"
)
// Order represents a completed purchase.
type Order struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
CardID uint `gorm:"not null" json:"card_id"`
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents
Total int `gorm:"not null" json:"total"` // Amount in cents
ShippingName string `gorm:"size:255" json:"shipping_name"`
ShippingStreet string `gorm:"size:255" json:"shipping_street"`
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"`
ShippingCity string `gorm:"size:255" json:"shipping_city"`
ShippingState string `gorm:"size:50" json:"shipping_state"`
ShippingZip string `gorm:"size:20" json:"shipping_zip"`
ShippingCountry string `gorm:"size:2" json:"shipping_country"`
ShippingPhone string `gorm:"size:20" json:"shipping_phone"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
CardID uint `gorm:"not null" json:"card_id"`
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents
Total int `gorm:"not null" json:"total"` // Amount in cents
ShippingName string `gorm:"size:255" json:"shipping_name"` // The name of the person the shipment is tied to
ShippingStreet string `gorm:"size:255" json:"shipping_street"` // The street address the shipment is tied to
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` // The alternate street address the shipment is going to if first is valid for whatever reason
ShippingCity string `gorm:"size:255" json:"shipping_city"` // The city the shipment is going to
ShippingState string `gorm:"size:50" json:"shipping_state"` // The state the shipment is tied to
ShippingZip string `gorm:"size:20" json:"shipping_zip"` // The zip code the order is tied to
ShippingCountry string `gorm:"size:2" json:"shipping_country"` // The country the shipment is tied to
ShippingPhone string `gorm:"size:20" json:"shipping_phone"` // Phone number the shipment is tied to
// Shipment / tracking - single embedded blob (since its just one warehouse (my house))
Carrier string `gorm:"size:100" json:"carrier"`
TrackingNumber string `gorm:"size:255;index" json:"tracking_number"`
TrackingURL string `gorm:"size:512" json:"tracking_url"`
ShippedAt *time.Time `json:"shipped_at"`
TrackingStatus TrackingStatus `gorm:"size:50" json:"tracking_status"`
TrackingStatusDetails string `gorm:"size:512" json:"tracking_status_details"`
TrackingStatusUpdatedAt *time.Time `json:"tracking_status_updated_at"`
// Shippo label shit, populated by the PurchaseLabel handler
ShippoTransactionID *string `gorm:"size:255;uniqueIndex" json:"-"`
LabelURL string `gorm:"size:512" json:"-"`
LabelCostCents int `json:"-"`
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
SlackThreadTS *string `gorm:"size:64" json:"-"`
// LastRefundRequestAt timestamps the most recent customer-initiated refund
// request for this order. Used to rate-limit repeat submissions.
LastRefundRequestAt *time.Time `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // GORM soft-delete sentinel
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
}
// TableName specifies the table name for the Order model.
@@ -77,3 +111,41 @@ func (OrderItem) TableName() string {
func (o *Order) TotalInDollars() float64 {
return float64(o.Total) / 100
}
// IsActive returns whether the order is still active, which is paid but unfulfilled,
// or shipped but not yet delivered.
func (o *Order) IsActive() bool {
return o.Status == OrderStatusPaid || o.Status == OrderStatusShipped
}
// RefundRequestReasons is the canonical list of customer-facing refund reasons
// accepted by both the API and the TUI.
var RefundRequestReasons = []string{
"Wrong item received",
"Item damaged in transit",
"Item never arrived",
"Quality issue",
"Other",
}
// RefundRequestReasonOther is the sentinel reason that requires a free-form message.
const RefundRequestReasonOther = "Other"
// IsValidRefundReason reports whether r is one of the accepted refund reasons.
func IsValidRefundReason(r string) bool {
for _, reason := range RefundRequestReasons {
if reason == r {
return true
}
}
return false
}
// CanRequestRefund reports whether a customer is allowed to request a refund
// for this order. Paid, shipped, and delivered orders qualify; everything else
// is either still unpaid or already in a terminal state.
func (o *Order) CanRequestRefund() bool {
return o.Status == OrderStatusPaid ||
o.Status == OrderStatusShipped ||
o.Status == OrderStatusDelivered
}

View File

@@ -0,0 +1,96 @@
package models
// DisplayKind is a ui agnostic color/severity token.
// The TUI (or any other renderer) maps this to a concrete style
// the model stays free of UI deps.
type DisplayKind string
const (
DisplayKindNeutral DisplayKind = "neutral" // pending, default
DisplayKindAccent DisplayKind = "accent" // paid, awaiting fulfillment
DisplayKindBrand DisplayKind = "brand" // shipped / in transit
DisplayKindSuccess DisplayKind = "success" // delivered
DisplayKindError DisplayKind = "error" // cancelled, failed, refunded
DisplayKindWarning DisplayKind = "warning" // delivery issue, returned
DisplayKindRefund DisplayKind = "refund" // refund requested, awaiting review
)
// refundPending reports whether the order has an open customer refund request
// that has not yet been resolved by a terminal status change.
func (order *Order) refundPending() bool {
if order.LastRefundRequestAt == nil {
return false
}
switch order.Status {
case OrderStatusRefunded, OrderStatusCancelled, OrderStatusFailed:
return false
}
return true
}
func (order *Order) DisplayState() string {
if order.refundPending() {
return "Refund Requested"
}
switch order.Status {
case OrderStatusPending:
return "Awaiting payment"
case OrderStatusPaid:
if order.TrackingNumber == "" {
return "Paid * awaiting label"
}
// !IMPORTANT
// Reachable only if UpdateTracking auto-promote is removed later
if order.TrackingStatus == TrackingStatusPreTransit {
return "Paid * ready to ship"
}
return "Paid"
case OrderStatusShipped:
switch order.TrackingStatus {
case TrackingStatusTransit:
return "In Transit"
case TrackingStatusFailure:
return "Delivery Issue"
case TrackingStatusReturned:
return "Returned to Sender"
case TrackingStatusDelivered:
// Webhook hasn't promoted Status yet, trust tracking
return "Delivered"
}
return "Label Created"
case OrderStatusDelivered:
return "Delivered"
case OrderStatusCancelled:
return "Cancelled"
case OrderStatusRefunded:
return "Refunded"
case OrderStatusFailed:
return "Payment failed"
}
return string(order.Status)
}
func (order *Order) DisplayKind() DisplayKind {
if order.refundPending() {
return DisplayKindRefund
}
switch order.Status {
case OrderStatusPending:
return DisplayKindNeutral
case OrderStatusPaid:
return DisplayKindAccent
case OrderStatusShipped:
switch order.TrackingStatus {
case TrackingStatusFailure, TrackingStatusReturned:
return DisplayKindWarning
case TrackingStatusDelivered:
return DisplayKindSuccess
}
return DisplayKindBrand
case OrderStatusDelivered:
return DisplayKindSuccess
case OrderStatusCancelled, OrderStatusRefunded, OrderStatusFailed:
return DisplayKindError
}
return DisplayKindNeutral
}

View File

@@ -0,0 +1,45 @@
package models
import (
"testing"
"time"
)
func TestOrderDisplayState(t *testing.T) {
now := time.Now()
tests := []struct {
name string
order Order
want string
kind DisplayKind
}{
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},
{"shipped no tracking event", Order{Status: OrderStatusShipped}, "Label Created", DisplayKindBrand},
{"shipped pre-transit", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusPreTransit}, "Label Created", DisplayKindBrand},
{"shipped in transit", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusTransit}, "In Transit", DisplayKindBrand},
{"shipped failure", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusFailure}, "Delivery Issue", DisplayKindWarning},
{"shipped returned", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusReturned}, "Returned to Sender", DisplayKindWarning},
{"shipped tracking-delivered (webhook lag)", Order{Status: OrderStatusShipped, TrackingStatus: TrackingStatusDelivered}, "Delivered", DisplayKindSuccess},
{"delivered", Order{Status: OrderStatusDelivered}, "Delivered", DisplayKindSuccess},
{"cancelled", Order{Status: OrderStatusCancelled}, "Cancelled", DisplayKindError},
{"refunded", Order{Status: OrderStatusRefunded}, "Refunded", DisplayKindError},
{"failed", Order{Status: OrderStatusFailed}, "Payment failed", DisplayKindError},
{"paid with open refund request", Order{Status: OrderStatusPaid, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
{"delivered with open refund request", Order{Status: OrderStatusDelivered, LastRefundRequestAt: &now}, "Refund Requested", DisplayKindRefund},
{"refunded order ignores prior request", Order{Status: OrderStatusRefunded, LastRefundRequestAt: &now}, "Refunded", DisplayKindError},
{"cancelled order ignores prior request", Order{Status: OrderStatusCancelled, LastRefundRequestAt: &now}, "Cancelled", DisplayKindError},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.order.DisplayState(); got != tc.want {
t.Errorf("DisplayState() = %q, want %q", got, tc.want)
}
if got := tc.order.DisplayKind(); got != tc.kind {
t.Errorf("DisplayKind() = %q, want %q", got, tc.kind)
}
})
}
}

63
pkg/models/order_event.go Normal file
View File

@@ -0,0 +1,63 @@
package models
import (
"encoding/json"
"time"
)
type OrderEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
OrderID uint `gorm:"not null;index" json:"order_id"`
Type string `gorm:"size:100;not null;index" json:"type"`
Payload string `gorm:"type:text" json:"payload"`
Actor string `gorm:"size:255" json:"actor"`
CreatedAt time.Time `json:"created_at"`
}
func (OrderEvent) TableName() string {
return "order_events"
}
func (e *OrderEvent) PayloadMap() map[string]any {
if e.Payload == "" {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(e.Payload), &m); err != nil {
return nil
}
return m
}
func (e *OrderEvent) DisplayLabel() (string, bool) {
switch e.Type {
case "order_paid":
return "Paid", true
case "label_purchased":
return "Label created", true
case "order_delivered":
return "Delivered", true
case "order_refunded":
return "Refunded", true
case "order_failed":
return "Payment failed", true
case "tracking_updated":
status, _ := e.PayloadMap()["status"].(string)
return trackingLabel(status)
}
return "", false
}
func trackingLabel(status string) (string, bool) {
switch status {
case "transit":
return "In transit", true
case "failure":
return "Delivery issue", true
case "returned":
return "Returned to sender", true
case "pre_transit":
return "Label scanned by carrier", true
}
return "", false
}

View File

@@ -0,0 +1,23 @@
package models
import "time"
// PayRedirect maps a short token to a stripe hosted url with a TTL.
// survives api restarts so a customer mid-3Ds authentication still
// lands on the bank-hosted challenge page after a deploy or crash
type PayRedirect struct {
Token string `gorm:"primaryKey;size:64" json:"token"`
URL string `gorm:"type:text;not null" json:"url"`
Purpose string `gorm:"size:32;not null" json:"purpose"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
}
const (
RedirectPurposeCheckout3DS = "checkout_3ds"
RedirectPurposeAddCard = "add_card"
)
func (PayRedirect) TableName() string {
return "pay_redirects"
}

28
pkg/models/ssh_key.go Normal file
View File

@@ -0,0 +1,28 @@
package models
import (
"time"
"gorm.io/gorm"
)
// SSHKey represents one of a users registered SSH public keys.
// A user may have multiple keys, any of them grants access.
type SSHKey struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Fingerprint string `gorm:"uniqueIndex;size:100;not null" json:"fingerprint"`
PublicKey string `gorm:"type:text" json:"-"`
Comment string `gorm:"size:255" json:"comment"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Associations
User User `gorm:"foreignKey:UserID" json:"-"`
}
// TableName specifies the table name for the SSHKey model.
func (SSHKey) TableName() string {
return "ssh_keys"
}

327
pkg/notify/slack.go Normal file
View File

@@ -0,0 +1,327 @@
// Package notify provides a simple interface to send notifications to Slack.
package notify
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// slack client
var slackClient = &http.Client{Timeout: 5 * time.Second}
// dollars converts cents to dollars
func dollars(cents int) string {
return fmt.Sprintf("$%.2f", float64(cents)/100)
}
// postSlackMessage POSTs to chat.postMessage and returns (ts, ok). Logs on failure.
func postSlackMessage(token string, payload map[string]any) (string, bool) {
body, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := slackClient.Do(req)
if err != nil {
slog.Warn("slack post failed", "error", err)
return "", false
}
defer func() { _ = resp.Body.Close() }()
var parsed struct {
Ok bool `json:"ok"`
TS string `json:"ts"`
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&parsed)
if !parsed.Ok {
slog.Warn("slack post non-ok", "error", parsed.Error)
return "", false
}
return parsed.TS, true
}
func pinMessage(token, channel, ts string) bool {
return slackPinCall(token, "https://slack.com/api/pins.add", channel, ts, "already_pinned")
}
func unpinMessage(token, channel, ts string) bool {
return slackPinCall(token, "https://slack.com/api/pins.remove", channel, ts, "no_pin")
}
func slackPinCall(token, endpoint, channel, ts, benignErr string) bool {
body, _ := json.Marshal(map[string]any{"channel": channel, "timestamp": ts})
req, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := slackClient.Do(req)
if err != nil {
slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err)
return false
}
defer func() { _ = resp.Body.Close() }()
var parsed struct {
Ok bool `json:"ok"`
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&parsed)
if !parsed.Ok && parsed.Error != benignErr {
slog.Warn("slack pin call non-ok", "endpoint", endpoint, "error", parsed.Error)
return false
}
return true
}
func buildReceiptBlocks(order *models.Order) []map[string]any {
blocks := []map[string]any{
{"type": "divider"},
}
for _, item := range order.Items {
line := fmt.Sprintf("*%s* × %d\n%s", item.Name, item.Quantity, dollars(item.Price*item.Quantity))
blocks = append(blocks, map[string]any{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": line},
})
}
blocks = append(blocks,
map[string]any{"type": "divider"},
map[string]any{
"type": "section",
"fields": []map[string]any{
{"type": "mrkdwn", "text": "*Subtotal:*\n" + dollars(order.Subtotal)},
{"type": "mrkdwn", "text": "*Shipping:*\n" + dollars(order.ShippingCost)},
{"type": "mrkdwn", "text": "*Total:*\n" + dollars(order.Total)},
},
},
map[string]any{
"type": "section",
"text": map[string]any{
"type": "mrkdwn",
"text": fmt.Sprintf(":package: *Ship to:*\n%s\n%s\n%s, %s %s",
order.ShippingName,
order.ShippingStreet,
order.ShippingCity,
order.ShippingZip,
order.ShippingCountry,
),
},
},
map[string]any{
"type": "actions",
"elements": []map[string]any{
{
"type": "button",
"text": map[string]any{"type": "plain_text", "text": "Buy Label", "emoji": true},
"value": fmt.Sprintf("%d", order.ID),
"action_id": "buy_label",
"style": "primary",
},
},
},
)
return blocks
}
// SlackOrderPaid posts a compact parent message ("Order #N paid - $X.XX") to
// the orders channel, persists the resulting ts onto the order, and posts the
// full receipt as the first thread reply. All subsequent order events
// (label-buy, tracking updates) should be posted as further thread replies
// via SlackPostToOrderThread. No-op if SLACK_BOT_TOKEN or SLACK_CHANNEL unset.
func SlackOrderPaid(order *models.Order) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return
}
parentText := fmt.Sprintf(":coffee: Order #%d paid - %s", order.ID, dollars(order.Total))
ts, ok := postSlackMessage(token, map[string]any{
"channel": channel,
"text": parentText,
})
if !ok {
return
}
if err := database.GetDB().Model(&models.Order{}).Where("id = ?", order.ID).Update("slack_thread_ts", ts).Error; err != nil {
slog.Warn("failed to save slack thread ts", "order_id", order.ID, "error", err)
}
pinMessage(token, channel, ts)
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": ts,
"text": parentText,
"blocks": buildReceiptBlocks(order),
})
}
func SlackUnpinOrder(orderID uint) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return
}
var order models.Order
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for unpin", "order_id", orderID, "error", err)
return
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return
}
unpinMessage(token, channel, *order.SlackThreadTS)
}
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
// It looks up the thread ts from the database and returns an error when the
// reply cannot be posted.
func SlackPostToOrderThread(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, false)
}
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
// to the channel (reply_broadcast=true) so channel members get a normal
// notification. Use for messages that need immediate operator attention.
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, true)
}
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return fmt.Errorf("slack is not configured")
}
var order models.Order
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
return fmt.Errorf("failed to look up order thread: %w", err)
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return fmt.Errorf("order has no slack thread")
}
payload := map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": text,
}
if broadcast {
payload["reply_broadcast"] = true
}
if _, ok := postSlackMessage(token, payload); !ok {
slog.Warn("failed to post slack thread reply", "order_id", orderID)
return fmt.Errorf("failed to post slack thread reply")
}
return nil
}
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return
}
var order models.Order
if err := database.GetDB().
Select("id", "carrier", "tracking_number", "tracking_url", "slack_thread_ts").
Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for label-purchased slack post", "order_id", orderID, "error", err)
return
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return
}
headLine := fmt.Sprintf(":white_check_mark: Labeled - %s `%s` (%s). <%s|Download label PDF>", order.Carrier, order.TrackingNumber, dollars(costCents), labelURL)
blocks := []map[string]any{
{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": headLine},
},
}
if strings.EqualFold(order.Carrier, "BRING") {
blocks = append(blocks, markStatusActionBlock(order.ID))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": headLine,
"blocks": blocks,
})
}
func markStatusActionBlock(orderID uint) map[string]any {
id := fmt.Sprintf("%d", orderID)
return map[string]any{
"type": "actions",
"elements": []map[string]any{
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark Pre-Transit", "emoji": true}, "value": id, "action_id": "mark_pre_transit"},
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark In Transit", "emoji": true}, "value": id, "action_id": "mark_in_transit", "style": "primary"},
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark Delivered", "emoji": true}, "value": id, "action_id": "mark_delivered"},
},
}
}
func SlackUnshippedReminder(orders []models.Order) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" || len(orders) == 0 {
return
}
workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN")
lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))}
for _, order := range orders {
lines = append(lines, " "+unshippedOrderLine(order, channel, workspace))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"text": strings.Join(lines, "\n"),
})
}
func unshippedOrderLine(order models.Order, channel, workspace string) string {
age := time.Since(order.CreatedAt).Round(time.Hour)
location := order.ShippingCountry
if order.ShippingCity != "" {
location = order.ShippingCity + ", " + order.ShippingCountry
}
label := fmt.Sprintf("Order #%d", order.ID)
if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" {
ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "")
permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts)
label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID)
}
tag := ""
if order.ShippingCountry == "NO" {
tag = " _(NO * Bring blocked)_"
}
return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag)
}

View File

@@ -0,0 +1,62 @@
package observability
import (
"context"
"log"
"log/slog"
"os"
"strings"
"terminalShop/api/middleware"
)
// countingHandler wraps a slog.Handler and increments the log_messages_total
// counter for every record before delegating to the inner handler. This is
// what gives the dashboard a per-level log rate without forcing call sites
// to thread metrics manually.
type countingHandler struct {
inner slog.Handler
}
func (h *countingHandler) Enabled(ctx context.Context, l slog.Level) bool {
return h.inner.Enabled(ctx, l)
}
func (h *countingHandler) Handle(ctx context.Context, r slog.Record) error {
middleware.RecordLogLevel(strings.ToLower(r.Level.String()))
return h.inner.Handle(ctx, r)
}
func (h *countingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &countingHandler{inner: h.inner.WithAttrs(attrs)}
}
func (h *countingHandler) WithGroup(name string) slog.Handler {
return &countingHandler{inner: h.inner.WithGroup(name)}
}
// InitLogger installs a slog-based default logger with a level-counting wrapper
// and bridges the stdlib `log` package through slog so existing log.Printf
// calls are also counted (as INFO).
func InitLogger() {
level := slog.LevelInfo
if env := strings.ToLower(os.Getenv("LOG_LEVEL")); env != "" {
switch env {
case "debug":
level = slog.LevelDebug
case "warn", "warning":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
}
base := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
handler := &countingHandler{inner: base}
slog.SetDefault(slog.New(handler))
// Route the stdlib log package through slog at INFO so legacy log.Printf
// calls show up in structured output and bump the counter.
slog.SetLogLoggerLevel(slog.LevelInfo)
log.SetFlags(0)
}

View File

@@ -0,0 +1,32 @@
package observability
import (
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func InitTracing(ctx context.Context, serviceName, endpoint string) (func(context.Context) error, error) {
exp, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint(endpoint), otlptracehttp.WithInsecure())
if err != nil {
return nil, err
}
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(serviceName)))
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(5*time.Second)), sdktrace.WithResource(res))
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
return tp.Shutdown, nil
}

12
pkg/shipping/types.go Normal file
View File

@@ -0,0 +1,12 @@
package shipping
type LabelResult struct {
TransactionID string
RateID string
Carrier string
ServiceLevel string
TrackingNumber string
TrackingURL string
LabelURL string
CostCents int
}

View File

@@ -2,15 +2,27 @@ package shippo
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"terminalShop/pkg/shipping"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
const baseURL = "https://api.goshippo.com"
const (
labelPollInterval = 1 * time.Second
labelPollTimeout = 30 * time.Second
)
// Client handles communication with the Shippo API.
type Client struct {
apiKey string
@@ -22,11 +34,325 @@ func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
Timeout: 10 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport),
},
}
}
type Parcel struct {
LengthCm float64
WidthCm float64
HeightCm float64
WeightKg float64
}
type LineItem struct {
Title string
Quantity int
WeightKg float64
}
type shipmentRate struct {
ObjectID string `json:"object_id"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Provider string `json:"provider"`
ServiceLevel struct {
Name string `json:"name"`
} `json:"servicelevel"`
}
type shipmentResponse struct {
Status string `json:"status"`
Rates []shipmentRate `json:"rates"`
}
type shippoOrderResponse struct {
ObjectID string `json:"object_id"`
}
type transactionResponse struct {
ObjectID string `json:"object_id"`
Status string `json:"status"`
ObjectState string `json:"object_state"`
TrackingNumber string `json:"tracking_number"`
TrackingURL string `json:"tracking_url_provider"`
LabelURL string `json:"label_url"`
Rate string `json:"rate"`
Messages []struct {
Text string `json:"text"`
} `json:"messages"`
}
func (c *Client) CreateLabel(ctx context.Context, to Address, email string, items []LineItem) (*shipping.LabelResult, error) {
from := fromAddress()
if from.Street1 == "" {
return nil, fmt.Errorf("SHIP_FROM_STREET1 not configured")
}
totalKg := 0.0
for _, it := range items {
totalKg += float64(it.Quantity) * it.WeightKg
}
if totalKg <= 0 {
return nil, fmt.Errorf("order has zero shipping weight")
}
parcel := Parcel{LengthCm: 30, WidthCm: 30, HeightCm: 30, WeightKg: totalKg}
rate, err := c.createShipmentForRates(ctx, from, to, parcel)
if err != nil {
return nil, fmt.Errorf("get rates: %w", err)
}
shippoOrderID, err := c.createShippoOrder(ctx, to, email, items, totalKg)
if err != nil {
return nil, fmt.Errorf("create shippo order: %w", err)
}
tx, err := c.buyLabel(ctx, rate.ObjectID, shippoOrderID)
if err != nil {
return nil, fmt.Errorf("buy label: %w", err)
}
final, err := c.pollTransaction(ctx, tx.ObjectID)
if err != nil {
return nil, fmt.Errorf("poll transaction: %w", err)
}
costCents := parseAmountCents(rate.Amount)
return &shipping.LabelResult{
TransactionID: final.ObjectID,
RateID: rate.ObjectID,
Carrier: rate.Provider,
ServiceLevel: rate.ServiceLevel.Name,
TrackingNumber: final.TrackingNumber,
TrackingURL: final.TrackingURL,
LabelURL: final.LabelURL,
CostCents: costCents,
}, nil
}
func (c *Client) createShipmentForRates(ctx context.Context, from, to Address, parcel Parcel) (*shipmentRate, error) {
payload := map[string]any{
"address_from": addrPayload(from),
"address_to": addrPayload(to),
"parcels": []map[string]any{{
"length": parcel.LengthCm,
"width": parcel.WidthCm,
"height": parcel.HeightCm,
"distance_unit": "cm",
"weight": parcel.WeightKg,
"mass_unit": "kg",
}},
"async": false,
"extra": map[string]any{"bypass_address_validation": true},
}
var resp shipmentResponse
if err := c.doJSON(ctx, "POST", "/shipments", payload, &resp); err != nil {
return nil, err
}
if resp.Status != "SUCCESS" || len(resp.Rates) == 0 {
return nil, fmt.Errorf("shippo returned no rates (status=%s)", resp.Status)
}
filtered := resp.Rates[:0:0]
for _, r := range resp.Rates {
if r.Provider == "USPS" {
filtered = append(filtered, r)
}
}
if len(filtered) == 0 {
return nil, fmt.Errorf("no USPS rates available for this route")
}
resp.Rates = filtered
sort.Slice(resp.Rates, func(i, j int) bool {
ai, _ := strconv.ParseFloat(resp.Rates[i].Amount, 64)
aj, _ := strconv.ParseFloat(resp.Rates[j].Amount, 64)
return ai < aj
})
r := resp.Rates[0]
return &r, nil
}
func (c *Client) createShippoOrder(ctx context.Context, to Address, email string, items []LineItem, totalKg float64) (string, error) {
lines := make([]map[string]any, 0, len(items))
for _, item := range items {
lines = append(lines, map[string]any{
"quantity": item.Quantity,
"title": item.Title,
"weight": roundN(item.WeightKg, 4),
"weight_unit": "kg",
})
}
toPayload := addrPayload(to)
if email != "" {
toPayload["email"] = email
}
payload := map[string]any{
"to_address": toPayload,
"line_items": lines,
"placed_at": time.Now().Format(time.RFC3339),
"weight": roundN(totalKg, 2),
"weight_unit": "kg",
}
var resp shippoOrderResponse
if err := c.doJSON(ctx, "POST", "/v1/orders", payload, &resp); err != nil {
return "", err
}
if resp.ObjectID == "" {
return "", fmt.Errorf("shippo order returned no object_id")
}
return resp.ObjectID, nil
}
func (c *Client) buyLabel(ctx context.Context, rateID, shippoOrderID string) (*transactionResponse, error) {
payload := map[string]any{
"rate": rateID,
"order": shippoOrderID,
"async": true,
}
var resp transactionResponse
if err := c.doJSON(ctx, "POST", "/v1/transactions", payload, &resp); err != nil {
return nil, err
}
if resp.ObjectState == "INVALID" {
return nil, fmt.Errorf("transaction invalid: %s", firstMessage(resp.Messages))
}
return &resp, nil
}
func (c *Client) pollTransaction(ctx context.Context, txID string) (*transactionResponse, error) {
deadLine := time.Now().Add(labelPollTimeout)
for {
var resp transactionResponse
if err := c.doJSON(ctx, "GET", "/transactions/"+txID, nil, &resp); err != nil {
return nil, err
}
switch resp.Status {
case "SUCCESS":
return &resp, nil
case "ERROR":
return nil, fmt.Errorf("shippo transaction error: %s", firstMessage(resp.Messages))
}
if time.Now().After(deadLine) {
return nil, fmt.Errorf("timed out waiting for label (last status=%s)", resp.Status)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(labelPollInterval):
}
}
}
func (c *Client) doJSON(ctx context.Context, method, path string, body any, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, rdr)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "ShippoToken "+c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("shippo %s %s -> %d: %s", method, path, resp.StatusCode, string(respBody))
}
if out != nil {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("parse reponse: %w", err)
}
}
return nil
}
func addrPayload(a Address) map[string]any {
m := map[string]any{
"name": a.Name,
"street1": a.Street1,
"city": a.City,
"country": a.Country,
"zip": a.Zip,
}
if a.Street2 != "" {
m["street2"] = a.Street2
}
if a.State != "" {
m["state"] = a.State
}
if a.Phone != "" {
m["phone"] = a.Phone
}
if a.Email != "" {
m["email"] = a.Email
}
return m
}
// roundN rounds to n decimal places. Shippo's /v1/orders rejects >2 decimals
// on parcel weight and >4 on line-item weight.
func roundN(v float64, n int) float64 {
pow := 1.0
for i := 0; i < n; i++ {
pow *= 10
}
if v >= 0 {
return float64(int64(v*pow+0.5)) / pow
}
return float64(int64(v*pow-0.5)) / pow
}
func parseAmountCents(s string) int {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return int(f * 100)
}
func firstMessage(msgs []struct {
Text string `json:"text"`
}) string {
if len(msgs) == 0 {
return "no detail"
}
return msgs[0].Text
}
func fromAddress() Address {
return Address{
Name: os.Getenv("SHIP_FROM_NAME"),
Street1: os.Getenv("SHIP_FROM_STREET1"),
Street2: os.Getenv("SHIP_FROM_STREET2"),
City: os.Getenv("SHIP_FROM_CITY"),
State: os.Getenv("SHIP_FROM_STATE"),
Country: os.Getenv("SHIP_FROM_COUNTRY"),
Zip: os.Getenv("SHIP_FROM_ZIP"),
Phone: os.Getenv("SHIP_FROM_PHONE"),
Email: os.Getenv("SHIP_FROM_EMAIL"),
}
}
// Address represents a shipping address for validation.
type Address struct {
Name string `json:"name"`
@@ -37,6 +363,7 @@ type Address struct {
Country string `json:"country"`
Zip string `json:"zip"`
Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"`
}
// addressRequest is the payload sent to Shippo's address validation endpoint.
@@ -75,7 +402,7 @@ type addressResponse struct {
// ValidateAddress sends an address to Shippo for validation.
// Returns the normalized/corrected address if valid, or an error if invalid.
func (c *Client) ValidateAddress(addr Address) (*Address, error) {
func (c *Client) ValidateAddress(ctx context.Context, addr Address) (*Address, error) {
reqBody := addressRequest{
Name: addr.Name,
Street1: addr.Street1,
@@ -93,7 +420,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
return nil, fmt.Errorf("failed to marshal address: %w", err)
}
req, err := http.NewRequest("POST", baseURL+"/v1/addresses", bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/v1/addresses", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -105,7 +432,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
if err != nil {
return nil, fmt.Errorf("shippo request failed: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {

22
pkg/stripeclient/init.go Normal file
View File

@@ -0,0 +1,22 @@
package stripeclient
import (
"net/http"
"time"
"github.com/stripe/stripe-go/v78"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// InitOTel wires the Stripe SDK to use an HTTP client whose transport emits
// OpenTelemetry client spans. Call once at startup, after tracing is initialized.
func InitOTel() {
httpClient := &http.Client{
Timeout: 80 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
backends := stripe.NewBackends(httpClient)
stripe.SetBackend(stripe.APIBackend, backends.API)
stripe.SetBackend(stripe.UploadsBackend, backends.Uploads)
stripe.SetBackend(stripe.ConnectBackend, backends.Connect)
}

View File

@@ -1,341 +1,51 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
func (m Model) BuildAccountView(availableHeight int) string {
// updateAccountViewport initializes or resizes the detail viewport for the account page.
// Follows the reference pattern: each page owns its own viewport(s).
func (m Model) updateAccountViewport() Model {
// Match the reference: only subtract header + footer, no extra margins
// (account is a viewport page — views.go renders it without margin padding)
headerHeight := lipgloss.Height(m.BuildHeader())
footerHeight := lipgloss.Height(m.BuildFooter())
verticalMarginHeight := headerHeight + footerHeight
availableHeight := m.heightContainer - verticalMarginHeight
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2 // 2 for gap
// Build the left panel (menu items)
leftPanel := ""
for i, item := range models.AccountMenuItems {
if m.AccountCursor == i {
style := lipgloss.NewStyle().
Background(m.theme.Highlight()).
Foreground(m.theme.Accent()).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
} else {
style := lipgloss.NewStyle().
Foreground(m.theme.Body()).
Padding(0, 1).
Width(leftWidth - 2).
Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
}
}
leftContainer := lipgloss.NewStyle().
Width(leftWidth)
// Build the detail view (right panel) based on cursor position
detailView := ""
if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
// On small terminals, use full content width
detailContentWidth := rightWidth - 2
if m.size < large {
detailContentWidth = m.widthContent - 2
}
contentStyle := m.theme.TextBody().Width(detailContentWidth)
selectedItem := models.AccountMenuItems[m.AccountCursor]
switch selectedItem {
case "order history":
if !m.OrdersLoaded {
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("Loading orders...")
} else if len(m.Orders) == 0 {
detailView = titleStyle.Render("Order History") + "\n\n" + contentStyle.Render("No orders yet.")
} else if m.OrderViewState == 2 {
// State 2: full detail for the selected order
detailView = m.buildOrderDetailView(m.Orders[m.OrderCursor], detailContentWidth)
} else {
// State 0 (preview) or State 1 (browsing with cursor)
boxWidth := detailContentWidth - 4
boxPadding := 0
if m.heightContainer >= 25 {
boxPadding = 1
}
lines := titleStyle.Render("Order History") + "\n\n"
for i, order := range m.Orders {
isSelected := m.OrderViewState == 1 && i == m.OrderCursor
lines += m.buildOrderCard(order, boxWidth, isSelected, boxPadding, detailContentWidth)
if i < len(m.Orders)-1 {
lines += "\n"
}
}
if m.OrderViewState == 0 {
hintStyle := m.theme.TextDim()
lines += "\n" + hintStyle.Render("enter: browse orders")
}
detailView = lines
}
case "faq":
questionStyle := m.theme.TextHighlight().Bold(true)
faqContent := ""
for i, faq := range m.FAQs {
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
faqContent += contentStyle.Render(wordWrap(faq.Answer, detailContentWidth))
if i < len(m.FAQs)-1 {
faqContent += "\n\n"
}
}
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.FaqFocused {
hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
}
case "about":
accentStyle := m.theme.TextHighlight().Bold(true)
userStyle := m.theme.TextAccent().Bold(true)
aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
detailContentWidth,
)) + "\n\n"
aboutContent += userStyle.Render("@thdxr") + "\n"
aboutContent += userStyle.Render("@adamdotdev") + "\n"
aboutContent += userStyle.Render("@theprimeagen") + "\n"
aboutContent += userStyle.Render("@teej_dv") + "\n"
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
aboutContent += contentStyle.Render("And me:") + "\n\n"
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
aboutContent += contentStyle.Render(wordWrap("This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt",
detailContentWidth)) + "\n"
aboutContent += accentStyle.Render("Terminal Products, Inc.")
detailView = titleStyle.Render("About") + "\n\n" + aboutContent
}
}
// Apply internal scrolling to just the detail view
// Scrolling is needed for: order detail/list and FAQ when focused
if m.OrderViewState >= 1 || m.FaqFocused {
detailLines := strings.Split(detailView, "\n")
totalLines := len(detailLines)
// In stacked mode, reduce available height by left panel
scrollHeight := availableHeight
if m.size < large {
leftPanelHeight := len(models.AccountMenuItems) + 1
scrollHeight = availableHeight - leftPanelHeight
}
if scrollHeight < 3 {
scrollHeight = 3
}
// Clamp scroll offset locally (don't mutate m since value receiver)
offset := m.ScrollOffset
maxScroll := totalLines - scrollHeight
if maxScroll < 0 {
maxScroll = 0
}
if offset > maxScroll {
offset = maxScroll
}
if offset < 0 {
offset = 0
}
// Slice to visible window
if totalLines > scrollHeight {
end := offset + scrollHeight
if end > totalLines {
end = totalLines
}
detailLines = detailLines[offset:end]
}
detailView = strings.Join(detailLines, "\n")
}
detailContainer := lipgloss.NewStyle().
Width(rightWidth)
// Responsive layout: side-by-side on large, stacked on small/medium
rightWidth := m.widthContent - leftWidth - 2
detailWidth := rightWidth
if m.size < large {
return lipgloss.JoinVertical(
lipgloss.Left,
leftContainer.Render(leftPanel),
detailContainer.Width(m.widthContent).Render(detailView),
)
detailWidth = m.widthContent
// In stacked mode, reduce available height by the left panel
leftPanelHeight := len(models.AccountMenuItems) + 1
availableHeight -= leftPanelHeight
}
if availableHeight < 3 {
availableHeight = 3
}
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
" ",
detailContainer.Render(detailView),
)
}
// buildOrderCard renders a single order as a bordered box for the order list.
// Shows order number, date, status, total, and the first 2 items as a preview.
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string {
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right
nameStyle := m.theme.TextAccent().Bold(true)
dimStyle := m.theme.TextDim()
statusStyle := m.theme.TextHighlight().Bold(true)
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
status := statusStyle.Render(strings.ToUpper(string(order.Status)))
rightSide := date + " " + status + " " + total
rightWidth := lipgloss.Width(rightSide)
leftWidth := lipgloss.Width(orderLabel)
spacing := boxWidth - leftWidth - rightWidth - 4 // 4 for padding
if spacing < 1 {
spacing = 1
}
line1 := orderLabel + lipgloss.NewStyle().Width(spacing).Render("") + rightSide
// Item preview lines (show first 2 items)
var lines []string
lines = append(lines, line1)
previewCount := 2
if len(order.Items) < previewCount {
previewCount = len(order.Items)
}
for i := 0; i < previewCount; i++ {
item := order.Items[i]
itemLine := dimStyle.Render(fmt.Sprintf("%dx %s - $%.2f", item.Quantity, item.Name, float64(item.Price)/100.0))
lines = append(lines, itemLine)
}
if len(order.Items) > 2 {
extra := len(order.Items) - 2
moreText := "item"
if extra > 1 {
moreText = "items"
}
lines = append(lines, dimStyle.Render(fmt.Sprintf("+ %d more %s", extra, moreText)))
}
boxContent := strings.Join(lines, "\n")
// Create bordered box — highlight if selected
var itemBox lipgloss.Style
if isSelected {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true).
BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2).
Width(boxWidth)
if !m.account.viewportReady {
m.account.detailViewport = viewport.New(viewport.WithWidth(detailWidth), viewport.WithHeight(availableHeight))
m.account.detailViewport.KeyMap = modifiedKeyMap
m.account.viewportReady = true
} else {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(m.theme.Border()).
Padding(boxPadding, 2).
Width(boxWidth)
m.account.detailViewport.SetWidth(detailWidth)
m.account.detailViewport.SetHeight(availableHeight)
}
centered := lipgloss.NewStyle().
Width(containerWidth).
Align(lipgloss.Center)
return centered.Render(itemBox.Render(boxContent))
return m
}
// buildOrderDetailView renders the full detail view for a single order.
func (m Model) buildOrderDetailView(order models.Order, width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
labelStyle := m.theme.TextHighlight().Bold(true)
valueStyle := m.theme.TextBody()
dimStyle := m.theme.TextDim()
var b strings.Builder
// Header
b.WriteString(titleStyle.Render(fmt.Sprintf("Order #%d", order.ID)))
b.WriteString("\n\n")
// Status and date
b.WriteString(labelStyle.Render("Status: "))
b.WriteString(valueStyle.Render(strings.ToUpper(string(order.Status))))
b.WriteString("\n")
b.WriteString(labelStyle.Render("Date: "))
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
b.WriteString("\n\n")
// Items
b.WriteString(labelStyle.Render("Items"))
b.WriteString("\n")
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
for _, item := range order.Items {
price := fmt.Sprintf("$%.2f", float64(item.Price)/100.0)
line := fmt.Sprintf(" %dx %-20s %s", item.Quantity, item.Name, price)
b.WriteString(valueStyle.Render(line))
b.WriteString("\n")
}
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
// Totals
subtotal := fmt.Sprintf("$%.2f", float64(order.Subtotal)/100.0)
shipping := fmt.Sprintf("$%.2f", float64(order.ShippingCost)/100.0)
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Subtotal", subtotal)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Shipping", shipping)))
b.WriteString("\n")
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
b.WriteString("\n\n")
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingName))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingStreet))
b.WriteString("\n")
if order.ShippingStreet2 != "" {
b.WriteString(valueStyle.Render(" " + order.ShippingStreet2))
b.WriteString("\n")
}
cityLine := order.ShippingCity
if order.ShippingState != "" {
cityLine += ", " + order.ShippingState
}
cityLine += " " + order.ShippingZip
b.WriteString(valueStyle.Render(" " + cityLine))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingCountry))
b.WriteString("\n\n")
// Footer hint
b.WriteString(dimStyle.Render("esc: back to orders"))
return b.String()
}
// computeFaqScrollMax calculates the maximum scroll offset for the FAQ content.
// This renders the FAQ the same way BuildAccountView does, counts the lines,
// and returns the max scroll value. Returns 0 if no scrolling is needed.
func (m Model) computeFaqScrollMax() int {
// getAccountDetailContent generates the content string for the current detail panel.
func (m Model) getAccountDetailContent() string {
leftWidth := 18
rightWidth := m.widthContent - leftWidth - 2
detailContentWidth := rightWidth - 2
@@ -343,128 +53,388 @@ func (m Model) computeFaqScrollMax() int {
detailContentWidth = m.widthContent - 2
}
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return ""
}
faqContent := ""
for i, faq := range m.FAQs {
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
faqContent += contentStyle.Render(wordWrap(faq.Answer, detailContentWidth))
if i < len(m.FAQs)-1 {
faqContent += "\n\n"
selectedItem := models.AccountMenuItems[m.account.cursor]
switch selectedItem {
case "active orders":
return m.ActiveOrdersView(detailContentWidth)
case "order history":
return m.OrdersView(detailContentWidth)
case "addresses":
return m.AddressesView(detailContentWidth)
case "cards":
return m.CardsView(detailContentWidth)
case "faq":
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
questionStyle := m.theme.TextHighlight().Bold(true)
contentStyle := m.theme.TextBody().Width(detailContentWidth)
faqContent := ""
for i, faq := range m.FAQs {
faqContent += questionStyle.Render(wordWrap(faq.Question, detailContentWidth)) + "\n"
faqContent += contentStyle.Render(wordWrap(faq.Answer, detailContentWidth))
if i < len(m.FAQs)-1 {
faqContent += "\n\n"
}
}
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.account.faqFocused {
hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
}
return detailView
case "about":
return m.AboutView(detailContentWidth)
}
return ""
}
// scrollToAccountDetailItem adjusts the viewport scroll to keep the selected item visible.
// Follows the reference pattern: cursor moves immediately, viewport catches up.
//
// Each sub-page has a title header (e.g. "Order History" + margin + newlines) that
// pushes the first item down. headerOffset accounts for those lines. itemHeight
// must match the actual rendered card height including borders, padding, and the
// inter-card gap ("\n" between cards in the view function).
func (m Model) scrollToAccountDetailItem() Model {
var itemHeight, itemCount, selectedIndex, headerOffset int
switch {
case m.account.orderViewState == 1:
// No title in list mode, cards joined with JoinVertical (no gaps)
// Card: border(2) + content(2) + PaddingLeft only = 4 lines
headerOffset = 0
itemHeight = 4
itemCount = len(m.currentOrderList())
selectedIndex = m.account.orderCursor
case m.account.addressListFocused:
headerOffset = 3
itemHeight = 5
itemCount = len(m.SavedAddresses)
selectedIndex = m.account.addressCursor
case m.account.cardListFocused:
headerOffset = 3
itemHeight = 4
itemCount = len(m.SavedCards)
selectedIndex = m.account.cardCursor
default:
return m
}
if itemCount == 0 {
return m
}
targetY := headerOffset + (selectedIndex * itemHeight)
vpH := m.account.detailViewport.Height()
offset := m.account.detailViewport.YOffset()
// 1-item buffer: scroll when the NEXT item would be off-screen,
// not when the current item hits the very edge
if targetY-itemHeight < offset {
m.account.detailViewport.SetYOffset(targetY - itemHeight)
}
if targetY+itemHeight+itemHeight > offset+vpH {
m.account.detailViewport.SetYOffset(targetY - vpH + itemHeight + itemHeight)
}
return m
}
func (m Model) BuildAccountView(availableHeight int) string {
leftWidth := 18
// Build the left panel (menu items)
leftPanel := ""
for i, item := range models.AccountMenuItems {
if m.account.cursor == i {
style := m.theme.Base().Background(m.theme.Highlight()).Foreground(m.theme.Accent()).Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
} else {
style := m.theme.TextBody().Padding(0, 1).Width(leftWidth - 2).Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n"
}
}
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
leftContainer := m.theme.Base().
Width(leftWidth)
totalLines := len(strings.Split(detailView, "\n"))
// Generate detail content and set it on the viewport
detailContent := m.getAccountDetailContent()
m.account.detailViewport.SetContent(detailContent)
scrollHeight := m.heightContainer - 7
// Viewport handles scrolling/clipping — no manual line-slicing needed
detailRendered := m.account.detailViewport.View()
// Responsive layout: side-by-side on large, stacked on small/medium
if m.size < large {
scrollHeight -= len(models.AccountMenuItems) + 1
}
if scrollHeight < 3 {
scrollHeight = 3
return lipgloss.JoinVertical(
lipgloss.Left,
leftContainer.Render(leftPanel),
detailRendered,
)
}
maxScroll := totalLines - scrollHeight
if maxScroll < 0 {
maxScroll = 0
}
return maxScroll
return lipgloss.JoinHorizontal(
lipgloss.Top,
leftContainer.Render(leftPanel),
" ",
detailRendered,
)
}
func (m Model) AboutView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
// USE this if wanting to add a corp name later
// accentStyle := m.theme.TextHighlight().Bold(true)
userStyle := m.theme.TextAccent().Bold(true)
aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
width,
)) + "\n\n"
aboutContent += userStyle.Render("@thdxr") + "\n"
aboutContent += userStyle.Render("@adamdotdev") + "\n"
aboutContent += userStyle.Render("@theprimeagen") + "\n"
aboutContent += userStyle.Render("@teej_dv") + "\n"
aboutContent += userStyle.Render("@iamdavidhill") + "\n\n"
aboutContent += contentStyle.Render("And me:") + "\n\n"
aboutContent += userStyle.Render("@marcusDenslow") + "\n\n"
aboutContent += contentStyle.Render(wordWrap(
"This project tries to copy the idea of terminalDotShop, but with one twist: write everything in GO. this is my humble attempt",
width,
)) + "\n"
return titleStyle.Render("About") + "\n\n" + aboutContent
}
func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
// Pass non-key messages to viewport (e.g. window resize)
var cmd tea.Cmd
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
return m, cmd
}
selectedItem := ""
if m.account.cursor < len(models.AccountMenuItems) {
selectedItem = models.AccountMenuItems[m.account.cursor]
}
switch keyMsg.String() {
case "up", "k":
if m.FaqFocused {
m.ScrollOffset -= 3
if m.ScrollOffset < 0 {
m.ScrollOffset = 0
}
} else if m.OrderViewState == 1 && m.OrderCursor > 0 {
m.OrderCursor--
cardHeight := 5
if m.heightContainer >= 25 {
cardHeight = 7
}
targetTop := 3 + m.OrderCursor*cardHeight
if targetTop < m.ScrollOffset {
m.ScrollOffset = targetTop
}
} else if m.OrderViewState == 0 && m.AccountCursor > 0 {
m.AccountCursor--
m.ScrollOffset = 0
// In order detail or FAQ, scroll the viewport directly
if m.account.orderViewState == 2 || m.account.faqFocused {
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m.account.detailViewport.ScrollUp(3)
return m, nil
}
if m.account.addressListFocused && m.account.addressDeleting == nil {
if m.account.addressCursor > 0 {
m.account.addressCursor--
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
}
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
if m.account.cardCursor > 0 {
m.account.cardCursor--
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
}
} else if m.account.orderViewState == 1 && m.account.orderCursor > 0 {
m.account.orderCursor--
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
} else if m.account.orderViewState == 0 && m.account.cursor > 0 {
m.account.cursor--
m.account.detailViewport.GotoTop()
}
case "down", "j":
if m.FaqFocused {
m.ScrollOffset += 3
maxScroll := m.computeFaqScrollMax()
if m.ScrollOffset > maxScroll {
m.ScrollOffset = maxScroll
}
} else if m.OrderViewState == 1 && m.OrderCursor < len(m.Orders)-1 {
m.OrderCursor++
cardHeight := 5
if m.heightContainer >= 25 {
cardHeight = 7
}
viewportHeight := m.heightContainer - 7
if m.size < large {
viewportHeight -= len(models.AccountMenuItems) + 1
}
if viewportHeight < 5 {
viewportHeight = 5
}
targetBottom := 3 + (m.OrderCursor+1)*cardHeight
if targetBottom > m.ScrollOffset+viewportHeight {
m.ScrollOffset = targetBottom - viewportHeight
}
} else if m.OrderViewState == 0 && m.AccountCursor < len(models.AccountMenuItems)-1 {
m.AccountCursor++
m.ScrollOffset = 0
if m.account.orderViewState == 2 || m.account.faqFocused {
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m.account.detailViewport.ScrollDown(3)
return m, nil
}
case "pgup", "ctrl+u":
if m.FaqFocused || m.OrderViewState >= 2 {
m.ScrollOffset -= 3
if m.ScrollOffset < 0 {
m.ScrollOffset = 0
if m.account.addressListFocused && m.account.addressDeleting == nil {
if m.account.addressCursor < len(m.SavedAddresses)-1 {
m.account.addressCursor++
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
}
} else if m.account.cardListFocused && m.account.cardDeleting == nil {
if m.account.cardCursor < len(m.SavedCards)-1 {
m.account.cardCursor++
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
}
} else if m.account.orderViewState == 1 && m.account.orderCursor < len(m.currentOrderList())-1 {
m.account.orderCursor++
m.account.detailViewport.SetContent(m.getAccountDetailContent())
m = m.scrollToAccountDetailItem()
} else if m.account.orderViewState == 0 && !m.account.addressListFocused && !m.account.cardListFocused && m.account.cursor < len(models.AccountMenuItems)-1 {
m.account.cursor++
m.account.detailViewport.GotoTop()
}
case "pgup", "ctrl+u", "pgdown", "ctrl+d":
// Ensure viewport has content before delegating scroll
m.account.detailViewport.SetContent(m.getAccountDetailContent())
var cmd tea.Cmd
m.account.detailViewport, cmd = m.account.detailViewport.Update(msg)
return m, cmd
case "r":
if m.account.orderViewState == 2 {
if order, ok := m.selectedRefundableOrder(); ok {
return m.OpenRefundComposer(order.ID)
}
}
case "pgdown", "ctrl+d":
if m.FaqFocused || m.OrderViewState >= 2 {
m.ScrollOffset += 3
maxScroll := m.computeFaqScrollMax()
if m.FaqFocused && maxScroll >= 0 && m.ScrollOffset > maxScroll {
m.ScrollOffset = maxScroll
}
case "x", "d":
if m.account.addressListFocused && m.account.addressDeleting == nil && m.account.addressCursor < len(m.SavedAddresses) {
m.account.addressDeleting = &m.account.addressCursor
} else if m.account.cardListFocused && m.account.cardDeleting == nil && m.account.cardCursor < len(m.SavedCards) {
m.account.cardDeleting = &m.account.cardCursor
}
case "y":
if m.account.addressDeleting != nil {
cursor := *m.account.addressDeleting
addr := m.SavedAddresses[cursor]
m.account.addressDeleting = nil
m.SavedAddresses = append(m.SavedAddresses[:cursor], m.SavedAddresses[cursor+1:]...)
if m.account.addressCursor >= len(m.SavedAddresses) && m.account.addressCursor > 0 {
m.account.addressCursor--
}
return m, m.deleteAddressCmd(addr.ID)
} else if m.account.cardDeleting != nil {
cursor := *m.account.cardDeleting
card := m.SavedCards[cursor]
m.account.cardDeleting = nil
m.SavedCards = append(m.SavedCards[:cursor], m.SavedCards[cursor+1:]...)
if m.account.cardCursor >= len(m.SavedCards) && m.account.cardCursor > 0 {
m.account.cardCursor--
}
return m, m.deleteCardCmd(card.ID)
}
case "n":
m.account.addressDeleting = nil
m.account.cardDeleting = nil
case "p", "enter":
if m.AccountCursor == 0 && len(m.Orders) > 0 {
if m.OrderViewState == 0 {
m.OrderViewState = 1
m.OrderCursor = 0
m.ScrollOffset = 0
} else if m.OrderViewState == 1 {
m.OrderViewState = 2
m.ScrollOffset = 0
switch selectedItem {
case "order history", "active orders":
if len(m.currentOrderList()) > 0 {
switch m.account.orderViewState {
case 0:
m.account.orderViewState = 1
m.account.orderCursor = 0
m.account.detailViewport.GotoTop()
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
{key: "enter", value: "details"},
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
case 1:
m.account.orderYOffset = m.account.detailViewport.YOffset()
m.account.orderViewState = 2
m.account.detailViewport.GotoTop()
footer := []footerCommand{
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "q", value: "quit"},
}
if _, ok := m.selectedRefundableOrder(); ok {
footer = append([]footerCommand{{key: "r", value: "refund"}}, footer...)
}
m.footer = footer
}
}
case "addresses":
if !m.account.addressListFocused {
m.account.addressListFocused = true
m.account.addressCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
}
case "cards":
if !m.account.cardListFocused {
m.account.cardListFocused = true
m.account.cardCursor = 0
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "x", value: "delete"},
{key: "esc", value: "back"},
}
}
case "faq":
if !m.account.faqFocused {
m.account.faqFocused = true
m.account.detailViewport.GotoTop()
m.footer = []footerCommand{
{key: "j/k", value: "scroll"},
{key: "esc", value: "back"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "q", value: "quit"},
}
}
} else if m.AccountCursor == 1 && !m.FaqFocused {
m.FaqFocused = true
m.ScrollOffset = 0
}
case "esc":
if m.FaqFocused {
m.FaqFocused = false
m.ScrollOffset = 0
} else if m.OrderViewState > 0 {
m.OrderViewState--
m.ScrollOffset = 0
accountDefaultFooter := []footerCommand{
{key: "j/k", value: "navigate"},
{key: "enter", value: "select"},
{key: "s", value: "shop"},
{key: "c", value: "cart"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
if m.account.addressDeleting != nil {
m.account.addressDeleting = nil
} else if m.account.addressListFocused {
m.account.addressListFocused = false
m.account.addressCursor = 0
m.footer = accountDefaultFooter
} else if m.account.cardDeleting != nil {
m.account.cardDeleting = nil
} else if m.account.cardListFocused {
m.account.cardListFocused = false
m.account.cardCursor = 0
m.footer = accountDefaultFooter
} else if m.account.faqFocused {
m.account.faqFocused = false
m.account.detailViewport.GotoTop()
m.footer = accountDefaultFooter
} else if m.account.orderViewState > 0 {
m.account.orderViewState--
if m.account.orderViewState == 1 {
// Set content first so SetYOffset can clamp against correct line count
m.account.detailViewport.SetContent(m.getAccountDetailContent())
if m.account.orderYOffset > 0 {
m.account.detailViewport.SetYOffset(m.account.orderYOffset)
m.account.orderYOffset = 0
}
m.footer = []footerCommand{
{key: "j/k", value: "orders"},
{key: "enter", value: "details"},
{key: "esc", value: "back"},
{key: "q", value: "quit"},
}
} else {
m.account.detailViewport.GotoTop()
m.footer = accountDefaultFooter
}
}
}
return m, nil

View File

@@ -0,0 +1,30 @@
package tui
func (m Model) AddressesView(width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
lines := titleStyle.Render("Address") + "\n\n"
if len(m.SavedAddresses) == 0 {
lines += contentStyle.Render("No saved addresses")
} else {
for i, addr := range m.SavedAddresses {
isSelected := m.account.addressListFocused && i == m.account.addressCursor
label := addr.Name + " " + addr.Street + ", " + addr.City
if len(label) > width-4 {
label = label[:width-4]
}
if m.account.addressDeleting != nil && *m.account.addressDeleting == i {
lines += m.theme.TextError().Bold(true).Render(" deletes? (y/n)") + "\n"
} else {
lines += m.formatListItemCustom(label, isSelected, width, true) + "\n"
}
}
}
if m.account.addressListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
return lines
}

32
pkg/tui/account_cards.go Normal file
View File

@@ -0,0 +1,32 @@
package tui
import (
"fmt"
)
func (m Model) CardsView(width int) string {
titleSTyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
contentStyle := m.theme.TextBody().Width(width)
lines := titleSTyle.Render("Cards") + "\n\n"
if m.SavedCardsIsEmpty() {
lines += contentStyle.Render("No saved cards")
} else {
for i, card := range m.SavedCards {
isSelected := m.account.cardListFocused && i == m.account.cardCursor
label := fmt.Sprintf("**** **** **** %s %s exp %02d/%02d", card.Last4, card.Brand, card.ExpMonth, card.ExpYear%100)
if m.account.cardDeleting != nil && *m.account.cardDeleting == i {
lines += m.theme.TextError().Bold(true).Render(" delete? (y/s)") + "\n"
} else {
lines += m.formatListItemCustom(label, isSelected, width, true) + "\n"
}
}
}
if m.account.cardListFocused {
lines += "\n" + m.theme.TextDim().Render("x: delete esc: back")
} else {
lines += "\n" + m.theme.TextDim().Render("enter: manage")
}
return lines
}

286
pkg/tui/account_orders.go Normal file
View File

@@ -0,0 +1,286 @@
package tui
import (
"fmt"
"strings"
"terminalShop/pkg/models"
"charm.land/lipgloss/v2"
)
func (m Model) OrdersView(width int) string {
return m.orderListView("Order History", m.HistoryOrders(), width)
}
func (m Model) ActiveOrdersView(width int) string {
return m.orderListView("Active Orders", m.ActiveOrders(), width)
}
func (m Model) ActiveOrders() []models.Order {
out := make([]models.Order, 0, len(m.Orders))
for _, o := range m.Orders {
if o.IsActive() {
out = append(out, o)
}
}
return out
}
func (m Model) HistoryOrders() []models.Order {
out := make([]models.Order, 0, len(m.Orders))
for _, o := range m.Orders {
if !o.IsActive() {
out = append(out, o)
}
}
return out
}
// selectedRefundableOrder returns the order under the cursor in the active
// order-detail view, paired with ok=true, when the user is allowed to file a
// refund request for it. Returns (zero, false) otherwise.
func (m Model) selectedRefundableOrder() (models.Order, bool) {
orders := m.currentOrderList()
if m.account.orderCursor < 0 || m.account.orderCursor >= len(orders) {
return models.Order{}, false
}
order := orders[m.account.orderCursor]
if !order.CanRequestRefund() {
return models.Order{}, false
}
return order, true
}
func (m Model) currentOrderList() []models.Order {
if m.account.cursor < 0 || m.account.cursor >= len(models.AccountMenuItems) {
return nil
}
switch models.AccountMenuItems[m.account.cursor] {
case "active orders":
return m.ActiveOrders()
case "order history":
return m.HistoryOrders()
}
return nil
}
func (m Model) orderListView(title string, orders []models.Order, width int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded {
return titleStyle.Render(title) + "\n" + contentStyle.Render("Loading orders...")
}
if len(orders) == 0 {
return titleStyle.Render(title) + "\n" + contentStyle.Render("No orders yet")
}
if m.account.orderViewState == 2 {
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
}
boxWidth := width - 2
// Build cards and join vertically — no manual "\n" gaps means fixed spacing
var cards []string
for i, order := range orders {
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
cards = append(cards, m.buildOrderCard(order, boxWidth, isSelected))
}
cardList := lipgloss.JoinVertical(lipgloss.Left, cards...)
// In preview mode (viewState 0), show title + hint
if m.account.orderViewState == 0 {
hintStyle := m.theme.TextDim()
return titleStyle.Render(title) + "\n" +
cardList + "\n" +
hintStyle.Render("enter: browse orders")
}
// In list browsing mode (viewState 1), just show cards — no title
// (the left panel menu already shows "order history" as selected)
return cardList
}
// buildOrderCard renders a single order as a bordered box with fixed height.
// Shows order number + total on line 1, date + status on line 2.
// Item details are shown in the detail view (viewState 2).
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool) string {
nameStyle := m.theme.TextDim()
if isSelected {
nameStyle = m.theme.TextAccent().Bold(true)
}
// When selected, secondary text uses accent color so the whole card "lights up"
dimStyle := m.theme.TextDim()
if isSelected {
dimStyle = m.theme.TextAccent()
}
// Line 1: "Order #N" left, "$X.XX" right
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
innerWidth := boxWidth - 4 // account for border + padding
leftWidth := lipgloss.Width(orderLabel)
rightWidth := lipgloss.Width(total)
spacing := innerWidth - leftWidth - rightWidth
if spacing < 1 {
spacing = 1
}
line1 := orderLabel + lipgloss.NewStyle().Width(spacing).Render("") + total
// Line 2: date + derived status
date := dimStyle.Render(order.CreatedAt.Format("Jan 02 2006"))
status := m.displayStyle(order.DisplayKind()).Bold(true).Render(order.DisplayState())
line2 := date + " " + status
boxContent := line1 + "\n" + line2
return m.CreateBoxCustom(boxContent, isSelected, boxWidth)
}
// buildOrderDetailView renders the full detail view for a single order
func (m Model) buildOrderDetailView(order models.Order, _ int) string {
titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
labelStyle := m.theme.TextHighlight().Bold(true)
valueStyle := m.theme.TextBody()
dimStyle := m.theme.TextDim()
var b strings.Builder
// Header
b.WriteString(titleStyle.Render(fmt.Sprintf("Order #%d", order.ID)))
b.WriteString("\n\n")
// Status and date
b.WriteString(labelStyle.Render("Status: "))
b.WriteString(m.displayStyle(order.DisplayKind()).Render(order.DisplayState()))
b.WriteString("\n")
if order.TrackingStatusDetails != "" {
b.WriteString(dimStyle.Render(" " + order.TrackingStatusDetails))
b.WriteString("\n")
}
b.WriteString(labelStyle.Render("Date: "))
b.WriteString(valueStyle.Render(order.CreatedAt.Format("Jan 02 2006 3:04 PM")))
b.WriteString("\n\n")
// Items
b.WriteString(labelStyle.Render("Items"))
b.WriteString("\n")
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
for _, item := range order.Items {
price := fmt.Sprintf("$%.2f", float64(item.Price)/100.0)
line := fmt.Sprintf(" %dx %-20s %s", item.Quantity, item.Name, price)
b.WriteString(valueStyle.Render(line))
b.WriteString("\n")
}
b.WriteString(dimStyle.Render(strings.Repeat("-", 40)))
b.WriteString("\n")
// Totals
subtotal := fmt.Sprintf("$%.2f", float64(order.Subtotal)/100.0)
shipping := fmt.Sprintf("$%.2f", float64(order.ShippingCost)/100.0)
total := fmt.Sprintf("$%.2f", float64(order.Total)/100.0)
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Subtotal", subtotal)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-22s %s", "Shipping", shipping)))
b.WriteString("\n")
b.WriteString(labelStyle.Render(fmt.Sprintf(" %-22s %s", "Total", total)))
b.WriteString("\n\n")
// Shipping (only when at least one shipment exists)
if order.Carrier != "" {
b.WriteString(labelStyle.Render("Shipping"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Carrier", order.Carrier)))
b.WriteString("\n")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Tracking", order.TrackingNumber)))
b.WriteString("\n")
if order.TrackingStatus != "" {
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s", "Status", string(order.TrackingStatus))))
b.WriteString("\n")
}
if order.ShippedAt != nil {
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-10s %s",
"Shipped", order.ShippedAt.Format("Jan 02 2006"))))
b.WriteString("\n")
}
if order.TrackingURL != "" {
b.WriteString(dimStyle.Render(fmt.Sprintf(" https://api.sshops.uk/t/%d", order.ID)))
b.WriteString("\n")
}
}
visible := make([]models.OrderEvent, 0, len(order.Events))
for _, event := range order.Events {
if _, ok := event.DisplayLabel(); ok {
visible = append(visible, event)
}
}
if len(visible) > 0 {
b.WriteString(labelStyle.Render("Timeline"))
b.WriteString("\n")
for _, event := range visible {
label, _ := event.DisplayLabel()
when := event.CreatedAt.Format("Jan 02 3:04 PM")
b.WriteString(valueStyle.Render(fmt.Sprintf(" %-20s %s", label, dimStyle.Render(when))))
b.WriteString("\n")
}
b.WriteString("\n")
}
// Shipping address
b.WriteString(labelStyle.Render("Ship To"))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingName))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingStreet))
b.WriteString("\n")
if order.ShippingStreet2 != "" {
b.WriteString(valueStyle.Render(" " + order.ShippingStreet2))
b.WriteString("\n")
}
cityLine := order.ShippingCity
if order.ShippingState != "" {
cityLine += ", " + order.ShippingState
}
cityLine += " " + order.ShippingZip
b.WriteString(valueStyle.Render(" " + cityLine))
b.WriteString("\n")
b.WriteString(valueStyle.Render(" " + order.ShippingCountry))
b.WriteString("\n\n")
// Footer hint
if order.CanRequestRefund() {
b.WriteString(dimStyle.Render("r: refund esc: back to orders"))
} else {
b.WriteString(dimStyle.Render("esc: back to orders"))
}
return b.String()
}
// displayStyle maps a model-layer DisplayKind token to a themed lipgloss style.
// Keeps pkg/models free of UI deps.
func (m Model) displayStyle(k models.DisplayKind) lipgloss.Style {
switch k {
case models.DisplayKindSuccess:
return m.theme.TextSuccess()
case models.DisplayKindBrand:
return m.theme.TextBrand()
case models.DisplayKindAccent:
return m.theme.TextAccent()
case models.DisplayKindError:
return m.theme.TextError()
case models.DisplayKindWarning:
return m.theme.TextLoading()
case models.DisplayKindRefund:
return m.theme.TextBrand()
}
return m.theme.TextHighlight()
}

View File

@@ -1,7 +1,7 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
func (m Model) BuildBreadcrumbs() string {

View File

@@ -3,9 +3,9 @@ package tui
import (
"fmt"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
func (m Model) updateCartViewport() Model {
@@ -16,24 +16,25 @@ func (m Model) updateCartViewport() Model {
if availH < 1 {
availH = 1
}
if !m.cartVPReady {
m.cartVP = viewport.New(m.widthContent, availH)
m.cartVP.KeyMap = viewport.KeyMap{}
m.cartVPReady = true
if !m.cart.viewportReady {
m.cart.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.cart.viewport.KeyMap = viewport.KeyMap{}
m.cart.viewportReady = true
} else {
m.cartVP.Width = m.widthContent
m.cartVP.Height = availH
m.cart.viewport.SetWidth(m.widthContent)
m.cart.viewport.SetHeight(availH)
}
return m
}
func (m Model) generateCartContent() string {
if len(m.Cart) == 0 {
if m.IsCartEmpty() {
emptyStyle := m.theme.TextDim().
Align(lipgloss.Center).
Width(m.widthContent).
Padding(2, 0)
return emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
render := emptyStyle.Render("Your cart is empty\n\nPress s to go back to shop")
return render
}
cartItems := ""
@@ -86,29 +87,14 @@ func (m Model) generateCartContent() string {
boxContent := contentLine1 + "\n" + contentLine2
var itemBox lipgloss.Style
if idx == m.CartCursor {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true).
BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2).
Width(boxWidth).
Foreground(m.theme.Accent()).
Bold(true)
isSelected := idx == m.cart.cursor
if isSelected {
boxContent = m.theme.TextAccent().Bold(true).Render(boxContent)
} else {
itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(m.theme.Border()).
Padding(boxPadding, 2).
Width(boxWidth).
Foreground(m.theme.Accent())
boxContent = m.theme.TextAccent().Render(boxContent)
}
centered := lipgloss.NewStyle().
Width(m.widthContent).
Align(lipgloss.Center)
cartItems += centered.Render(itemBox.Render(boxContent))
box := m.createBoxInner(boxContent, isSelected, lipgloss.Left, 2, boxPadding, boxWidth)
cartItems += m.theme.Base().Width(m.widthContent).Align(lipgloss.Center).Render(box)
if idx < len(itemSlice)-1 {
cartItems += "\n"
@@ -118,26 +104,30 @@ func (m Model) generateCartContent() string {
}
func (m Model) CartView() string {
if !m.cartVPReady {
if !m.cart.viewportReady {
m = m.updateCartViewport()
}
content := m.generateCartContent()
m.cartVP.SetContent(content)
m.cart.viewport.SetContent(content)
if len(m.Cart) > 0 {
itemHeight := 5
targetY := m.CartCursor * itemHeight
if targetY < m.cartVP.YOffset {
m.cartVP.SetYOffset(targetY)
boxPadding := 0
if m.heightContainer >= 25 {
boxPadding = 1
}
if targetY+itemHeight > m.cartVP.YOffset+m.cartVP.Height {
m.cartVP.SetYOffset(targetY - m.cartVP.Height + itemHeight)
itemHeight := 4 + 2*boxPadding + 1
targetY := m.cart.cursor * itemHeight
if targetY < m.cart.viewport.YOffset() {
m.cart.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.cart.viewport.YOffset()+m.cart.viewport.Height() {
m.cart.viewport.SetYOffset(targetY - m.cart.viewport.Height() + itemHeight)
}
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.cartVP.View()),
lipgloss.Height(m.cart.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.cartVP.View(),
m.cart.viewport.View(),
)
}
@@ -148,41 +138,41 @@ func (m Model) CartUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
switch keyMsg.String() {
case "up", "k":
if m.CartCursor > 0 {
m.CartCursor--
if m.cart.cursor > 0 {
m.cart.cursor--
}
case "down", "j":
if m.CartCursor < len(m.Cart)-1 {
m.CartCursor++
if m.cart.cursor < len(m.Cart)-1 {
m.cart.cursor++
}
case "+", "=":
cartItems := m.GetCartItemsSlice()
if m.CartCursor >= 0 && m.CartCursor < len(cartItems) {
cartItems[m.CartCursor].Quantity++
return m, m.syncCartItemCmd(cartItems[m.CartCursor].CoffeeID, cartItems[m.CartCursor].Quantity)
if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) {
cartItems[m.cart.cursor].Quantity++
m, cmd := m.syncCartItemCmd(cartItems[m.cart.cursor].CoffeeID, cartItems[m.cart.cursor].Quantity)
return m, cmd
}
case "-", "_":
cartItems := m.GetCartItemsSlice()
if m.CartCursor >= 0 && m.CartCursor < len(cartItems) {
coffeeID := cartItems[m.CartCursor].CoffeeID
cartItems[m.CartCursor].Quantity--
newQty := cartItems[m.CartCursor].Quantity
if cartItems[m.CartCursor].Quantity <= 0 {
if m.cart.cursor >= 0 && m.cart.cursor < len(cartItems) {
coffeeID := cartItems[m.cart.cursor].CoffeeID
cartItems[m.cart.cursor].Quantity--
newQty := cartItems[m.cart.cursor].Quantity
if cartItems[m.cart.cursor].Quantity <= 0 {
delete(m.Cart, coffeeID)
if len(m.Cart) == 0 {
m.CartCursor = 0
} else if m.CartCursor >= len(m.Cart) {
m.CartCursor = len(m.Cart) - 1
m.cart.cursor = 0
} else if m.cart.cursor >= len(m.Cart) {
m.cart.cursor = len(m.Cart) - 1
}
newQty = 0
}
return m, m.syncCartItemCmd(coffeeID, newQty)
m, cmd := m.syncCartItemCmd(coffeeID, newQty)
return m, cmd
}
case "p", "enter":
if len(m.Cart) > 0 {
m = m.SwitchPage(shippingPage)
m = m.updateShippingViewport()
return m, m.fetchAddressesCmd()
if !m.IsCartEmpty() {
return m.ShippingSwitch()
}
}
return m, nil

View File

@@ -2,10 +2,14 @@ package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// RenderConfirmation renders the order confirmation view
@@ -24,28 +28,28 @@ func (m Model) RenderConfirmation() string {
// Shipping summary
shippingSection := ""
if m.ConfirmShipping != nil {
if m.confirm.shipping != nil {
shippingSection = sectionStyle.Render(
labelStyle.Render("Ship to: ") +
valueStyle.Render(m.ConfirmShipping.Name) + "\n" +
valueStyle.Render(m.confirm.shipping.Name) + "\n" +
labelStyle.Render(" ") +
valueStyle.Render(m.ConfirmShipping.Street) + "\n" +
valueStyle.Render(m.confirm.shipping.Street) + "\n" +
labelStyle.Render(" ") +
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ConfirmShipping.City, m.ConfirmShipping.State, m.ConfirmShipping.Zip, m.ConfirmShipping.Country)),
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.confirm.shipping.City, m.confirm.shipping.State, m.confirm.shipping.Zip, m.confirm.shipping.Country)),
)
}
// Items summary
cartSection := ""
for _, item := range m.ConfirmItems {
for _, item := range m.confirm.items {
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100)
cartSection += sectionStyle.Render(line) + "\n"
}
cartSection += sectionStyle.Render(
labelStyle.Render("Shipping: ") + valueStyle.Render("$0.00"),
labelStyle.Render("Shipping: ")+valueStyle.Render("$0.00"),
) + "\n"
cartSection += sectionStyle.Render(
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.ConfirmTotal)/100)),
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.confirm.total)/100)),
)
success := successStyle.Render("Order placed successfully!")
@@ -70,34 +74,34 @@ func (m Model) updateConfirmViewport() Model {
if availH < 1 {
availH = 1
}
if !m.confirmVPReady {
m.confirmVP = viewport.New(m.widthContent, availH)
m.confirmVP.KeyMap = viewport.DefaultKeyMap()
m.confirmVPReady = true
if !m.confirm.viewportReady {
m.confirm.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.confirm.viewport.KeyMap = viewport.DefaultKeyMap()
m.confirm.viewportReady = true
} else {
m.confirmVP.Width = m.widthContent
m.confirmVP.Height = availH
m.confirm.viewport.SetWidth(m.widthContent)
m.confirm.viewport.SetHeight(availH)
}
return m
}
func (m Model) ConfirmView() string {
if !m.confirmVPReady {
if !m.confirm.viewportReady {
m = m.updateConfirmViewport()
}
m.confirmVP.SetContent(m.RenderConfirmation())
m.confirm.viewport.SetContent(m.RenderConfirmation())
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.confirmVP.View()),
lipgloss.Height(m.confirm.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.confirmVP.View(),
m.confirm.viewport.View(),
)
}
func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
if m.confirmVPReady {
m.confirmVP, cmd = m.confirmVP.Update(msg)
if m.confirm.viewportReady {
m.confirm.viewport, cmd = m.confirm.viewport.Update(msg)
}
keyMsg, ok := msg.(tea.KeyMsg)
@@ -109,12 +113,11 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
return m, cmd
}
m = m.SwitchPage(shopPage)
m.CheckingOut = false
m.ConfirmTotal = 0
m.ConfirmItems = nil
m.ConfirmShipping = nil
m = m.resetPageState()
m.confirm.total = 0
m.confirm.items = nil
m.confirm.shipping = nil
m, _ = m.ShopSwitch()
return m, func() tea.Msg {
if m.APIClient != nil {
_ = m.APIClient.ClearCart()
@@ -122,3 +125,228 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
return nil
}
}
func formatUSD(cents int) string {
return fmt.Sprintf("$%d.%02d", cents/100, cents%100)
}
func (m Model) generateReviewContent() string {
view := strings.Builder{}
if m.review.cardJustAdded {
if m.review.cardWasDuplicate {
view.WriteString(m.theme.TextDim().Render("identical card already saved... using existing card") + "\n\n")
} else {
view.WriteString(m.theme.TextSuccess().Bold(true).Render("card added successfully") + "\n\n")
}
}
if m.ShippingInfo != nil {
view.WriteString(m.ShippingInfo.Name + "\n")
view.WriteString(m.ShippingInfo.Street + "\n")
if m.ShippingInfo.Street2 != "" {
view.WriteString(m.ShippingInfo.Street2 + "\n")
}
fmt.Fprintf(&view, "%s, %s %s, %s\n", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)
}
view.WriteString("\n")
if m.SelectedCard != nil {
fmt.Fprintf(&view, "cc: **** **** **** %s\n", m.SelectedCard.Last4)
}
view.WriteString("\n")
subtotal := m.CalculateSubtotal()
fmt.Fprintf(&view, "subtotal: %s\n", formatUSD(subtotal))
view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n")
view.WriteString("\n")
view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n")
return m.theme.Base().Padding(0, 1).Render(view.String())
}
func (m Model) ReviewView() string {
if m.CheckingOut {
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
}
if m.review.authFailed {
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again")
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
if m.review.requiresAction {
return m.renderRequiresActionView()
}
if m.review.success {
content := m.theme.TextSuccess().Bold(true).Padding(1).Render("order placed successfully!")
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
content := m.generateReviewContent()
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(content),
lipgloss.Center, lipgloss.Center,
content,
)
}
// renderRequiresActionView shows a QR for the 3DS challenge URL while the TUI
// polls the order status. Mirrors the add-card QR layout in payment_form.go.
func (m Model) renderRequiresActionView() string {
base := m.theme.TextLabel()
accent := m.theme.TextHighlight()
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
if m.review.redirectURL == "" {
return base.Render(" waiting for authentication link...")
}
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
if err != nil || qrSize > m.widthContent {
body := header + "\n\n" +
base.Render("open this url on your phone to authenticate:") + "\n\n" +
accent.Render(m.review.redirectURL) + "\n\n" +
base.Render("waiting for completion...")
return lipgloss.Place(m.widthContainer, lipgloss.Height(body), lipgloss.Center, lipgloss.Center, body)
}
// Same gotcha as payment_form.go: lipgloss layout helpers corrupt qrfefe ANSI.
return qr +
header + "\n" +
base.Render("scan or visit to complete authentication") + "\n" +
accent.Render(m.review.redirectURL) + "\n\n" +
base.Render("waiting for completion...")
}
func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m = m.SwitchPage(paymentPage)
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
m.review.cardJustAdded = false
m.payment.view = 0
m.payment.form = nil
return m, nil
}
if msg.RequiresAction {
m.review.requiresAction = true
m.review.redirectURL = msg.RedirectURL
m.review.orderID = msg.OrderID
return m, m.schedulePollOrderStatus(msg.OrderID)
}
m.Cart = make(map[uint]*models.CartItem)
m.cart.cursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m.review.success = true
return m, nil
case OrderStatusMsg:
if !m.review.requiresAction || msg.OrderID != m.review.orderID {
return m, nil
}
if msg.Err != nil {
// Transient — keep polling.
return m, m.schedulePollOrderStatus(msg.OrderID)
}
switch msg.Status {
case "paid", "shipped", "delivered":
m.Cart = make(map[uint]*models.CartItem)
m.cart.cursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m.review.requiresAction = false
m.review.redirectURL = ""
m.review.success = true
return m, nil
case "failed", "cancelled":
m.review.requiresAction = false
m.review.redirectURL = ""
m.review.authFailed = true
return m, nil
default:
return m, m.schedulePollOrderStatus(msg.OrderID)
}
case tea.KeyMsg:
if m.CheckingOut {
return m, nil
}
if m.review.authFailed {
if msg.String() == "esc" || msg.String() == "enter" {
m.review.authFailed = false
m = m.SwitchPage(paymentPage)
m.payment.view = 0
m.payment.form = nil
return m, nil
}
return m, nil
}
if m.review.requiresAction {
if msg.String() == "esc" {
m.review.requiresAction = false
m.review.redirectURL = ""
m = m.SwitchPage(paymentPage)
m.payment.view = 0
return m, nil
}
return m, nil
}
if m.review.success {
m.review.success = false
m.review.cardJustAdded = false
m.shipping.form = nil
m.payment.form = nil
m, _ = m.ShopSwitch()
return m, func() tea.Msg {
if m.APIClient != nil {
_ = m.APIClient.ClearCart()
}
return nil
}
}
switch msg.String() {
case "esc":
m = m.SwitchPage(paymentPage)
return m, nil
case "enter":
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
}
}
return m, nil
}
func friendlyCheckoutError(raw error) string {
s := raw.Error()
switch {
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
return "your bank wants to verify this charge, authentication step coming soon."
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
return "this card doesn't support online authentication. Try another card"
case strings.Contains(s, "CARD_EXPIRED"):
return "card expired. Add a new card and try again"
case strings.Contains(s, "CARD_CVC_FAILED"):
return "card cvc incorrect. Check the back of the card and try again"
case strings.Contains(s, "CARD_INSUFFICIENT_FUNDS"):
return "insufficient funds on this card"
case strings.Contains(s, "CARD_PROCESSING_ERROR"):
return "card processing error. Try again in a moment"
case strings.Contains(s, "CARD_DECLINED"):
return "card declined"
}
return "checkout failed, try again."
}

View File

@@ -1,50 +1,10 @@
[
{
"question": "help, i have a question about my order!",
"answer": "send us an email at support@terminal.shop."
"question": "i have a question!",
"answer": "I'm happy to answer any question! From questions about your order, to questions about working on the code, please contact me through my github @marcusDenslow, or at my email. marcus.allen.denslow@gmail.com"
},
{
"question": "when was my coffee roasted? the roast date is blank on my bag.",
"answer": "we roast your coffee within 24 hours of shipping, and you'll receive an email once your order ships. we're working on a solution to stamping the roast date on the bags, but so far all attempts have failed (the ink wipes off)."
},
{
"question": "where do you ship?",
"answer": "we ship all of our blends in the US. Unfortunately, we don't ship to other countries at this time. We are actively looking for other small-batch roasters in other countries and regions though!"
},
{
"question": "is your coffee ethically sourced?",
"answer": "absolutely."
},
{
"question": "is ordering via ssh secure?",
"answer": "you bet it is. arguably more secure than your browser. ssh incorporates encryption and authentication via a process called public key cryptography. if that doesn't sound secure we don't know what does."
},
{
"question": "what is cron?",
"answer": "cron is our monthly membership, available only in the US (for now). if you subscribe, you'll receive a new coffee blend every month, along with other surprises both physical and digital."
},
{
"question": "i tried to subscribe to cron, but it says i need a public key?",
"answer": "we don't allow subscriptions without a public key, as you won't be able to ssh back in with the same identity (we treat users without a public key as anonymous). create a key with `ssh-keygen -t rsa` and make sure your ssh client isn't omitting your key."
},
{
"question": "how do you store my data?",
"answer": "we store minimal information about you when you order through our ssh shop: name, shipping address and email, along with a fingerprint generated from your public key. all payment information is stored exclusively by Stripe."
},
{
"question": "do you offer a subscription?",
"answer": "in addition to cron, our monthly membership, you can also subscribe to any of the blends offered in our shop."
},
{
"question": "will Terminal coffee make me a better developer?",
"answer": "legally we cannot guarantee that it will, but..."
},
{
"question": "does coffee from the command line taste better than regular coffee?",
"answer": "it's just science!"
},
{
"question": "is it true your coffee contains the tears of @thdxr?",
"answer": "yes, this is true."
"question": "why is it so empty here?",
"answer": "more is to come!"
}
]

View File

@@ -1,194 +1,25 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
func (m Model) BuildFooter() string {
// Style for keybinds (bold)
keybindStyle := m.theme.TextDim().Bold(true)
// Style for descriptions (normal)
descStyle := m.theme.TextDim()
// On small terminals, only show the menu hint
if m.size == small {
footerText := keybindStyle.Render("m") + " " + descStyle.Render("menu")
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
}
var footerText string
if m.FaqFocused {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("scroll"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.currentPage == accountPage {
switch m.OrderViewState {
case 2:
// Viewing single order detail
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
case 1:
// Browsing order list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("orders"),
keybindStyle.Render("enter"),
descStyle.Render("details"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
default:
// Account tabs
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("navigate"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
}
} else if m.currentPage == confirmPage {
// Confirmation screen
footerText = fmt.Sprintf("%s %s %s %s",
keybindStyle.Render("esc"),
descStyle.Render("back to shop"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else if m.currentPage == shippingPage {
if m.ShippingView == 0 && m.ShippingForm == nil {
// Address list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("addresses"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("d/x"),
descStyle.Render("delete"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
} else {
// Shipping form
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("tab"),
descStyle.Render("next"),
keybindStyle.Render("enter"),
descStyle.Render("submit"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
}
} else if m.currentPage == paymentPage {
if m.PaymentView == 0 && m.PaymentForm == nil {
// Card list
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("cards"),
keybindStyle.Render("enter"),
descStyle.Render("select"),
keybindStyle.Render("d/x"),
descStyle.Render("delete"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
} else if m.CheckingOut {
// Submitting order
footerText = descStyle.Render("submitting order...")
} else {
// Payment form
footerText = fmt.Sprintf("%s %s %s %s %s %s",
keybindStyle.Render("tab"),
descStyle.Render("next"),
keybindStyle.Render("enter"),
descStyle.Render("submit"),
keybindStyle.Render("esc"),
descStyle.Render("back"),
)
}
} else if m.currentPage == cartPage {
// In cart view, show proceed option
if len(m.Cart) > 0 {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("p/enter"),
descStyle.Render("checkout"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("pgup/pgdn"),
descStyle.Render("scroll"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("items"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("s"),
descStyle.Render("shop"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
}
} else {
footerText = fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s %s %s",
keybindStyle.Render("j/k"),
descStyle.Render("products"),
keybindStyle.Render("+/-"),
descStyle.Render("qty"),
keybindStyle.Render("c"),
descStyle.Render("cart"),
keybindStyle.Render("a"),
descStyle.Render("account"),
keybindStyle.Render("?"),
descStyle.Render("help"),
keybindStyle.Render("q"),
descStyle.Render("quit"),
)
parts := []string{}
for _, cmd := range m.footer {
parts = append(parts, keybindStyle.Render(cmd.key)+" "+descStyle.Render(cmd.value))
}
footerText := strings.Join(parts, " ")
return lipgloss.NewStyle().
Align(lipgloss.Center).
Width(m.widthContainer).
Render(footerText)
return lipgloss.NewStyle().Align(lipgloss.Center).Width(m.widthContainer).Render(footerText)
}

View File

@@ -3,90 +3,56 @@ package tui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
)
func (m Model) BuildHeader() string {
// Calculate cart total and item count
total := 0
itemCount := 0
for _, item := range m.Cart {
total += item.Quantity * item.Coffee.Price
itemCount += item.Quantity
bold := m.theme.TextAccent().Bold(true).Render
accent := m.theme.TextAccent().Render
base := m.theme.Base().Render
cursor := m.theme.Base().Background(m.theme.Brand()).Render(" ")
mark := bold("t") + cursor
logo := bold("terminal")
menu := bold("m") + base(" ☰")
tab := func(key, name string, active bool) string {
if active {
return accent(key + " " + name)
}
return accent(key) + base(" "+name)
}
shop := tab("s", "shop", m.currentPage == shopPage)
account := tab("a", "account", m.currentPage == accountPage)
// Define colors
grayStyle := m.theme.TextBody()
boldWhiteStyle := m.theme.TextAccent().Bold(true)
boldGrayStyle := m.theme.TextBody().Bold(true)
// Build cart tab (used in all sizes)
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
var cartKeybind, cartName, cartInfo string
priceStr := fmt.Sprintf(" $%2v", m.CalculateSubtotal()/100)
countStr := fmt.Sprintf(" [%d]", m.CartItemCount())
cartLabel := base(" cart")
if m.inCartFlow() {
// Active: bold white keybind and name
cartKeybind = boldWhiteStyle.Render("c")
cartName = boldWhiteStyle.Render("cart")
cartInfo = fmt.Sprintf(" %s %s",
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", float64(total)/100)),
itemCountText)
} else {
// Inactive: bold gray keybind, gray name
cartKeybind = boldGrayStyle.Render("c")
cartName = grayStyle.Render("cart")
cartInfo = fmt.Sprintf(" %s %s",
boldWhiteStyle.Render(fmt.Sprintf("$%.2f", float64(total)/100)),
itemCountText)
cartLabel = accent(" cart")
}
cartTab := cartKeybind + " " + cartName + cartInfo
var tabsContent string
cart := accent("c") + cartLabel + accent(priceStr) + base(countStr)
var tabs []string
switch m.size {
case small:
// Small: just a mark + Cart
mark := boldWhiteStyle.Render("t")
tabsContent = mark + " " + cartTab
tabs = []string{mark, cart}
case medium:
menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu")
logo := boldWhiteStyle.Render("terminal coffee")
separator := m.theme.TextAccent().Render("|")
tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab
tabs = []string{menu, logo, cart}
default:
separator := m.theme.TextAccent().Render("|")
var shopKeybind, shopName string
if m.currentPage == shopPage {
shopKeybind = boldWhiteStyle.Render("s")
shopName = boldWhiteStyle.Render("shop")
} else {
shopKeybind = boldGrayStyle.Render("s")
shopName = grayStyle.Render("shop")
}
var accountKeybind, accountName string
if m.currentPage == accountPage {
accountKeybind = boldWhiteStyle.Render("a")
accountName = boldWhiteStyle.Render("account")
} else {
accountKeybind = boldGrayStyle.Render("a")
accountName = grayStyle.Render("account")
}
tabsContent = fmt.Sprintf("%s %s %s %s %s %s %s", shopKeybind, shopName, separator, accountKeybind, accountName, separator, cartTab)
tabs = []string{logo, shop, account, cart}
}
// Add box with padding (no vertical padding, just horizontal)
tabBox := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(0, 2).
MarginBottom(1)
tbl := table.New().Border(lipgloss.NormalBorder()).BorderStyle(lipgloss.NewStyle().Foreground(m.theme.Border())).Row(tabs...).Width(m.widthContent).StyleFunc(func(row, col int) lipgloss.Style {
return m.theme.Base().Padding(0, 1).AlignHorizontal(lipgloss.Center)
}).Render()
// Center the whole thing within the container
centered := lipgloss.NewStyle().
Width(m.widthContainer).
Align(lipgloss.Center)
return centered.Render(tabBox.Render(tabsContent))
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(tbl),
lipgloss.Center,
lipgloss.Center,
tbl,
)
}

View File

@@ -1,8 +1,8 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
func (m Model) BuildHelpView() string {

View File

@@ -1,18 +1,17 @@
package tui
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"fmt"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) BuildMenuView() string {
bold := m.theme.TextAccent().Bold(true)
base := m.theme.TextBody()
rowStyle := lipgloss.NewStyle().Padding(0,1)
rowStyle := lipgloss.NewStyle().Padding(0, 1)
rows := []string{
rowStyle.Render(bold.Render("s") + " " + base.Render("shop")),
rowStyle.Render(bold.Render("a") + " " + base.Render("account")),
@@ -21,13 +20,11 @@ func (m Model) BuildMenuView() string {
rowStyle.Render(bold.Render("q") + " " + base.Render("quit")),
}
menuContent := ""
for _, row := range rows {
menuContent += row + "\n"
}
logoStyle := m.theme.TextAccent().Bold(true)
logo := logoStyle.Render("terminal coffee")
@@ -35,12 +32,8 @@ func (m Model) BuildMenuView() string {
hint := base.Render("press esc to close")
itemCount := 0
total := 0
for _, item := range m.Cart {
total += item.Quantity * item.Coffee.Price
itemCount += item.Quantity
}
total := m.CalculateSubtotal()
itemCount := m.CartItemCount()
cartHint := base.Render(fmt.Sprintf("cart $%.2f [%d]", float64(total)/100, itemCount))
@@ -52,7 +45,7 @@ func (m Model) BuildMenuView() string {
"",
hint,
cartHint,
)
)
return lipgloss.Place(
m.viewportWidth,
@@ -60,8 +53,8 @@ func (m Model) BuildMenuView() string {
lipgloss.Center,
lipgloss.Center,
assembled,
)
)
}
func (m Model) MenuUpdate(msg tea.Msg) (Model, tea.Cmd) {
@@ -72,17 +65,15 @@ func (m Model) MenuUpdate(msg tea.Msg) (Model, tea.Cmd) {
switch keyMsg.String() {
case "s":
m.ShowingMenu = false
return m.SwitchPage(shopPage).resetPageState(), nil
m, cmd := m.ShopSwitch()
return m, cmd
case "a":
m.ShowingMenu = false
m = m.SwitchPage(accountPage).resetPageState()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
return m, nil
return m.AccountSwitch()
case "c":
m.ShowingMenu = false
return m.SwitchPage(cartPage).resetPageState(), nil
m, cmd := m.CartSwitch()
return m, cmd
case "esc":
m.ShowingMenu = false
case "?":

View File

@@ -4,12 +4,12 @@ import (
"fmt"
"log"
"math"
"sort"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/stripe/stripe-go/v78"
stripetoken "github.com/stripe/stripe-go/v78/token"
@@ -28,7 +28,7 @@ const (
large // viewportWidth >= 80
)
// page represetnts the currently active TUI screen
// page represents the currently active TUI screen
type page int
const (
@@ -36,6 +36,7 @@ const (
cartPage
shippingPage
paymentPage
reviewPage
confirmPage
accountPage
splashPage
@@ -51,35 +52,48 @@ type resizeTickMsg struct {
// this prevents flickering when resizing the terminal
const resizeDebounce = 50 * time.Millisecond
type footerCommand struct {
key string
value string
}
// VisibleError is a typed error message displayed to the user as a banner.
// Using a struct instead of a plain string lets the Update loop catch raw
// errors and convert them, and lets ESC dismiss the banner.
type VisibleError struct {
message string
}
// VisibleNotice is a typed success message displayed to the user as a banner.
type VisibleNotice struct {
message string
}
type Model struct {
// User authentication
User *models.User
Fingerprint string
SSHPublicKeyStr string
AccessToken string
AuthFingerprintKey string // shared secret for /auth/token refresh
StripePublicKey string
// Shop state
Username string
Coffees []models.Coffee
shop shopState
cartVP viewport.Model
cartVPReady bool
shippingVP viewport.Model
shippingVPReady bool
paymentVP viewport.Model
paymentVPReady bool
confirmVP viewport.Model
confirmVPReady bool
Cart map[uint]*models.CartItem
CartCursor int // cursor position in cart view
AccountCursor int
currentPage page // currently active screen
switched bool // true when the page just changed (for state resets)
ScrollOffset int // scroll position for content
CheckingOut bool // true while saveCardAndConvert is running
lastCartUpdateID int64 // debounce ID for optimistic cart sync
// One-shop auth command - capture pubKeyStr in closure, not stored on Model
authCmd tea.Cmd
// Shared data
Username string
Coffees []models.Coffee
Cart map[uint]*models.CartItem
SavedAddresses []models.Address
SavedCards []models.Card
Orders []models.Order
OrdersLoaded bool
ordersPollStarted bool
FAQs []FAQ
// Page navigation
currentPage page // currently active screen
switched bool // true when the page just changed (for state resets)
// Menu modal state
ShowingMenu bool // true when full-screen menu is showing
@@ -99,48 +113,33 @@ type Model struct {
pendingWidth int // buffered width from latest resize
pendingHeight int // buffered height from latest resize
Loading bool // true when fetching data from API
ErrorMsg string // error message if API fetch fails
Loading bool // true when fetching data from API
error *VisibleError
notice *VisibleNotice
APIClient *api.Client
// Rendering
renderer *lipgloss.Renderer
theme theme.Theme
theme theme.Theme
// Checkout state
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step)
PaymentForm *PaymentFormState // Payment form state (nil when not in payment step)
ShippingInfo *models.Address // Selected shipping address for current order
SavedAddresses []models.Address // Saved addresses from database
ShippingView int
AddressCursor int
SavedCards []models.Card // Saved cards from database
SelectedCard *models.Card // Card selected from saved list (nil = new card)
CardCursor int // cursor position in the card list
PaymentView int // 0=card list, 1=new card form, 2 = browser payment
CollectURL *string // URL for browser-based card entry
CollectCardCount int // card count when browser polling started
// Checkout flow state (spans multiple pages)
CheckingOut bool
ShippingInfo *models.Address
SelectedCard *models.Card
lastCartUpdateID int64
// Order history
Orders []models.Order
OrdersLoaded bool
OrderCursor int // which order is selected in the list
OrderViewState int // 0=preview, 1=browsing list, 2=viewing detail
// Static content
FAQs []FAQ // loaded from embedded faq.json
FaqFocused bool // true when FAQ detail is focused
// Splash screen
splashDataReady bool // true when products have loaded
splashDelayDone bool // true when minimum display time has elapsed
splashCursor bool // toggles for blinking cursor animation
// Confirmation screen — snapshot populated on checkout, cleared when leaving confirm page
ConfirmTotal int // server-confirmed order total in cents
ConfirmItems []models.CartItem // cart items at time of purchase
ConfirmShipping *models.Address // shipping address at time of purchase
// Per-page State
splash splashState
shop shopState
cart cartState
shipping shippingState
payment paymentState
account accountState
refund refundState
review reviewState
confirm confirmState
// Footer
footer []footerCommand
}
type shopState struct {
@@ -150,6 +149,78 @@ type shopState struct {
viewportsReady bool
}
type splashState struct {
dataReady bool
delayDone bool
cursor bool
}
type cartState struct {
cursor int
viewport viewport.Model
viewportReady bool
}
type shippingState struct {
form *ShippingFormState
view int
addressCursor int
viewport viewport.Model
viewportReady bool
}
type paymentState struct {
form *PaymentFormState
view int
cardCursor int
collectURL *string
collectCardCount int
collectBaseline time.Time
collectDeadline time.Time
collectTimeOut bool
viewport viewport.Model
viewportReady bool
}
type accountState struct {
cursor int
orderViewState int
orderCursor int
orderYOffset int // saved scroll pos when entering order detail
faqFocused bool
addressListFocused bool
cardListFocused bool
addressCursor int
cardCursor int
addressDeleting *int
cardDeleting *int
detailViewport viewport.Model
viewportReady bool
}
type reviewState struct {
cardJustAdded bool
cardWasDuplicate bool
success bool
// 3DS / SCA flow: when the backend returns 202 requires_action,
// requiresAction is true and redirectURL is the Stripe-hosted challenge
// page. The TUI shows a QR and polls GetOrderStatus(orderID) until the
// order flips to paid or failed.
requiresAction bool
redirectURL string
orderID uint
authFailed bool
}
type confirmState struct {
total int
items []models.CartItem
shipping *models.Address
viewport viewport.Model
viewportReady bool
}
var modifiedKeyMap = viewport.KeyMap{
PageDown: key.NewBinding(key.WithKeys("pgdown")),
PageUp: key.NewBinding(key.WithKeys("pgup")),
@@ -161,15 +232,122 @@ var modifiedKeyMap = viewport.KeyMap{
func (m Model) SwitchPage(p page) Model {
m.currentPage = p
m.switched = true
m.error = nil
m.notice = nil
return m
}
func (m Model) ShopSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
m.footer = []footerCommand{
{key: "j/k", value: "products"},
{key: "+/-", value: "qty"},
{key: "c", value: "cart"},
{key: "a", value: "account"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
return m, nil
}
func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage)
m = m.updateCartViewport()
if m.IsCartEmpty() {
m.footer = []footerCommand{
{key: "j/k", value: "items"},
{key: "+/-", value: "qty"},
{key: "s", value: "shop"},
{key: "a", value: "account"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
} else {
m.footer = []footerCommand{
{key: "j/k", value: "items"},
{key: "+/-", value: "qty"},
{key: "p/enter", value: "checkout"},
{key: "s", value: "shop"},
{key: "a", value: "account"},
{key: "pgup/pgdn", value: "scroll"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
}
return m, nil
}
func (m Model) AccountSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(accountPage)
m.account = accountState{}
m.footer = []footerCommand{
{key: "j/k", value: "navigate"},
{key: "enter", value: "select"},
{key: "s", value: "shop"},
{key: "?", value: "help"},
{key: "q", value: "quit"},
}
m = m.updateAccountViewport()
m.account.detailViewport.GotoTop()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
return m, nil
}
func (m Model) ShippingSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(shippingPage)
m.shipping = shippingState{}
m = m.updateShippingViewport()
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, m.fetchAddressesCmd()
}
func (m Model) PaymentSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(paymentPage)
m.payment = paymentState{}
m.SelectedCard = nil
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, m.fetchCardsCmd()
}
func (m Model) ReviewSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(reviewPage)
m.footer = []footerCommand{
{key: "enter", value: "confirm"},
{key: "esc", value: "back"},
}
return m, nil
}
func (m Model) ConfirmSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(confirmPage)
m.confirm = confirmState{}
m = m.updateConfirmViewport()
m.footer = []footerCommand{
{key: "esc", value: "back to shop"},
{key: "q", value: "quit"},
}
return m, nil
}
// inCartFlow returns true when the user is anywhere in the cart or checkout flow
func (m Model) inCartFlow() bool {
return m.currentPage == cartPage ||
m.currentPage == shippingPage ||
m.currentPage == paymentPage ||
m.currentPage == confirmPage
m.currentPage == reviewPage
}
func (m Model) checkoutStep() int {
@@ -178,7 +356,7 @@ func (m Model) checkoutStep() int {
return 1
case paymentPage:
return 2
case confirmPage:
case reviewPage:
return 3
default:
return 0
@@ -187,7 +365,7 @@ func (m Model) checkoutStep() int {
// updateLayout recalculates all layout variables from the raw viewport dimensions.
// This should be called whenever the terminal is resized.
func (m *Model) updateLayout(width, height int) {
func (m Model) updateLayout(width, height int) Model {
m.viewportWidth = width
m.viewportHeight = height
switch {
@@ -210,6 +388,7 @@ func (m *Model) updateLayout(width, height int) {
}
m.widthContent = m.widthContainer - 2
return m
}
// ProductsMsg is sent when products are fetched from API
@@ -235,26 +414,9 @@ func (m Model) fetchCartCmd() tea.Msg {
return CartFetchedMsg{Cart: cart, Err: err}
}
// fetchProductsCmd fetches products from the API
func (m Model) fetchProductsCmd() tea.Msg {
if m.APIClient == nil {
log.Println("[TUI] APIClient is nil, using fallback products")
return ProductsMsg{Err: nil} // No API client, use fallback
}
log.Printf("[TUI] Fetching products from API at %s", m.APIClient.BaseURL)
products, err := m.APIClient.GetProducts()
if err != nil {
log.Printf("[TUI] Failed to fetch products: %v", err)
} else {
log.Printf("[TUI] Loaded %d products from API", len(products))
}
return ProductsMsg{Products: products, Err: err}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.splashAuthCmd,
m.authCmd,
tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
return DelayCompleteMsg{}
}),
@@ -264,18 +426,19 @@ func (m Model) Init() tea.Cmd {
)
}
func (m Model) resetPageState() Model {
m.ShippingForm = nil
m.PaymentForm = nil
m.ScrollOffset = 0
m.OrderViewState = 0
m.FaqFocused = false
return m
type CheckoutResultMsg struct {
OrderID uint
Total int
RequiresAction bool
RedirectURL string
Err error
}
type CheckoutResultMsg struct {
// OrderStatusMsg carries the result of a poll against /orders/{id}/status.
// Emitted on the 2s tick while waiting for 3DS authentication to complete.
type OrderStatusMsg struct {
OrderID uint
Total int
Status string
Err error
}
@@ -437,6 +600,38 @@ func (m Model) deleteCardCmd(id uint) tea.Cmd {
}
}
// resolveCartAddress sets the shipping address on the cart, creating it first
// if the address had no ID yet (i.e the user typed it in fresh rather than picking
// from saved addresses)
func (m Model) resolveCartAddress() error {
if m.ShippingInfo == nil {
return nil
}
if m.ShippingInfo.ID != 0 {
if err := m.APIClient.SetCartAddress(m.ShippingInfo.ID); err != nil {
return fmt.Errorf("failed to set address: %w", err)
}
return nil
}
saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{
Name: m.ShippingInfo.Name,
Street: m.ShippingInfo.Street,
Street2: m.ShippingInfo.Street2,
City: m.ShippingInfo.City,
State: m.ShippingInfo.State,
Zip: m.ShippingInfo.Zip,
Country: m.ShippingInfo.Country,
Phone: m.ShippingInfo.Phone,
})
if err != nil {
return fmt.Errorf("failed to save address: %w", err)
}
if err := m.APIClient.SetCartAddress(saved.ID); err != nil {
return fmt.Errorf("failed to set address: %w", err)
}
return nil
}
// checkoutWithSavedCard sets address and saved card on the cart, then converts.
// Used when the user selects an existing card from the list (no tokenization needed).
func (m Model) checkoutWithSavedCard() tea.Cmd {
@@ -445,28 +640,8 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
return CheckoutResultMsg{Err: fmt.Errorf("API client or card not available")}
}
// 1. Set the shipping address on the cart
if m.ShippingInfo != nil && m.ShippingInfo.ID != 0 {
if err := m.APIClient.SetCartAddress(m.ShippingInfo.ID); err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
}
} else if m.ShippingInfo != nil {
saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{
Name: m.ShippingInfo.Name,
Street: m.ShippingInfo.Street,
Street2: m.ShippingInfo.Street2,
City: m.ShippingInfo.City,
State: m.ShippingInfo.State,
Zip: m.ShippingInfo.Zip,
Country: m.ShippingInfo.Country,
Phone: m.ShippingInfo.Phone,
})
if err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to save address: %w", err)}
}
if err := m.APIClient.SetCartAddress(saved.ID); err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
}
if err := m.resolveCartAddress(); err != nil {
return CheckoutResultMsg{Err: err}
}
// 2. Set the saved card on the cart
@@ -475,24 +650,47 @@ func (m Model) checkoutWithSavedCard() tea.Cmd {
}
// 3. Convert the cart to an order
order, err := m.APIClient.ConvertCart()
outcome, err := m.APIClient.ConvertCart()
if err != nil {
return CheckoutResultMsg{Err: err}
}
return CheckoutResultMsg{OrderID: order.ID, Total: order.Total}
if outcome.RequiresAction {
return CheckoutResultMsg{
OrderID: outcome.OrderID,
RequiresAction: true,
RedirectURL: outcome.RedirectURL,
}
}
return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total}
}
}
func (m *Model) syncCartItemCmd(coffeeID uint, quantity int) tea.Cmd {
// schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while
// waiting for the customer to complete 3DS authentication.
func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
if m.APIClient == nil {
return OrderStatusMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
}
status, err := m.APIClient.GetOrderStatus(orderID)
if err != nil {
return OrderStatusMsg{OrderID: orderID, Err: err}
}
return OrderStatusMsg{OrderID: orderID, Status: status}
})
}
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
if m.APIClient == nil || m.User == nil {
return nil
return m, nil
}
updateID := time.Now().UTC().UnixMilli()
m.lastCartUpdateID = updateID
return func() tea.Msg {
return m, func() tea.Msg {
cart, err := m.APIClient.SetCartItem(coffeeID, quantity)
return CartSyncedMsg{
UpdateID: updateID,
@@ -502,14 +700,11 @@ func (m *Model) syncCartItemCmd(coffeeID uint, quantity int) tea.Cmd {
}
}
// saveCardAndConvert tokenizes the card locally using the Stripe publishable
// key (raw card data never reaches the backend), saves the token, then converts the cart
func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
func (m Model) saveCardOnlyCmd(form PaymentFormCompleteMsg) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil {
return CheckoutResultMsg{Err: fmt.Errorf("API client not available")}
return CardSavedForReviewMsg{Err: fmt.Errorf("API client not available")}
}
stripe.Key = m.StripePublicKey
tok, err := stripetoken.New(&stripe.TokenParams{
Card: &stripe.CardParams{
@@ -523,64 +718,24 @@ func (m Model) saveCardAndConvert(form PaymentFormCompleteMsg) tea.Cmd {
})
if err != nil {
if stripeErr, ok := err.(*stripe.Error); ok {
return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
return CardSavedForReviewMsg{Err: fmt.Errorf("%s", stripeErr.Msg)}
}
return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
return CardSavedForReviewMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)}
}
card, err := m.APIClient.SaveCard(api.SaveCardRequest{Token: tok.ID})
if err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)}
}
// 2. Set the shipping address on the cart
if m.ShippingInfo != nil && m.ShippingInfo.ID != 0 {
if err := m.APIClient.SetCartAddress(m.ShippingInfo.ID); err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
}
} else if m.ShippingInfo != nil {
// Address was entered fresh (not from saved list), save it first
saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{
Name: m.ShippingInfo.Name,
Street: m.ShippingInfo.Street,
Street2: m.ShippingInfo.Street2,
City: m.ShippingInfo.City,
State: m.ShippingInfo.State,
Zip: m.ShippingInfo.Zip,
Country: m.ShippingInfo.Country,
Phone: m.ShippingInfo.Phone,
})
if err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to save address: %w", err)}
}
if err := m.APIClient.SetCartAddress(saved.ID); err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
}
}
// 3. Set the card on the cart
if err := m.APIClient.SetCartCard(card.ID); err != nil {
return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)}
}
// 4. Convert the cart to an order
order, err := m.APIClient.ConvertCart()
if err != nil {
return CheckoutResultMsg{Err: err}
}
return CheckoutResultMsg{
OrderID: order.ID,
Total: order.Total,
return CardSavedForReviewMsg{Err: fmt.Errorf("failed to save card: %w", err)}
}
return CardSavedForReviewMsg{Card: *card}
}
}
// loadCartFromAPI populates the in-memory cart map from an API CartData response
// It matches cart items to local Coffees by ID so the Coffee struct is populated
func (m *Model) loadCartFromAPI(cartData *api.CartData) {
func (m Model) loadCartFromAPI(cartData *api.CartData) Model {
m.Cart = make(map[uint]*models.CartItem)
if cartData == nil {
return
return m
}
for _, item := range cartData.Items {
@@ -591,48 +746,68 @@ func (m *Model) loadCartFromAPI(cartData *api.CartData) {
}
}
if m.CartCursor >= len(m.Cart) {
m.CartCursor = 0
if m.cart.cursor >= len(m.Cart) {
m.cart.cursor = 0
}
return m
}
// GetCartItemsSlice converts the cart map to a sorted slice for consistent iteration.
func (m Model) GetCartItemsSlice() []*models.CartItem {
// Get keys and sort them for stable ordering
keys := make([]uint, 0, len(m.Cart))
for key := range m.Cart {
keys = append(keys, key)
for cartKey := range m.Cart {
keys = append(keys, cartKey)
}
// Sort keys to ensure consistent order
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[i] > keys[j] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
// Build items slice in sorted order
items := make([]*models.CartItem, 0, len(m.Cart))
for _, key := range keys {
items = append(items, m.Cart[key])
for _, cartKey := range keys {
items = append(items, m.Cart[cartKey])
}
return items
}
// NewModel creates a new model using the default renderer.
func NewModel(username string) Model {
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
// IsCartEmpty returns true when the cart has no items
func (m Model) IsCartEmpty() bool {
return len(m.Cart) == 0
}
// newModelWithRenderer creates a new model with the given renderer.
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
// CartItemCount returns the total number of items (sum of quanities) in the cart
func (m Model) CartItemCount() int {
count := 0
for _, item := range m.Cart {
count += item.Quantity
}
return count
}
// CalculateSubtotal returns the cart subtotal in cents
func (m Model) CalculateSubtotal() int {
subtotal := 0
for _, item := range m.Cart {
subtotal += item.Coffee.Price * item.Quantity
}
return subtotal
}
func (m Model) SavedAddressesIsEmpty() bool {
return len(m.SavedAddresses) == 0
}
func (m Model) SavedCardsIsEmpty() bool {
return len(m.SavedCards) == 0
}
// NewModel creates a new model.
func NewModel(username string) Model {
m := Model{
Username: username,
currentPage: splashPage,
renderer: renderer,
theme: theme.BasicTheme(renderer),
theme: theme.BasicTheme(),
Coffees: []models.Coffee{
{
Name: "Espresso",
@@ -689,24 +864,19 @@ func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
},
},
Cart: make(map[uint]*models.CartItem),
CartCursor: 0,
AccountCursor: 0,
OrderCursor: 0,
OrderViewState: 0,
Loading: true,
APIClient: api.NewClient("http://localhost:8080", ""),
FAQs: LoadFaqs(),
Cart: make(map[uint]*models.CartItem),
Loading: true,
APIClient: api.NewClient("http://localhost:8080", ""),
FAQs: LoadFaqs(),
}
m.updateLayout(120, 30)
m = m.updateLayout(120, 30)
return m
}
// NewModelWithAuth creates a new model with user authentication context.
func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := newModelWithRenderer("", renderer)
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := NewModel("")
m.Fingerprint = fingerprint
m.SSHPublicKeyStr = pubKeyStr
m.AuthFingerprintKey = clientSecret
m.StripePublicKey = stripePublicKey
m.currentPage = splashPage
@@ -715,11 +885,23 @@ func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr
} else {
m.APIClient = api.NewClient("http://localhost:8000", "")
}
m.authCmd = func() tea.Msg {
token, user, err := m.APIClient.GetOrCreateToken(
fingerprint,
pubKeyStr,
clientSecret,
)
if err != nil {
return SplashAuthMsg{Err: fmt.Errorf("auth failed: %w", err)}
}
return SplashAuthMsg{Token: token, User: user}
}
return m
}
func newViewport(w, h int) viewport.Model {
return viewport.New(w, h)
return viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))
}
func (m Model) collectCardCmd() tea.Cmd {
@@ -735,18 +917,40 @@ func (m Model) collectCardCmd() tea.Cmd {
}
}
func (m Model) pollCardsCmd(cardCount int) tea.Cmd {
return tea.Tick(time.Second, func(_ time.Time) tea.Msg {
const (
ordersPollInterval = 10 * time.Second
cardPollTimeout = 10 * time.Minute
)
type OrdersPollTickMsg struct{}
func (m Model) pollOrderCmd() tea.Cmd {
return tea.Tick(ordersPollInterval, func(_ time.Time) tea.Msg {
return OrdersPollTickMsg{}
})
}
func (m Model) pollCardsCmd(baseline time.Time, baselineCount int, deadline time.Time) tea.Cmd {
return tea.Tick(time.Second, func(now time.Time) tea.Msg {
if now.After(deadline) {
return PollPaymentTimeoutMsg{}
}
if m.APIClient == nil {
return PollPaymentStatusMsg{CardCount: cardCount}
return PollPaymentStatusMsg{Baseline: baseline, BaselineCount: baselineCount, Deadline: deadline}
}
cards, err := m.APIClient.GetCards()
if err != nil {
return PollPaymentStatusMsg{CardCount: cardCount}
return PollPaymentStatusMsg{Baseline: baseline, BaselineCount: baselineCount, Deadline: deadline}
}
if len(cards) > cardCount {
return PollPaymentCompleteMsg{Cards: cards}
var newest time.Time
for _, card := range cards {
if card.UpdatedAt.After(newest) {
newest = card.UpdatedAt
}
}
return PollPaymentStatusMsg{CardCount: cardCount}
if newest.After(baseline) {
return PollPaymentCompleteMsg{Cards: cards, Duplicate: len(cards) == baselineCount}
}
return PollPaymentStatusMsg{Baseline: baseline, BaselineCount: baselineCount, Deadline: deadline}
})
}

View File

@@ -18,7 +18,7 @@ func TestModel_updateLayout(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := NewModel("")
m.updateLayout(tt.width, tt.height)
m = m.updateLayout(tt.width, tt.height)
if m.viewportWidth != tt.width {
t.Errorf("viewportWidth = %d, want %d", m.viewportWidth, tt.width)
}

View File

@@ -3,11 +3,12 @@ package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
"terminalShop/pkg/tui/qrfefe"
@@ -39,6 +40,13 @@ type PaymentFormCompleteMsg struct {
BillingZip string
}
// CardSavedForReviewMsg is sent after a new card is tokenized and saved
// The user is taken to the review page to confirm before checkout
type CardSavedForReviewMsg struct {
Card models.Card
Err error
}
// PaymentFormErrorMsg is sent when payment form validation fails
type PaymentFormErrorMsg struct {
Message string
@@ -52,15 +60,20 @@ type PollPaymentInitMsg struct {
// PollPaymentStatusMsg is sent each polling tick while waiting for the browser
// card entry to complete.
type PollPaymentStatusMsg struct {
CardCount int
Baseline time.Time
BaselineCount int
Deadline time.Time
}
// PollPaymentCompleteMsg is sent when the card count increases, meaning the
// webhook saved the new card
type PollPaymentCompleteMsg struct {
Cards []models.Card
Cards []models.Card
Duplicate bool
}
type PollPaymentTimeoutMsg struct{}
// buildPaymentForm creates the huh form bound to state's fields.
// No field-level validators so Tab/Enter move freely.
func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
@@ -195,7 +208,8 @@ func validatePaymentForm(state *PaymentFormState) string {
// UpdatePaymentForm handles updates to the payment form.
// Mutates state in place, returns only the tea.Cmd.
func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
if _, ok := msg.(tea.WindowSizeMsg); ok {
switch _, ok := msg.(tea.WindowSizeMsg); {
case ok:
if m.size < medium {
state.form = state.form.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
} else {
@@ -265,108 +279,92 @@ func (m Model) updatePaymentViewport() Model {
if availH < 1 {
availH = 1
}
if !m.paymentVPReady {
m.paymentVP = viewport.New(m.widthContent, availH)
m.paymentVP.KeyMap = viewport.KeyMap{}
m.paymentVPReady = true
if !m.payment.viewportReady {
m.payment.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.payment.viewport.KeyMap = viewport.KeyMap{}
m.payment.viewportReady = true
} else {
m.paymentVP.Width = m.widthContent
m.paymentVP.Height = availH
m.payment.viewport.SetWidth(m.widthContent)
m.payment.viewport.SetHeight(availH)
}
return m
}
func (m Model) PaymentPageView() string {
if !m.paymentVPReady {
if !m.payment.viewportReady {
m = m.updatePaymentViewport()
}
var content string
if m.CheckingOut {
content = " submitting order..."
} else if m.PaymentView == 2 {
} else if m.payment.view == 2 {
// Bypass viewport — it strips the raw ANSI codes qrfefe emits.
return lipgloss.Place(
m.widthContainer,
m.paymentVP.Height,
m.payment.viewport.Height(),
lipgloss.Center, lipgloss.Center,
m.renderPaymentHttpsView(),
)
} else if m.PaymentView == 0 && m.PaymentForm == nil {
} else if m.payment.view == 0 && m.payment.form == nil {
content = m.RenderCardList()
m.paymentVP.SetContent(content)
m.payment.viewport.SetContent(content)
itemHeight := 3
targetY := m.CardCursor * itemHeight
if targetY < m.paymentVP.YOffset {
m.paymentVP.SetYOffset(targetY)
targetY := m.payment.cardCursor * itemHeight
if targetY < m.payment.viewport.YOffset() {
m.payment.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.paymentVP.YOffset+m.paymentVP.Height {
m.paymentVP.SetYOffset(targetY - m.paymentVP.Height + itemHeight + 1)
if targetY+itemHeight > m.payment.viewport.YOffset()+m.payment.viewport.Height() {
m.payment.viewport.SetYOffset(targetY - m.payment.viewport.Height() + itemHeight + 1)
}
if m.CardCursor == len(m.SavedCards) {
m.paymentVP.GotoBottom()
if m.payment.cardCursor == len(m.SavedCards) {
m.payment.viewport.GotoBottom()
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.paymentVP.View()),
lipgloss.Height(m.payment.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.paymentVP.View(),
m.payment.viewport.View(),
)
} else if m.PaymentForm != nil {
content = m.RenderPaymentForm(m.PaymentForm)
} else if m.payment.form != nil {
content = m.RenderPaymentForm(m.payment.form)
}
m.paymentVP.SetContent(content)
m.payment.viewport.SetContent(content)
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.paymentVP.View()),
lipgloss.Height(m.payment.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.paymentVP.View(),
m.payment.viewport.View(),
)
}
func (m Model) RenderCardList() string {
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
activeStyle := m.theme.TextAccent().Bold(true)
inactiveStyle := m.theme.TextDim()
labelStyle := m.theme.TextLabel()
title := titleStyle.Render("Select Payment Method")
var lines []string
var boxes []string
for i, card := range m.SavedCards {
cursor := " "
style := inactiveStyle
if i == m.CardCursor {
cursor = "> "
style = activeStyle
}
focused := i == m.payment.cardCursor
brand := style.Render(strings.ToUpper(card.Brand[:1]) + card.Brand[1:])
last4 := labelStyle.Render(fmt.Sprintf("**** **** **** %s", card.Last4))
exp := labelStyle.Render(fmt.Sprintf("Expires %02d/%02d", card.ExpMonth, card.ExpYear%100))
brand := strings.ToUpper(card.Brand[:1]) + card.Brand[1:]
last4 := fmt.Sprintf("**** **** **** %s", card.Last4)
exp := fmt.Sprintf("Expires %02d/%02d", card.ExpMonth, card.ExpYear%100)
content := brand + " " + last4 + "\n" + exp
lines = append(lines, cursor+brand+" "+last4)
lines = append(lines, " "+exp)
lines = append(lines, "")
box := m.CreateBox(m.formatListItem(content, focused), focused)
boxes = append(boxes, box)
}
sshCursor := " "
sshStyle := inactiveStyle
if m.CardCursor == len(m.SavedCards) {
sshCursor = "> "
sshStyle = activeStyle
}
lines = append(lines, sshCursor+sshStyle.Render("+ add card via ssh"))
sshFocused := m.payment.cardCursor == len(m.SavedCards)
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
boxes = append(boxes, sshBox)
httpsCursor := " "
httpsStyle := inactiveStyle
if m.CardCursor == len(m.SavedCards)+1 {
httpsCursor = "> "
httpsStyle = activeStyle
}
lines = append(lines, httpsCursor+httpsStyle.Render("+ add payment via browser"))
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox)
parts := []string{title}
parts = append(parts, strings.Join(lines, "\n"))
parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, boxes...))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
@@ -375,22 +373,29 @@ func (m Model) renderPaymentHttpsView() string {
baseStyle := m.theme.TextLabel()
accentStyle := m.theme.TextHighlight()
if m.CollectURL == nil {
if m.payment.collectTimeOut {
minutes := int(cardPollTimeout / time.Minute)
return m.theme.TextError().Bold(true).Render("payment link timed out") + "\n\n" +
baseStyle.Render(fmt.Sprintf("no card saved after %d minutes.", minutes)) + "\n" +
baseStyle.Render("press ") + accentStyle.Render("esc") + baseStyle.Render(" to go back and try again")
}
if m.payment.collectURL == nil {
return baseStyle.Render(" generating payment link...")
}
qr, qrSize, err := qrfefe.Generate(0, *m.CollectURL)
qr, qrSize, err := qrfefe.Generate(*m.payment.collectURL)
if err != nil || qrSize > m.widthContent {
// QR too wide for the terminal — just show the URL.
return baseStyle.Render("open in browser to add payment information:") + "\n\n" +
accentStyle.Render(*m.CollectURL) + "\n"
accentStyle.Render(*m.payment.collectURL) + "\n"
}
// Concatenate manually — lipgloss layout functions (JoinVertical with Center)
// measure string widths and add padding that corrupts qrfefe's raw ANSI codes.
return qr +
baseStyle.Render("scan or visit to add payment information") + "\n" +
accentStyle.Render(*m.CollectURL) + "\n"
accentStyle.Render(*m.payment.collectURL) + "\n"
}
// PaymentUpdate handles all messages while on the payment page.
@@ -403,125 +408,153 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
} else {
m.SavedCards = msg.Cards
}
m.PaymentView = 0
m.CardCursor = 0
m.PaymentForm = nil
m.payment.view = 0
m.payment.cardCursor = 0
m.payment.form = nil
return m, nil
case PaymentFormCompleteMsg:
if m.PaymentForm != nil {
m.PaymentForm.submitting = true
m.CheckingOut = true
return m, m.saveCardAndConvert(msg)
if m.payment.form != nil && !m.payment.form.submitting {
m.payment.form.submitting = true
return m, m.saveCardOnlyCmd(msg)
}
return m, nil
case CheckoutResultMsg:
m.CheckingOut = false
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err)
if len(m.SavedCards) > 0 {
m.PaymentView = 0
m.PaymentForm = nil
return m, nil
}
// Re-open the form so the user can retry with corrected details.
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
}
// Snapshot order details before clearing cart/shipping
m.ConfirmTotal = msg.Total
for _, item := range m.GetCartItemsSlice() {
m.ConfirmItems = append(m.ConfirmItems, *item)
}
m.ConfirmShipping = m.ShippingInfo
// Clear cart and shipping immediately so they don't appear stale elsewhere
m.Cart = make(map[uint]*models.CartItem)
m.CartCursor = 0
m.ShippingInfo = nil
m.OrdersLoaded = false
m = m.SwitchPage(confirmPage)
return m, nil
case PaymentFormErrorMsg:
m.ErrorMsg = msg.Message
m.error = &VisibleError{message: msg.Message}
return m, nil
case CardSavedForReviewMsg:
if m.payment.form != nil {
m.payment.form.submitting = false
}
if msg.Err != nil {
m.error = &VisibleError{message: fmt.Sprintf("failed to save card: %v", msg.Err)}
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init()
}
m.SavedCards = append(m.SavedCards, msg.Card)
selected := msg.Card
m.SelectedCard = &selected
m.payment.form = nil
m.review.cardJustAdded = true
return m.ReviewSwitch()
case PollPaymentInitMsg:
url := msg.URL
m.CollectURL = &url
return m, m.pollCardsCmd(m.CollectCardCount)
m.payment.collectURL = &url
return m, m.pollCardsCmd(m.payment.collectBaseline, m.payment.collectCardCount, m.payment.collectDeadline)
case PollPaymentStatusMsg:
if m.PaymentView != 2 {
if m.payment.view != 2 {
return m, nil // user navigated away, stop polling
}
return m, m.pollCardsCmd(msg.CardCount)
return m, m.pollCardsCmd(msg.Baseline, msg.BaselineCount, msg.Deadline)
case PollPaymentTimeoutMsg:
if m.payment.view != 2 {
return m, nil
}
m.payment.collectTimeOut = true
return m, nil
case PollPaymentCompleteMsg:
if m.PaymentView != 2 {
if m.payment.view != 2 {
return m, nil
}
m.SavedCards = msg.Cards
m.PaymentView = 0
m.CollectURL = nil
selected := m.SavedCards[len(m.SavedCards)-1]
m.payment.view = 0
m.payment.collectURL = nil
var selected models.Card
if msg.Duplicate {
for _, card := range m.SavedCards {
if selected.ID == 0 || card.UpdatedAt.After(selected.UpdatedAt) {
selected = card
}
}
} else {
selected = m.SavedCards[len(m.SavedCards)-1]
}
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
m.review.cardJustAdded = true
m.review.cardWasDuplicate = msg.Duplicate
return m.ReviewSwitch()
}
// Card list navigation
if m.PaymentView == 0 && m.PaymentForm == nil {
if m.payment.view == 0 && m.payment.form == nil {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok || m.CheckingOut {
return m, nil
}
m.ErrorMsg = ""
m.error = nil
switch keyMsg.String() {
case "esc":
m = m.SwitchPage(shippingPage)
return m, m.fetchAddressesCmd()
return m.ShippingSwitch()
case "up", "k":
if m.CardCursor > 0 {
m.CardCursor--
if m.payment.cardCursor > 0 {
m.payment.cardCursor--
}
case "down", "j":
if m.CardCursor < len(m.SavedCards)+1 {
m.CardCursor++
if m.payment.cardCursor < len(m.SavedCards)+1 {
m.payment.cardCursor++
}
case "enter":
if m.CardCursor < len(m.SavedCards) {
selected := m.SavedCards[m.CardCursor]
if m.payment.cardCursor < len(m.SavedCards) {
selected := m.SavedCards[m.payment.cardCursor]
m.SelectedCard = &selected
m.CheckingOut = true
return m, m.checkoutWithSavedCard()
return m.ReviewSwitch()
}
if m.CardCursor == len(m.SavedCards) {
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init()
if m.payment.cardCursor == len(m.SavedCards) {
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init()
}
// Browser payment
m.PaymentView = 2
m.CollectURL = nil
m.CollectCardCount = len(m.SavedCards)
m.payment.view = 2
m.payment.collectURL = nil
m.payment.collectTimeOut = false
m.payment.collectCardCount = len(m.SavedCards)
var maxT time.Time
for _, card := range m.SavedCards {
if card.UpdatedAt.After(maxT) {
maxT = card.UpdatedAt
}
}
m.payment.collectBaseline = maxT
m.payment.collectDeadline = time.Now().Add(cardPollTimeout)
m.footer = []footerCommand{
{key: "esc", value: "back"},
}
return m, m.collectCardCmd()
case "d", "x":
if m.CardCursor < len(m.SavedCards) {
card := m.SavedCards[m.CardCursor]
m.SavedCards = append(m.SavedCards[:m.CardCursor], m.SavedCards[m.CardCursor+1:]...)
if m.CardCursor >= len(m.SavedCards) && m.CardCursor > 0 {
m.CardCursor--
if m.payment.cardCursor < len(m.SavedCards) {
card := m.SavedCards[m.payment.cardCursor]
m.SavedCards = append(m.SavedCards[:m.payment.cardCursor], m.SavedCards[m.payment.cardCursor+1:]...)
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.payment.cardCursor--
}
if len(m.SavedCards) == 0 {
m.PaymentView = 1
m.PaymentForm = m.InitPaymentForm()
return m, tea.Batch(m.PaymentForm.form.Init(), m.deleteCardCmd(card.ID))
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
}
return m, m.deleteCardCmd(card.ID)
}
@@ -529,34 +562,47 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
return m, nil
}
// HTTPS/browser view avigation
if m.PaymentView == 2 {
// HTTPS/browser view navigation
if m.payment.view == 2 {
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
m.PaymentView = 0
m.CollectURL = nil
m.payment.view = 0
m.payment.collectURL = nil
m.payment.collectTimeOut = false
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil
}
return m, nil
}
// Payment form navigation
if m.PaymentForm != nil {
if m.payment.form != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
m.ErrorMsg = ""
m.error = nil
if len(m.SavedCards) > 0 {
m.PaymentView = 0
m.PaymentForm = nil
m.payment.view = 0
m.payment.form = nil
m.footer = []footerCommand{
{key: "j/k", value: "cards"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil
}
m.PaymentForm = nil
m.payment.form = nil
m = m.SwitchPage(shippingPage)
return m, m.fetchAddressesCmd()
}
// Clear error only when user starts typing, not on internal huh messages
m.ErrorMsg = ""
m.error = nil
}
return m, m.UpdatePaymentForm(msg, m.PaymentForm)
return m, m.UpdatePaymentForm(msg, m.payment.form)
}
return m, nil
}

View File

@@ -6,13 +6,14 @@ import (
"rsc.io/qr"
)
var tops_bottoms = []rune{' ', '▀', '▄', '█'}
var topsBottoms = []rune{' ', '▀', '▄', '█'}
func Generate(size int, text string) (string, int, error) {
func Generate(text string) (string, int, error) {
return generate(qr.L, text)
}
func generate(level qr.Level, text string) (string, int, error) {
// How to mark this as not re-used code for JetBrains, it's from the reference
code, err := qr.Encode(text, qr.Level(level))
if err != nil {
return "", 0, err
@@ -20,7 +21,7 @@ func generate(level qr.Level, text string) (string, int, error) {
qrRunes := make([]rune, 0)
for y := 0; y < code.Size-1; y+= 2 {
for y := 0; y < code.Size-1; y += 2 {
qrRunes = append(qrRunes, []rune(sgr.FgWhite+sgr.BgBlack)...)
for x := 0; x < code.Size; x += 1 {
@@ -32,7 +33,7 @@ func generate(level qr.Level, text string) (string, int, error) {
if code.Black(x, y+1) { // bottom pixel black
num += 2
}
qrRunes = append(qrRunes, tops_bottoms[num])
qrRunes = append(qrRunes, topsBottoms[num])
}
qrRunes = append(qrRunes, []rune(sgr.Reset)...)

395
pkg/tui/refund.go Normal file
View File

@@ -0,0 +1,395 @@
package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
)
// Refund composer layout constants.
const (
refundMinTextareaRows = 5
refundMaxTextareaRows = 10
// chrome above the textarea inside the modal (title, label, padding, footer).
refundModalChrome = 12
refundModalMaxWidth = 58
refundModalMinWidth = 24
// horizontal chrome the modal subtracts from m.widthContent.
refundModalHGutter = 6
// inner padding around the textarea inside the modal body.
refundTextareaHPad = 4
)
// Nerd-font glyphs used in the refund composer. Requires a Nerd Font in the
// SSH client terminal; renders as tofu otherwise.
const (
refundIconUndo = "\uf0e2" // nf-fa-undo
refundIconTag = "\uf02b" // nf-oct-tag
refundIconComment = "\uf41f" // nf-oct-comment
refundIconKey = "\uf11c" // nf-fa-keyboard_o
)
// refundFocus identifies which field in the refund composer has keyboard focus.
type refundFocus int
const (
refundFocusReason refundFocus = iota
refundFocusMessage
refundFocusCount
)
type refundState struct {
open bool
orderID uint
gen uint64 // bumped each open so late async results from a prior session are ignored
reason string
form *huh.Form
textarea textarea.Model
focusIdx refundFocus
submitting bool
err string
reasonErr string
messageErr string
}
type refundRequestMsg struct {
OrderID uint
Gen uint64
Err error
}
// OpenRefundComposer opens the refund request composer for an order.
func (m Model) OpenRefundComposer(orderID uint) (Model, tea.Cmd) {
state := refundState{
open: true,
orderID: orderID,
gen: m.refund.gen + 1,
}
state.textarea = m.newRefundTextarea()
state.form = m.buildRefundForm(&state)
m.refund = state
return m, m.refund.form.Init()
}
// RefundUpdate handles all input and async results while the refund composer is open.
func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
state := &m.refund
if !state.open {
return m, nil
}
switch msg := msg.(type) {
case refundRequestMsg:
if msg.Gen != state.gen || msg.OrderID != state.orderID {
return m, nil
}
state.submitting = false
if msg.Err != nil {
state.err = fmt.Sprintf("failed to send refund request: %v", msg.Err)
return m, nil
}
gen := state.gen
m.refund = refundState{gen: gen}
m.notice = &VisibleNotice{message: "Refund request sent"}
return m, nil
case tea.WindowSizeMsg:
state.form = m.buildRefundForm(state)
m.resizeRefundTextarea(state)
return m, nil
case tea.KeyMsg:
if state.submitting {
return m, nil
}
switch msg.String() {
case "esc":
gen := state.gen
m.refund = refundState{gen: gen}
return m, nil
case "ctrl+s":
return m, m.submitRefundRequest(state)
case "tab", "shift+tab":
return m, m.setRefundFocus(state, (state.focusIdx+1)%refundFocusCount)
}
}
if state.focusIdx == refundFocusReason {
state.err = ""
state.reasonErr = ""
form, cmd := state.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
state.form = f
}
if state.form.State == huh.StateCompleted {
state.form = m.buildRefundForm(state)
return m, m.setRefundFocus(state, refundFocusMessage)
}
return m, cmd
}
if _, ok := msg.(tea.KeyMsg); ok {
state.err = ""
state.messageErr = ""
}
ta, cmd := state.textarea.Update(msg)
state.textarea = ta
return m, cmd
}
func (m Model) buildRefundForm(state *refundState) *huh.Form {
options := make([]huh.Option[string], 0, len(models.RefundRequestReasons))
for _, reason := range models.RefundRequestReasons {
options = append(options, huh.NewOption(reason, reason))
}
return huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Reason").
Key("reason").
Options(options...).
Value(&state.reason).
Height(len(models.RefundRequestReasons) + 1),
),
).
WithShowErrors(false).
WithShowHelp(false).
WithTheme(m.theme.Form()).
WithLayout(huh.LayoutStack).
WithWidth(m.refundContentWidth())
}
func (m Model) newRefundTextarea() textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.Placeholder = "Describe the issue"
ta.ShowLineNumbers = true
ta.CharLimit = 65536
ta.DynamicHeight = false
ta.SetHeight(m.refundTextareaHeight())
ta.SetWidth(m.refundTextareaWidth())
base := lipgloss.NewStyle()
// Cursor-line uses a darker grey than theme.Border() so the active line
// reads as a subtle wash, not a bright bar competing with the highlight.
cursorLineBG := lipgloss.Color("236")
ta.SetStyles(textarea.Styles{
Focused: textarea.StyleState{
Base: base,
Text: base.Foreground(m.theme.Accent()),
LineNumber: base.Foreground(m.theme.Dim()),
CursorLine: base.Background(cursorLineBG).Foreground(m.theme.Accent()),
CursorLineNumber: base.Foreground(m.theme.Body()),
Placeholder: base.Foreground(m.theme.Dim()),
EndOfBuffer: base.Foreground(m.theme.Dim()),
},
Blurred: textarea.StyleState{
Base: base,
Text: base.Foreground(m.theme.Body()),
LineNumber: base.Foreground(m.theme.Dim()),
CursorLine: base.Foreground(m.theme.Background()),
CursorLineNumber: base.Foreground(m.theme.Dim()),
Placeholder: base.Foreground(m.theme.Dim()),
EndOfBuffer: base.Foreground(m.theme.Dim()),
},
Cursor: textarea.CursorStyle{
Color: m.theme.Highlight(),
Shape: tea.CursorBlock,
Blink: true,
},
})
return ta
}
func (m Model) resizeRefundTextarea(state *refundState) {
state.textarea.SetHeight(m.refundTextareaHeight())
state.textarea.SetWidth(m.refundTextareaWidth())
}
func (m Model) setRefundFocus(state *refundState, focusIdx refundFocus) tea.Cmd {
state.focusIdx = focusIdx
if focusIdx == refundFocusReason {
state.textarea.Blur()
state.form = m.buildRefundForm(state)
return state.form.Init()
}
return state.textarea.Focus()
}
func (m Model) submitRefundRequest(state *refundState) tea.Cmd {
state.err = ""
state.reasonErr = ""
state.messageErr = ""
reason := strings.TrimSpace(state.reason)
message := strings.TrimSpace(state.textarea.Value())
if reason == "" {
state.reasonErr = "select a refund reason"
return nil
}
if reason == models.RefundRequestReasonOther && message == "" {
state.messageErr = "message is required when reason is other"
return nil
}
state.submitting = true
return m.createRefundRequestCmd(state.orderID, state.gen, reason, message)
}
func (m Model) createRefundRequestCmd(orderID uint, gen uint64, reason string, message string) tea.Cmd {
return func() tea.Msg {
if m.APIClient == nil || m.User == nil {
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: fmt.Errorf("not authenticated")}
}
err := m.APIClient.CreateRefundRequest(orderID, api.RefundRequest{
Reason: reason,
Message: message,
})
return refundRequestMsg{OrderID: orderID, Gen: gen, Err: err}
}
}
// RenderRefundOverlay renders the refund composer as a centered modal.
// The modal has no border or background fill — only padding for breathing
// room. lipgloss.Place handles centering against the container.
func (m Model) RenderRefundOverlay() string {
state := &m.refund
contentWidth := m.refundContentWidth()
titleText := fmt.Sprintf("%s Refund request — Order #%d", refundIconUndo, state.orderID)
title := m.theme.TextAccent().
Bold(true).
Width(contentWidth).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(m.theme.Highlight()).
Render(titleText)
body := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
m.renderRefundReason(state),
"",
m.renderRefundTextarea(state),
"",
m.renderRefundFooter(state),
)
modal := lipgloss.NewStyle().
Padding(1, 2).
Render(body)
return lipgloss.Place(m.widthContainer, m.heightContainer, lipgloss.Center, lipgloss.Center, modal)
}
func (m Model) renderRefundReason(state *refundState) string {
if state.focusIdx == 0 && state.form != nil {
out := state.form.View()
if state.reasonErr != "" {
out += "\n" + m.theme.TextError().Render(state.reasonErr)
}
return out
}
value := state.reason
if value == "" {
value = "Select a reason"
}
label := m.theme.TextLabel().Render(refundIconTag + " Reason")
selected := m.theme.TextAccent().
Border(lipgloss.HiddenBorder()).
PaddingLeft(1).
Width(m.refundContentWidth()).
Render(value)
out := lipgloss.JoinVertical(lipgloss.Left, label, selected)
if state.reasonErr != "" {
out += "\n" + m.theme.TextError().Render(state.reasonErr)
}
return out
}
func (m Model) renderRefundTextarea(state *refundState) string {
label := "Message (optional)"
placeholder := "Describe the issue"
if state.reason == models.RefundRequestReasonOther {
label = "Message (required)"
placeholder = "Describe the issue..."
}
state.textarea.Placeholder = placeholder
border := m.theme.Border()
if state.focusIdx == refundFocusMessage {
border = m.theme.Highlight()
}
wrapper := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderForeground(border)
contentWidth := m.refundContentWidth()
state.textarea.SetWidth(contentWidth - wrapper.GetHorizontalFrameSize())
state.textarea.SetHeight(m.refundTextareaHeight())
textareaBox := wrapper.Render(state.textarea.View())
counter := m.theme.TextDim().
Width(contentWidth).
Align(lipgloss.Right).
Render(fmt.Sprintf("%d chars", state.textarea.Length()))
out := lipgloss.JoinVertical(
lipgloss.Left,
m.theme.TextLabel().Render(refundIconComment+" "+label),
textareaBox,
counter,
)
if state.messageErr != "" {
out += "\n" + m.theme.TextError().Render(state.messageErr)
}
return out
}
func (m Model) renderRefundFooter(state *refundState) string {
if state.submitting {
return m.theme.TextAccent().Render("sending refund request...")
}
if state.err != "" {
return m.theme.TextError().Render(state.err)
}
key := m.theme.TextHighlight().Bold(true)
sep := m.theme.TextDim().Render(" · ")
dim := m.theme.TextDim()
return dim.Render(refundIconKey+" ") +
key.Render("tab") + dim.Render(" next") + sep +
key.Render("shift+tab") + dim.Render(" prev") + sep +
key.Render("ctrl+s") + dim.Render(" submit") + sep +
key.Render("esc") + dim.Render(" cancel")
}
func (m Model) refundContentWidth() int {
width := min(m.widthContent-refundModalHGutter, refundModalMaxWidth)
if width < refundModalMinWidth {
width = max(1, m.widthContent-refundTextareaHPad)
}
return width
}
func (m Model) refundTextareaWidth() int {
return max(1, m.refundContentWidth()-refundTextareaHPad)
}
func (m Model) refundTextareaHeight() int {
return max(refundMinTextareaRows, min(refundMaxTextareaRows, m.heightContainer-refundModalChrome))
}

View File

@@ -6,10 +6,10 @@ import (
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
// countries defines the available countries with their dial codes.
@@ -18,21 +18,21 @@ var countries = []struct {
Code string
DialCode string
}{
{Name: "Denmark", Code: "DK", DialCode: "+45"},
{Name: "Finland", Code: "FI", DialCode: "+358"},
{Name: "Iceland", Code: "IS", DialCode: "+354"},
// {Name: "Denmark", Code: "DK", DialCode: "+45"},
// {Name: "Finland", Code: "FI", DialCode: "+358"},
// {Name: "Iceland", Code: "IS", DialCode: "+354"},
{Name: "Norway", Code: "NO", DialCode: "+47"},
{Name: "Sweden", Code: "SE", DialCode: "+46"},
// {Name: "Sweden", Code: "SE", DialCode: "+46"},
{Name: "USA", Code: "US", DialCode: "+1"},
}
// countryDialCodes maps country ISO codes to their dialing prefixes.
var countryDialCodes = map[string]string{
"DK": "+45",
"FI": "+358",
"IS": "+354",
// "DK": "+45",
// "FI": "+358",
// "IS": "+354",
"NO": "+47",
"SE": "+46",
// "SE": "+46",
"US": "+1",
}
@@ -81,6 +81,11 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
f := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Country").
Key("country").
Options(countryOptions...).
Value(&state.Country),
huh.NewInput().
Title("Full Name").
Key("name").
@@ -107,13 +112,6 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
Title("Postal Code").
Key("zip").
Value(&state.Zip),
huh.NewSelect[string]().
Title("Country").
Key("country").
Options(countryOptions...).
Value(&state.Country).
Height(5).
Filtering(true),
huh.NewInput().
Title("Phone").
Key("phone").
@@ -138,7 +136,7 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
// so that huh's Value() bindings point to stable memory.
func (m Model) InitShippingForm() *ShippingFormState {
state := &ShippingFormState{
Country: "SE",
Country: "US",
}
if m.User != nil && m.User.Name != "" {
@@ -263,87 +261,72 @@ func (m Model) updateShippingViewport() Model {
if availH < 1 {
availH = 1
}
if !m.shippingVPReady {
m.shippingVP = viewport.New(m.widthContent, availH)
m.shippingVP.KeyMap = viewport.KeyMap{}
m.shippingVPReady = true
if !m.shipping.viewportReady {
m.shipping.viewport = viewport.New(viewport.WithWidth(m.widthContent), viewport.WithHeight(availH))
m.shipping.viewport.KeyMap = viewport.KeyMap{}
m.shipping.viewportReady = true
} else {
m.shippingVP.Width = m.widthContent
m.shippingVP.Height = availH
m.shipping.viewport.SetWidth(m.widthContent)
m.shipping.viewport.SetHeight(availH)
}
return m
}
func (m Model) ShippingPageView() string {
if !m.shippingVPReady {
if !m.shipping.viewportReady {
m = m.updateShippingViewport()
}
if m.ShippingView == 0 && m.ShippingForm == nil {
if m.shipping.view == 0 && m.shipping.form == nil {
content := m.RenderAddressList()
m.shippingVP.SetContent(content)
m.shipping.viewport.SetContent(content)
itemHeight := 4
targetY := m.AddressCursor * itemHeight
if targetY < m.shippingVP.YOffset {
m.shippingVP.SetYOffset(targetY)
targetY := m.shipping.addressCursor * itemHeight
if targetY < m.shipping.viewport.YOffset() {
m.shipping.viewport.SetYOffset(targetY)
}
if targetY+itemHeight > m.shippingVP.YOffset+m.shippingVP.Height {
m.shippingVP.SetYOffset(targetY - m.shippingVP.Height + itemHeight + 1)
if targetY+itemHeight > m.shipping.viewport.YOffset()+m.shipping.viewport.Height() {
m.shipping.viewport.SetYOffset(targetY - m.shipping.viewport.Height() + itemHeight + 1)
}
if m.AddressCursor == len(m.SavedAddresses) {
m.shippingVP.GotoBottom()
if m.shipping.addressCursor == len(m.SavedAddresses) {
m.shipping.viewport.GotoBottom()
}
} else if m.ShippingForm != nil {
m.shippingVP.SetContent(m.RenderShippingForm(m.ShippingForm))
} else if m.shipping.form != nil {
m.shipping.viewport.SetContent(m.RenderShippingForm(m.shipping.form))
}
return lipgloss.Place(
m.widthContainer,
lipgloss.Height(m.shippingVP.View()),
lipgloss.Height(m.shipping.viewport.View()),
lipgloss.Center, lipgloss.Center,
m.shippingVP.View(),
m.shipping.viewport.View(),
)
}
func (m Model) RenderAddressList() string {
titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
activeStyle := m.theme.TextAccent().Bold(true)
inactiveStyle := m.theme.TextDim()
labelStyle := m.theme.TextLabel()
title := titleStyle.Render("Selected Shipping Address")
var lines []string
var boxes []string
for i, addr := range m.SavedAddresses {
cursor := " "
style := inactiveStyle
if i == m.AddressCursor {
cursor = "> "
style = activeStyle
}
focused := i == m.shipping.addressCursor
name := style.Render(addr.Name)
street := labelStyle.Render(addr.Street)
street := addr.Street
if addr.Street2 != "" {
street += ", " + labelStyle.Render(addr.Street2)
street += ", " + addr.Street2
}
city := labelStyle.Render(fmt.Sprintf("%s, %s %s", addr.City, addr.Country, addr.Zip))
city := fmt.Sprintf("%s, %s %s", addr.City, addr.Country, addr.Zip)
content := addr.Name + "\n" + street + "\n" + city
lines = append(lines, cursor+name)
lines = append(lines, " "+street)
lines = append(lines, " "+city)
lines = append(lines, "")
box := m.CreateBox(m.formatListItem(content, focused), focused)
boxes = append(boxes, box)
}
addCursor := " "
addStyle := inactiveStyle
if m.AddressCursor == len(m.SavedAddresses) {
addCursor = "> "
addStyle = activeStyle
}
lines = append(lines, addCursor+addStyle.Render("+ Add new address"))
addFocused := m.shipping.addressCursor == len(m.SavedAddresses)
addBox := m.CreateBox(m.formatListItem("+ Add new address", addFocused), addFocused)
boxes = append(boxes, addBox)
parts := []string{title}
parts = append(parts, strings.Join(lines, "\n"))
parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, boxes...))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
@@ -361,77 +344,88 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
case AddressesMsg:
if msg.Err != nil || len(msg.Addresses) == 0 {
m.SavedAddresses = nil
m.ShippingView = 1
m.ShippingForm = m.InitShippingForm()
return m, m.ShippingForm.form.Init()
m.shipping.view = 1
m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.shipping.form.form.Init()
}
m.SavedAddresses = msg.Addresses
m.ShippingView = 0
m.AddressCursor = 0
m.ShippingForm = nil
m.shipping.view = 0
m.shipping.addressCursor = 0
m.shipping.form = nil
return m, nil
case AddressSavedMsg:
if msg.Err != nil {
if m.ShippingForm != nil {
m.ShippingForm.submitting = false
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
if m.shipping.form != nil {
m.shipping.form.submitting = false
m.shipping.form.form = m.buildShippingForm(m.shipping.form)
}
m.ErrorMsg = "Invalid address. Currently only US and Norwegian addresses are supported."
return m, m.ShippingForm.form.Init()
m.error = &VisibleError{message: "Address could not be validated. Check the street, region, and postal code. US and Norway only."}
return m, m.shipping.form.form.Init()
}
m.ShippingInfo = &msg.Address
m.ShippingForm = nil
m = m.SwitchPage(paymentPage)
m.SelectedCard = nil
return m, m.fetchCardsCmd()
saved := msg.Address
m.ShippingInfo = &saved
m.SavedAddresses = append(m.SavedAddresses, saved)
return m.PaymentSwitch()
case ShippingFormErrorMsg:
m.ErrorMsg = msg.Message
m.error = &VisibleError{message: msg.Message}
return m, nil
}
// Address list navigation
if m.ShippingView == 0 && m.ShippingForm == nil {
if m.shipping.view == 0 && m.shipping.form == nil {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
m.ErrorMsg = ""
m.error = nil
switch keyMsg.String() {
case "esc":
m = m.SwitchPage(cartPage)
return m, nil
return m.CartSwitch()
case "up", "k":
if m.AddressCursor > 0 {
m.AddressCursor--
if m.shipping.addressCursor > 0 {
m.shipping.addressCursor--
}
case "down", "j":
if m.AddressCursor < len(m.SavedAddresses) {
m.AddressCursor++
if m.shipping.addressCursor < len(m.SavedAddresses) {
m.shipping.addressCursor++
}
case "enter":
if m.AddressCursor < len(m.SavedAddresses) {
selected := m.SavedAddresses[m.AddressCursor]
if m.shipping.addressCursor < len(m.SavedAddresses) {
selected := m.SavedAddresses[m.shipping.addressCursor]
m.ShippingInfo = &selected
m = m.SwitchPage(paymentPage)
m.SelectedCard = nil
return m, m.fetchCardsCmd()
return m.PaymentSwitch()
}
m.ShippingView = 1
m.ShippingForm = m.InitShippingForm()
return m, m.ShippingForm.form.Init()
m.shipping.view = 1
m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.shipping.form.form.Init()
case "d", "x":
if m.AddressCursor < len(m.SavedAddresses) {
addr := m.SavedAddresses[m.AddressCursor]
m.SavedAddresses = append(m.SavedAddresses[:m.AddressCursor], m.SavedAddresses[m.AddressCursor+1:]...)
if m.AddressCursor >= len(m.SavedAddresses) && m.AddressCursor > 0 {
m.AddressCursor--
if m.shipping.addressCursor < len(m.SavedAddresses) {
addr := m.SavedAddresses[m.shipping.addressCursor]
m.SavedAddresses = append(m.SavedAddresses[:m.shipping.addressCursor], m.SavedAddresses[m.shipping.addressCursor+1:]...)
if m.shipping.addressCursor >= len(m.SavedAddresses) && m.shipping.addressCursor > 0 {
m.shipping.addressCursor--
}
if len(m.SavedAddresses) == 0 {
m.ShippingView = 1
m.ShippingForm = m.InitShippingForm()
return m, tea.Batch(m.ShippingForm.form.Init(), m.deleteAddressCmd(addr.ID))
m.shipping.view = 1
m.shipping.form = m.InitShippingForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.shipping.form.form.Init(), m.deleteAddressCmd(addr.ID))
}
return m, m.deleteAddressCmd(addr.ID)
}
@@ -440,23 +434,29 @@ func (m Model) ShippingUpdate(msg tea.Msg) (Model, tea.Cmd) {
}
// Form navigation
if m.ShippingForm != nil {
if m.shipping.form != nil {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
m.ErrorMsg = ""
m.error = nil
if len(m.SavedAddresses) > 0 {
m.ShippingView = 0
m.ShippingForm = nil
m.shipping.view = 0
m.shipping.form = nil
m.footer = []footerCommand{
{key: "j/k", value: "addresses"},
{key: "enter", value: "select"},
{key: "d/x", value: "delete"},
{key: "esc", value: "back"},
}
return m, nil
}
m.ShippingForm = nil
m = m.SwitchPage(cartPage)
return m, nil
m.shipping.form = nil
m, cmd := m.CartSwitch()
return m, cmd
}
// Clear error only when user starts typing, not on internal huh messages
m.ErrorMsg = ""
m.error = nil
}
return m, m.UpdateShippingForm(msg, m.ShippingForm)
return m, m.UpdateShippingForm(msg, m.shipping.form)
}
return m, nil
}

View File

@@ -4,9 +4,9 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"terminalShop/pkg/models"
)
@@ -55,10 +55,10 @@ func (m Model) updateShopViewports() Model {
m.shop.detailViewport.KeyMap = modifiedKeyMap
m.shop.viewportsReady = true
} else {
m.shop.menuViewport.Width = menuWidth
m.shop.menuViewport.Height = availableHeight
m.shop.detailViewport.Width = detailWidth
m.shop.detailViewport.Height = availableHeight
m.shop.menuViewport.SetWidth(menuWidth)
m.shop.menuViewport.SetHeight(availableHeight)
m.shop.detailViewport.SetWidth(detailWidth)
m.shop.detailViewport.SetHeight(availableHeight)
}
return m
}
@@ -83,9 +83,9 @@ func (m Model) getShopMenuContent() string {
Background(selectedColor).Bold(true)
} else {
normal = m.theme.TextBody().
Width(menuWidth + 2).Padding(0, 1)
Width(menuWidth+2).Padding(0, 1)
highlighted = m.theme.TextAccent().
Width(menuWidth + 2).Padding(0, 1).
Width(menuWidth+2).Padding(0, 1).
Background(selectedColor).Bold(true)
}
@@ -180,7 +180,8 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
} else {
m.Cart[coffeeID] = &models.CartItem{CoffeeID: coffeeID, Coffee: coffee, Quantity: 1}
}
return m, m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity)
m, cmd := m.syncCartItemCmd(coffeeID, m.Cart[coffeeID].Quantity)
return m, cmd
case "-", "_":
coffeeID := coffee.ID
if item, exists := m.Cart[coffeeID]; exists {
@@ -190,7 +191,8 @@ func (m Model) ShopUpdate(msg tea.Msg) (Model, tea.Cmd) {
delete(m.Cart, coffeeID)
newQty = 0
}
return m, m.syncCartItemCmd(coffeeID, newQty)
m, cmd := m.syncCartItemCmd(coffeeID, newQty)
return m, cmd
}
}
}
@@ -224,7 +226,7 @@ func (m Model) shopMoveSelected(previous bool) Model {
if m.shop.viewportsReady {
targetY := (m.shop.selected + 2) * 1
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height/2)
m.shop.menuViewport.SetYOffset(targetY - m.shop.menuViewport.Height()/2)
m.shop.detailViewport.GotoTop()
}
return m

View File

@@ -5,9 +5,9 @@ import (
"terminalShop/pkg/api"
"terminalShop/pkg/models"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
type DelayCompleteMsg struct{}
@@ -24,18 +24,6 @@ type ViewInitMsg struct {
Err error
}
func (m Model) splashAuthCmd() tea.Msg {
token, user, err := m.APIClient.GetOrCreateToken(
m.Fingerprint,
m.SSHPublicKeyStr,
m.AuthFingerprintKey,
)
if err != nil {
return SplashAuthMsg{Err: fmt.Errorf("auth failed: %w", err)}
}
return SplashAuthMsg{Token: token, User: user}
}
func (m Model) splashViewInitCmd() tea.Msg {
data, err := m.APIClient.GetViewInit()
if err != nil {
@@ -48,13 +36,13 @@ func (m Model) SplashView() string {
accent := m.theme.TextBody().Bold(true)
var cursor string
if m.splashCursor {
if m.splash.cursor {
cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ")
} else {
cursor = " "
}
logo := accent.Render("terminal coffe") + cursor
logo := accent.Render("terminal coffee") + cursor
content := lipgloss.JoinVertical(
lipgloss.Center,

58
pkg/tui/styles.go Normal file
View File

@@ -0,0 +1,58 @@
package tui
import (
"strings"
"charm.land/lipgloss/v2"
)
func (m Model) createBoxInner(content string, selected bool, position lipgloss.Position, paddingH int, paddingV int, totalWidth int) string {
padded := lipgloss.PlaceHorizontal(totalWidth, position, content)
base := m.theme.Base().Border(lipgloss.NormalBorder()).Width(totalWidth)
var style lipgloss.Style
if selected {
style = base.BorderForeground(m.theme.Highlight())
} else {
style = base.BorderForeground(m.theme.Border())
}
return style.Padding(paddingV, paddingH).Render(padded)
}
func (m Model) CreateBox(content string, selected bool) string {
return m.createBoxInner(content, selected, lipgloss.Left, 1, 0, m.widthContent-2)
}
func (m Model) CreateBoxCustom(content string, selected bool, totalWidth int) string {
return m.createBoxInner(content, selected, lipgloss.Left, 1, 0, totalWidth)
}
func (m Model) formatListItem(text string, focused bool) string {
return m.formatListItemCustom(text, focused, m.widthContent, true)
}
func (m Model) formatListItemCustom(text string, focused bool, totalWidth int, showRadio bool) string {
accent := m.theme.TextAccent().Render
content := " " + text
hint := ""
if focused {
content = accent(" ☉ " + text)
hint = accent("enter")
}
if !showRadio {
content = text
}
padding := 6
if !showRadio {
padding = 2
}
var lines = strings.Split(content, "\n")
var firstLine = lines[0]
hintSpace := totalWidth - lipgloss.Width(hint) - lipgloss.Width(firstLine) - padding
lines[0] = firstLine + m.theme.Base().Width(hintSpace).Render() + hint
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}

View File

@@ -1,39 +1,50 @@
package theme
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"charm.land/bubbles/v2/help"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
// HuhTheme creates a huh form theme using this project's color palette.
func HuhTheme(t Theme) *huh.Theme {
var th huh.Theme
func HuhTheme(t Theme) huh.Theme {
var s huh.Styles
th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
s.FieldSeparator = lipgloss.NewStyle().SetString("\n\n")
f := &th.Focused
f.Base = t.renderer.NewStyle().
f := &s.Focused
f.Base = lipgloss.NewStyle().
PaddingLeft(1).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderForeground(t.accent)
f.Title = t.renderer.NewStyle().Foreground(t.body)
f.Description = t.renderer.NewStyle().Foreground(t.body)
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
th.Help = help.New().Styles
f.Title = lipgloss.NewStyle().Foreground(t.body)
f.Description = lipgloss.NewStyle().Foreground(t.body)
f.TextInput.Cursor = lipgloss.NewStyle().Foreground(t.highlight)
f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(t.dim)
f.TextInput.Prompt = lipgloss.NewStyle().Foreground(t.accent)
f.TextInput.Text = lipgloss.NewStyle().Foreground(t.accent)
f.ErrorIndicator = lipgloss.NewStyle().Foreground(t.error)
f.ErrorMessage = lipgloss.NewStyle().Foreground(t.error)
th.Blurred = copyFieldStyles(*f)
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
f.SelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
f.Option = lipgloss.NewStyle().Foreground(t.body)
f.SelectedOption = lipgloss.NewStyle().Foreground(t.highlight).Bold(true)
f.MultiSelectSelector = lipgloss.NewStyle().Foreground(t.accent).SetString("> ")
f.SelectedPrefix = lipgloss.NewStyle().Foreground(t.highlight).SetString("[x] ")
f.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.dim).SetString("[ ] ")
f.UnselectedOption = lipgloss.NewStyle().Foreground(t.body)
return &th
s.Help = help.New().Styles
s.Blurred = copyFieldStyles(*f)
s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
s.Blurred.Title = s.Blurred.Title.Foreground(t.body)
s.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
s.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(t.success).Bold(true)
s.Blurred.Option = lipgloss.NewStyle().Foreground(t.dim)
return huh.ThemeFunc(func(bool) *huh.Styles { return &s })
}
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }

View File

@@ -1,64 +1,83 @@
package theme
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"image/color"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/compat"
)
// Theme holds all colors and styles for the terminal coffee shop TUI.
// Theme holds all colors and styles for the TUI
type Theme struct {
renderer *lipgloss.Renderer
background lipgloss.TerminalColor
border lipgloss.TerminalColor
body lipgloss.TerminalColor
dim lipgloss.TerminalColor
accent lipgloss.TerminalColor
highlight lipgloss.TerminalColor
brand lipgloss.TerminalColor
label lipgloss.TerminalColor
success lipgloss.TerminalColor
error lipgloss.TerminalColor
background color.Color
border color.Color
body color.Color
dim color.Color
accent color.Color
highlight color.Color
brand color.Color
label color.Color
success color.Color
error color.Color
base lipgloss.Style
form *huh.Theme
form huh.Theme
}
// BasicTheme constructs the default theme for terminal coffee shop.
func BasicTheme(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
func BasicTheme() Theme {
t := Theme{}
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#000000"), Light: lipgloss.Color("#FBFCFD")}
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#666666"), Light: lipgloss.Color("#D7DBDF")}
t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#666666")
t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
t.accent = compat.AdaptiveColor{Dark: lipgloss.Color("#FFFFFF"), Light: lipgloss.Color("#11181C")}
t.highlight = lipgloss.Color("#4682B4")
t.brand = lipgloss.Color("#4682B4")
t.label = lipgloss.Color("#999999")
t.success = lipgloss.Color("#00FF00")
t.error = lipgloss.Color("196")
t.base = renderer.NewStyle().Foreground(t.body)
t.base = lipgloss.NewStyle().Foreground(t.body)
t.form = HuhTheme(t)
return t
}
// Color accessors — use in .Foreground() / .Background() calls.
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
func (t Theme) Border() lipgloss.TerminalColor { return t.border }
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
func Catppuccin() Theme {
t := Theme{}
// Style helpers — return a copy of base with the correct foreground.
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
t.background = compat.AdaptiveColor{Dark: lipgloss.Color("#1e1e2e"), Light: lipgloss.Color("#eff1f5")}
t.border = compat.AdaptiveColor{Dark: lipgloss.Color("#45475A"), Light: lipgloss.Color("#BCC0CC")}
t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#6C7086")
t.accent = lipgloss.Color("#CBA6F7")
t.highlight = lipgloss.Color("#CBA6F7")
t.brand = lipgloss.Color("#CBA6F7")
t.label = lipgloss.Color("#A6ADC8")
t.success = lipgloss.Color("#A6E3A1")
t.error = lipgloss.Color("#F38BA8")
t.base = lipgloss.NewStyle().Foreground(t.body)
t.form = HuhTheme(t)
return t
}
// Color accessors.
func (t Theme) Background() color.Color { return t.background }
func (t Theme) Border() color.Color { return t.border }
func (t Theme) Body() color.Color { return t.body }
func (t Theme) Dim() color.Color { return t.dim }
func (t Theme) Accent() color.Color { return t.accent }
func (t Theme) Highlight() color.Color { return t.highlight }
func (t Theme) Brand() color.Color { return t.brand }
func (t Theme) Label() color.Color { return t.label }
func (t Theme) Success() color.Color { return t.success }
func (t Theme) Error() color.Color { return t.error }
// Style helpers.
func (t Theme) Base() lipgloss.Style { return t.base }
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
@@ -70,10 +89,8 @@ func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.suc
func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
// PanelError returns a style for the error banner: dark red bg, red fg.
func (t Theme) PanelError() lipgloss.Style {
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
}
// Form returns the huh form theme for shipping and payment forms.
func (t Theme) Form() *huh.Theme { return t.form }
func (t Theme) Form() huh.Theme { return t.form }

View File

@@ -2,18 +2,19 @@ package tui
import (
"fmt"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"terminalShop/pkg/api"
"terminalShop/pkg/models"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Resize debounce tick, must fire before page dispatch
if tick, ok := msg.(resizeTickMsg); ok {
if tick.seq == m.resizeSeq {
m.updateLayout(m.pendingWidth, m.pendingHeight)
m = m.updateLayout(m.pendingWidth, m.pendingHeight)
switch m.currentPage {
case shopPage:
m = m.updateShopViewports()
@@ -25,12 +26,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.updatePaymentViewport()
case confirmPage:
m = m.updateConfirmViewport()
case accountPage:
m = m.updateAccountViewport()
case reviewPage:
// no viewport on review page
}
if m.ShippingForm != nil {
m.ShippingForm.form = m.buildShippingForm(m.ShippingForm)
if m.shipping.form != nil {
m.shipping.form.form = m.buildShippingForm(m.shipping.form)
}
if m.PaymentForm != nil {
m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm)
if m.payment.form != nil {
m.payment.form.form = m.buildPaymentForm(m.payment.form)
}
if m.refund.open {
m.refund.form = m.buildRefundForm(&m.refund)
m.resizeRefundTextarea(&m.refund)
}
}
return m, nil
@@ -45,6 +54,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch msg := msg.(type) {
case VisibleError:
m.error = &msg
m.notice = nil
return m, nil
case VisibleNotice:
m.notice = &msg
m.error = nil
return m, nil
case error:
m.error = &VisibleError{message: msg.Error()}
m.notice = nil
if m.currentPage == shopPage || m.currentPage == cartPage {
return m, m.fetchCartCmd
}
return m, nil
case tea.WindowSizeMsg:
m.pendingWidth = msg.Width
m.pendingHeight = msg.Height
@@ -55,8 +79,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
})
case SplashAuthMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("authentication failed: %v", msg.Err)
return m, nil
m.error = &VisibleError{message: fmt.Sprintf("authentication failed: %v", msg.Err)}
// Still attempt to load view data (products may not require auth)
// so the splash screen doesn't get permanently stuck.
return m, m.splashViewInitCmd
}
m.AccessToken = msg.Token
m.APIClient.Token = msg.Token
@@ -66,9 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.splashViewInitCmd, m.scheduleTokenRefreshCmd())
case ViewInitMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to load: %v", msg.Err)
m.Loading = false
return m, nil
return m.ShopSwitch()
}
m.Loading = false
if len(msg.Data.Products) > 0 {
@@ -84,24 +108,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.Username = u.Name
}
cart := &api.CartData{Items: msg.Data.Cart}
m.loadCartFromAPI(cart)
m = m.loadCartFromAPI(cart)
m.SavedAddresses = msg.Data.Addresses
m.SavedCards = msg.Data.Cards
m.Orders = msg.Data.Orders
m.OrdersLoaded = true
m.splashDataReady = true
if m.splashDelayDone {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
return m, nil
m.splash.dataReady = true
m.ordersPollStarted = true
pollCmd := m.pollOrderCmd()
if m.splash.delayDone {
nextM, switchCmd := m.ShopSwitch()
return nextM, tea.Batch(switchCmd, pollCmd)
}
return m, nil
return m, pollCmd
case CartSyncedMsg:
if msg.Err != nil {
return m, m.fetchCartCmd
}
if msg.UpdateID == m.lastCartUpdateID {
m.loadCartFromAPI(msg.Cart)
m = m.loadCartFromAPI(msg.Cart)
}
return m, nil
case tokenRefreshTickMsg:
@@ -119,36 +144,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Err == nil {
m.Orders = msg.Orders
m.OrdersLoaded = true
if !m.ordersPollStarted {
m.ordersPollStarted = true
return m, m.pollOrderCmd()
}
}
return m, nil
case OrdersPollTickMsg:
return m, tea.Batch(m.fetchOrdersCmd(), m.pollOrderCmd())
case AddressDeletedMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to delete address: %v", msg.Err)
m.error = &VisibleError{message: fmt.Sprintf("failed to delete address: %v", msg.Err)}
}
return m, nil
case CardDeletedMsg:
if msg.Err != nil {
m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err)
m.error = &VisibleError{message: fmt.Sprintf("failed to delete card: %v", msg.Err)}
}
return m, nil
case DelayCompleteMsg:
m.splashDelayDone = true
if m.currentPage == splashPage && m.splashDataReady {
m = m.SwitchPage(shopPage)
m = m.updateShopViewports()
return m, nil
m.splash.delayDone = true
if m.currentPage == splashPage && m.splash.dataReady {
return m.ShopSwitch()
}
return m, nil
case splashCursorTickMsg:
m.splashCursor = !m.splashCursor
m.splash.cursor = !m.splash.cursor
return m, tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg {
return splashCursorTickMsg{}
})
case tea.KeyMsg:
// If a form is active, skip global keys — let the page handler own input
if m.ShippingForm != nil || m.PaymentForm != nil {
if m.shipping.form != nil || m.payment.form != nil {
break
}
// Refund overlay owns input when open; dispatched below before page handlers.
if m.refund.open {
break
}
// Dismiss banners with ESC.
if msg.String() == "esc" && (m.error != nil || m.notice != nil) {
m.error = nil
m.notice = nil
return m, nil
}
// Global navigation
switch msg.String() {
case "ctrl+c", "q":
@@ -161,22 +200,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.menuLastPage = m.currentPage
return m, nil
case "s":
m = m.SwitchPage(shopPage).resetPageState()
m = m.updateShopViewports()
return m, nil
return m.ShopSwitch()
case "c":
m = m.SwitchPage(cartPage).resetPageState()
m = m.updateCartViewport()
return m, nil
return m.CartSwitch()
case "a":
m = m.SwitchPage(accountPage).resetPageState()
if !m.OrdersLoaded {
return m, m.fetchOrdersCmd()
}
return m, nil
return m.AccountSwitch()
}
// non-globals. fall through to page dispatch below
}
// Refund overlay intercepts all input after global handling.
if m.refund.open {
return m.RefundUpdate(msg)
}
// Page-specifics
switch m.currentPage {
case shopPage:
@@ -187,6 +224,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.ShippingUpdate(msg)
case paymentPage:
return m.PaymentUpdate(msg)
case reviewPage:
return m.ReviewUpdate(msg)
case confirmPage:
return m.ConfirmUpdate(msg)
case accountPage:

View File

@@ -1,9 +1,8 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// ResizeView renders a message when the terminal is too small.
@@ -21,7 +20,13 @@ func (m Model) ResizeView() string {
)
}
func (m Model) View() string {
func (m Model) View() tea.View {
v := tea.NewView(m.renderString())
v.AltScreen = true
return v
}
func (m Model) renderString() string {
if m.viewportWidth == 0 {
m.updateLayout(120, 30)
}
@@ -75,51 +80,27 @@ func (m Model) View() string {
if m.Loading {
loadingStyle := m.theme.TextLoading().Padding(2, 4)
content = loadingStyle.Render("Loading products from API...")
} else if m.ErrorMsg != "" {
} else if m.error != nil {
errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
errorBanner := errorStyle.Render(m.ErrorMsg)
// soft-wrap long messages instead of clipping.
if m.widthContent > 4 {
errorStyle = errorStyle.Width(m.widthContent - 4)
}
errorBanner := errorStyle.Render(m.error.message)
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
} else if m.notice != nil {
noticeStyle := m.theme.TextSuccess().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Success()).Padding(0, 1).MarginBottom(1)
noticeBanner := noticeStyle.Render(m.notice.message)
content = noticeBanner + "\n" + m.buildPageContent(availableContentHeight)
} else {
content = m.buildPageContent(availableContentHeight)
}
// Split content into lines and handle scrolling
contentLines := strings.Split(content, "\n")
totalLines := len(contentLines)
// Skip global scroll when account view handles its own scrolling
// (order list/detail and FAQ focused use internal scrolling in BuildAccountView)
accountHandlesScroll := m.currentPage == accountPage && (m.OrderViewState >= 1 || m.FaqFocused)
if !accountHandlesScroll {
// Ensure scroll offset is within bounds
if m.ScrollOffset < 0 {
m.ScrollOffset = 0
}
maxScroll := totalLines - availableContentHeight
if maxScroll < 0 {
maxScroll = 0
}
if m.ScrollOffset > maxScroll {
m.ScrollOffset = maxScroll
}
if totalLines > availableContentHeight {
start := m.ScrollOffset
end := start + availableContentHeight
if end > totalLines {
end = totalLines
}
contentLines = contentLines[start:end]
}
}
content = strings.Join(contentLines, "\n")
footer := m.BuildFooter()
isViewportPage := m.currentPage == shopPage || m.currentPage == cartPage ||
m.currentPage == shippingPage || m.currentPage == paymentPage ||
m.currentPage == confirmPage
m.currentPage == confirmPage || m.currentPage == accountPage
var child string
if isViewportPage {
@@ -129,11 +110,11 @@ func (m Model) View() string {
child = lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
}
} else if breadcrumbs != "" {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, ccontentWithPadding, footer)
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, breadcrumbs, contentWithPadding, footer)
} else {
ccontentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, ccontentWithPadding, footer)
contentWithPadding := lipgloss.NewStyle().MarginTop(marginTop).MarginBottom(marginBottom).Height(availableContentHeight).Render(content)
child = lipgloss.JoinVertical(lipgloss.Left, header, contentWithPadding, footer)
}
// Constrain the assembled layout to the container dimensions
@@ -142,6 +123,10 @@ func (m Model) View() string {
MaxHeight(m.heightContainer).
Render(child)
if m.refund.open {
constrained = m.RenderRefundOverlay()
}
// Center the entire container in the terminal viewport
return lipgloss.Place(
m.viewportWidth,
@@ -160,6 +145,8 @@ func (m Model) buildPageContent(height int) string {
return m.ShippingPageView()
case paymentPage:
return m.PaymentPageView()
case reviewPage:
return m.ReviewView()
case confirmPage:
return m.ConfirmView()
case cartPage:

View File

@@ -35,7 +35,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
Data: data,
}
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}
// RespondError sends an error JSON response
@@ -52,7 +52,7 @@ func RespondError(w http.ResponseWriter, statusCode int, code, message string, d
},
}
json.NewEncoder(w).Encode(response)
_ = json.NewEncoder(w).Encode(response)
}
// HealthData represents health check data

View File

@@ -66,7 +66,7 @@ func CcnValidator(cardNumber string) error {
func IsDigits(name string) ErrorHandler {
return func(str string) error {
for _, c := range str {
if !(c >= '0' && c <= '9') {
if c < '0' || c > '9' {
return fmt.Errorf("[%s] expected only digits but got %s", name, str)
}
}

Some files were not shown because too many files have changed in this diff Show More