150 Commits

Author SHA1 Message Date
ebd3e20285 chores/random chores (#60)
* fix(tui): remove horrible code that was taking up space

* fix(tui): rework error and notice messages to be at the top of the tui, fixing the entire UI shifting when a message pops up
2026-06-29 20:14:05 +02:00
9d5096b270 fix(privacy): forget saved card after privacy-mode checkout (#59) 2026-06-19 10:04:35 +02:00
85ed872c28 feat(privacy): add privacy mode (#58) 2026-06-19 09:31:44 +02:00
2c12ba586e feat(sca): charge checkout as customer-initiated transaction (#57)
Drop off_session from the initial PaymentIntent so the checkout charge is a Customer-Initiated Transaction (the customer is present in the TUI), not a Merchant-Initiated Transaction. MIT exemptions require a stored mandate we never capture; as a CIT, an SCA challenge surfaces as status=requires_action, the path the handler already had. The re-confirm in respondRequiresAction and the authentication_required error branch remain as defensive fallbacks. Source: stripe-samples accept-a-payment custom-payment-flow (no off_session on customer-present confirms).

Also clarify the MAX_ORDER_CENTS TRA-ceiling anchor comment as an approximate USD/EUR figure (config.go), and carry an unrelated 1-line spend-limit input width fix in pkg/tui/account.go.

Tests: add paymentIntentNewFn stub seam, TestConvertCartIsCustomerInitiated (asserts no off_session) and TestConvertCartRequiresAction (202 + redirect + audit row).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:48:00 +02:00
0c0b8e8059 fix(tui): accept spend limit input in dollars (#56)
Spend-limit input now takes dollars (e.g. 25.50) instead of raw cents.
Parsed to integer cents as dollars*100 + cents (no float multiply) so an
amount like 50.01 never loses a cent to binary rounding; the persisted
column and API still use cents. Current limit and prefill render via
formatCentsDollars. Tests updated to the dollar grammar.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:34:03 +02:00
7148ff1d4d feat(sca): add customer-set spend limit (#55) 2026-06-17 13:25:04 +02:00
22eeeeeac1 feat(sca): enforce user self-limit as lower-only cap in convert-cart (#54) 2026-06-17 10:24:55 +02:00
aa446cdfa5 feat(sca): add per-user spend cap override (#53)
* feat(sca): add per-user spend cap override

* feat(sca): add admin endpoint to set per-user order cap

- PATCH /api/v1/users/{id}/order-cap (RequireAdmin / X-Admin-Key) sets or
  clears User.MaxOrderCents; null clears (revert to global), >=0 sets,
  negative rejected 400 INVALID_CAP at the write boundary
- harden ConvertCart: a negative override falls back to the global cap with
  a slog.Warn (mirrors loadMaxOrderCents) so a bad DB write cannot silently
  void the fraud control
- emit user_order_cap_set audit event (set/cleared)
- tests: admin handler table + RequireAdmin gate router test + negative
  read-path fallback case

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(api): drop orphan comment referencing nonexistent request struct

The paragraph above SetUserOrderCap documented a setUserOrderCapRequest
struct that does not exist; the handler decodes into a raw map instead.
The raw-map rationale already lives inline at the decode site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:56:38 +02:00
8df1058932 test(api): cover everyTick helper (#52)
* test(api): cover everyTick helper

* test(api): make everyTick cancel test deterministic
2026-06-16 13:34:55 +02:00
64b6f0d91a refactor(api): extract everyTick helper for reconciler goroutines (#51) 2026-06-15 10:17:02 +02:00
5dd196834f feat/sca pay redirects sweep (#50)
* feat(sca): sweep expired pay_redirects rows on a ticker

* fix(fix): revert the 10-second ticker to 10m after forgetting to revert it after testing
2026-06-15 00:39:31 +02:00
e85dc7fd4d fix(webhook): fail closed when stripe webhook secret is not set (#45) 2026-06-14 22:12:36 +02:00
ff562c7427 docs(docs): update docs to get rid of gopls warnings. update some code to newer standards (#49) 2026-06-14 21:42:02 +02:00
aa34043e9b feat(tuie): drive roast meter from product roast_level field (#48) 2026-06-11 16:28:04 +02:00
5dd22fb8be style(tui): update rest of tui to new ui pattern (#47) 2026-06-11 15:53:48 +02:00
52d533703b feat(tui): remake parts of the tui (#46)
* feat(tui): remake parts of the tui

* chore(tui): remove unused newViewport helper
2026-06-11 11:24:56 +02:00
c9f8b1e674 test(test): smoke test deploy script with dirty box tree (#44) 2026-06-09 19:39:09 +02:00
d592249efb fix: fail deploy loudly on stale pull or HEAD mismatch (#43) 2026-06-09 19:20:08 +02:00
cfc97eb1ff fix/temp disable sshCardAdd button (#42)
* fix: disable add-card-via-ssh, mark coming soo, skip in cursor nav

* style(tui): reword (coming soon) to (WIP)
2026-06-09 14:33:09 +02:00
c112797875 refactor(sca): thread context.Context through reconciler entry points (#41)
* refactor(sca): thread context.Context through reconciler entry points

* test(sca): add mid-loop cancel test for stale 3ds sweep

* refactor(api): rename shutdown ctx to shutdownCtx for clarity

* style(sca): fix typo in ReconcileUnshipped commend
2026-06-09 00:31:56 +02:00
75225548ae feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep (#40)
* feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep

* test: assert sweep counter labels in remaining branch tests
2026-06-08 23:11:44 +02:00
53e934be3b chore(cap): raise per-order spend cap default from 200 to 500 (#39) 2026-06-07 23:47:43 +02:00
fc02f331d2 feat(audit): emit cart_rejected event when per-order cap blocks checkout (#38)
* feat(audit): emit cart_rejected event when per-order cap blocks checkout

* refactor(audit): drop dead persist call + harden cart_rejected test capture
2026-06-07 23:25:30 +02:00
bb960c31f8 feat(sca): sweep abandoned 3ds requires_action orders to failed (#37)
* feat(sca): sweep abandoned 3ds requires_action orders to failed

* fix(sca): address pr review on abandoned 3ds sweep

- Drop stripe.Key write in ReconcileStale3DSOrders; wire once in api/main.go after cfg.Load so concurrent reconcilers don't
race the SDK global.
- Add empty-PI short-circuit before paymentintent.Get to bury a future regression in a slog.Warn instead of a Stripe 4xx.
- Swap fmt.Printf for slog.Warn with k/v attrs in loadAbandoned3DSThresholdMinutes so Loki sees structured fields instead of
 literal %q/%d.
- Comment the updated_at predicate (RetryAuth bumps it via GORM auto-stamp, intentional grace-window extension) + the
conditional-UPDATE/map-form choice.
- Tests: processing/requires_capture skip, unknown-status skip, stripe-error skip, empty-PI skip, full env-parse table for
loadAbandoned3DSThresholdMinutes.

* fix(stripe): wire key once at init drop per-call writes

* refactor(stripe): drop dead stripeKey fields and pre-call sdk params
2026-06-07 18:16:33 +02:00
8b753e4f4b feat(sca): add requires_action orde status for 3ds in flight sate (#36)
* feat(sca): add requires_action orde status for 3ds in flight sate

* fix(sca): fix stuff the review commented on
2026-06-06 21:06:25 +02:00
90645958e5 chore(repo): change all instances of interface{} to any as of newer go standards (#35) 2026-06-06 13:59:09 +02:00
135f1361e4 fix(products): update pricing on products (#34)
* fix(products): update pricing on products

* fix(test): pin coffee price in cart-limit fixture

TestConvertCartLimit's quantities and expected totals were keyed to
the original 500-cent latte price. After the product price bump in
1ce63fb, coffee_id=4 became 2500 cents, so under_cap/at_cap subtests
overshot the $200 cap and over_cap's expected total magnitude was
wrong by 5x. Override coffee_id=4's price to 500 in test setup so the
cap math stays stable regardless of seed changes.
2026-06-05 23:49:03 +02:00
64deba7e03 feat(sca): wire retry-auth keybind on authfailed review screen (#33) 2026-06-05 22:52:54 +02:00
732353d9a4 fix(tui): fix the menu always selecting the first entry in the refund page (#32) 2026-06-04 00:09:07 +02:00
3420c8dce0 feat(sca): add retry-auth endpoint for failed 3ds challenges (#31)
* feat(sca): add retry-auth endpoint for failed 3ds challenges

* fix(sca): retry-auth unexpected-state branch out of card-decline

just fixes up in some of the things the review of the last commit
noticed
2026-06-03 23:42:27 +02:00
f3b94c3271 chore(test): add gotestsum + tparse runner with ci wiring (#30)
* chore(test): add gotestsum + tparse runner with ci wiring

* chore(tests): change the tests around a little
2026-06-02 23:17:03 +02:00
67e19eca98 feat(sca): expire inactive saved cards (#29)
* feat(sca): expire inactive saved cards

* fix(sca): address card ttl review feedback

- Correct sql typos breaking GET /cards and /view/init (used_id, id_default)
- Stop swallowing backfillCardStorageExpiration error in Migrate
- Wrap expireStoredCard local writes in a transaction and run stripe
  detach + audit only after commit, preventing double-fire on retry
- Drop redundant ttl refresh in ConvertCart; webhook is authoritative
- Rename validation_expired_at metric label to validation_card_expired
- Fix CardStorageExpiresAt doc comment and test failure message typos
- Add handler-level tests for expireStoredCards (user scoping + cart deref)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(sca): move card expiration to periodic reconciler

Read paths (GetCards, GetViewInit) now filter expired cards from
query results instead of detaching them inline, so request handlers
no longer block on Stripe API calls. Physical deletion runs in a
new ReconcileExpiredCards goroutine on an hourly ticker, mirroring
the existing ReconcileOrders / ReconcileUnshipped jobs. Per-card
action sites (SetCard, SetDefaultCard, ConvertCart) still expire
synchronously since they operate on one row the user is acting on.

ViewHandler's stripeKey dependency is dropped now that GetViewInit
no longer touches Stripe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sca): address card ttl review feedback

- guard zero order.CardID before refreshing card storage ttl in
  payment_intent.succeeded webhook to avoid noisy warn logs on payment
  paths that did not use a saved card
- delete unused Card.RefreshStorageTTL model method; the SQL-only
  refreshCardStorageTTL helper is the single canonical path
- clarify duplicate-card webhook path: storage_expires_at reset is a
  card-add refresh, not a payment-success refresh, so last_used_at
  intentionally stays untouched

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(sca): cover card ttl read paths and expired-card sync sites

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:49:48 +02:00
728ac24c96 fix(sca): return over-limit checkout errors to cart (#28) 2026-06-02 10:28:56 +02:00
1096c85b10 chore(ops): add nightly backup verification scripts (#27) 2026-06-02 00:02:27 +02:00
2f65cf0d90 feat(sca): cap per-order total ro prevent oversize charges (#26) 2026-06-01 22:40:20 +02:00
c3e6eb2b84 fix(ci): pin deploy go-version to go.mod to match ci.yml (#25) 2026-05-30 23:29:19 +02:00
03009ea6df chore(ci): bump checkout and setup-go to node24-ready majors (#24) 2026-05-30 23:19:29 +02:00
c8b7545ca6 chore(ci): skip golangci-lint under act to avoid node path issue (#23) 2026-05-30 19:48:20 +02:00
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
148 changed files with 13315 additions and 2412 deletions

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

@@ -0,0 +1,65 @@
name: CI
on:
pull_request:
branches: [master]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
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
if: ${{ !env.ACT }}
uses: golangci/golangci-lint-action@v7
with:
version: v2.4.0
- name: Install tparse
run: go install github.com/mfridman/tparse@v0.18.0
- name: Test (race detector)
run: |
set +e
go tool gotestsum \
--format pkgname \
--format-icons hivis \
--hide-summary=skipped,output,errors,failed \
--jsonfile test-output.json \
--junitfile test-report.xml \
--packages="./..." \
-- -count=1 -race -covermode=atomic -coverprofile=coverage.out
rc=$?
echo
tparse -all -notests -slow 10 -file=test-output.json || true
exit $rc
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: |
test-output.json
test-report.xml
coverage.out

View File

@@ -8,18 +8,10 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version: '1.23' go-version-file: go.mod
- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
restore-keys: ${{ runner.os }}-go-
- name: Run tests - name: Run tests
run: go test ./... run: go test ./...
- name: Deploy to server - name: Deploy to server
@@ -30,6 +22,15 @@ jobs:
key: ${{ secrets.SERVER_SSH_KEY }} key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }} port: ${{ secrets.SERVER_PORT }}
script: | script: |
set -euo pipefail
cd ~/terminalShop cd ~/terminalShop
git pull git fetch origin master
git reset --hard origin/master
deployed=$(git rev-parse HEAD)
expected="${{ github.sha }}"
if [ "$deployed" != "$expected" ]; then
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
exit 1
fi
docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml up -d --build
echo "deploy verified: HEAD=$deployed"

27
.gitignore vendored
View File

@@ -5,6 +5,15 @@
.env.example .env.example
.env.prod .env.prod
docs/sca-psd2-compliance.md
accept-a-payment/
codemap/
FULFILLMENT.md
.tmp-gocache/
.junie/
# Build artifacts # Build artifacts
*.exe *.exe
*.exe~ *.exe~
@@ -25,6 +34,8 @@ api/api-server
*.out *.out
coverage.txt coverage.txt
coverage.html coverage.html
test-output.json
test-report.xml
# Dependency directories # Dependency directories
vendor/ vendor/
@@ -38,6 +49,8 @@ vendor/
.DS_Store .DS_Store
.claude/ .claude/
.understand-anything/
# ssh config # ssh config
.ssh/ .ssh/
@@ -64,7 +77,6 @@ temp/
# OS-specific # OS-specific
Thumbs.db Thumbs.db
.DS_Store
CONTRIBUTING.md CONTRIBUTING.md
@@ -92,6 +104,19 @@ docs/
# Migration state (if using a migration tool that creates state files) # Migration state (if using a migration tool that creates state files)
migrations/*.state migrations/*.state
gh-dash/
loadtest/run.pid
loadtest/run.err
/00
ROADMAP.md
*.bak
terminalshop.prod.*
# Backup verification config (real values; example file is committed)
scripts/backups/config.env

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

55
Makefile Normal file
View File

@@ -0,0 +1,55 @@
.PHONY: test test-pretty test-watch test-ci test-summary cover
PKGS ?= ./...
JSON_OUT ?= test-output.json
JUNIT ?= test-report.xml
COVER ?= coverage.out
# Plain go test. Fastest, no extra deps loaded
test:
go test $(PKGS)
# Live pretty output. Groups by test name, reruns flakes once.
# Hides gotestsum's plain-text summary; tparse renders failures instead.
test-pretty:
@set +e; \
go tool gotestsum \
--format testname \
--format-icons hivis \
--hide-summary=skipped,output,errors,failed \
--rerun-fails=1 \
--jsonfile $(JSON_OUT) \
--packages="$(PKGS)" \
-- -count=1; \
rc=$$?; \
echo; \
tparse -all -notests -file=$(JSON_OUT) || true; \
exit $$rc
# TDD loop: re-runs affected packages on file save.
test-watch:
go tool gotestsum --watch --format testname --packages="$(PKGS)"
# CI / pre-push: race + coverage + JSON + JUnit + tparse summary table
test-ci:
@set +e; \
go tool gotestsum \
--format pkgname \
--format-icons hivis \
--hide-summary=skipped,output,errors,failed \
--jsonfile $(JSON_OUT) \
--junitfile $(JUNIT) \
--packages="$(PKGS)" \
-- -count=1 -race -covermode=atomic -coverprofile=$(COVER); \
rc=$$?; \
echo; \
tparse -all -notests -slow 10 -file=$(JSON_OUT) || true; \
exit $$rc
# Replay last summary without re-running tests
test-summary:
tparse -all -notests -file=$(JSON_OUT)
# Open HTML coverage report from last `test-ci` run
cover:
go tool cover -html=$(COVER)

View File

@@ -4,10 +4,71 @@
ssh sshops.uk ssh sshops.uk
``` ```
## demo
https://github.com/user-attachments/assets/daf895ac-2c01-4323-bf27-8d7829b9ddd5
it's a coffee shop but in your terminal. you ssh in, browse the menu, chuck stuff in your cart, put in your address and card, and buy coffee without ever touching a browser. your ssh key is your account so there's no login or signup or any of that. 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). 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).
running on Hetzner, behind Cloudflare. ## what it actually does
**tech:** Go, Bubbletea, Wish, Chi, PostgreSQL, Stripe, Shippo, Bring 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
git clone https://github.com/marcusDenslow/terminalShop
cd terminalShop
cp .env.example .env # fill in stripe test keys
go run main.go # ssh server on :23457
go run api/main.go # API on :8080
ssh localhost -p 23457
```
uses SQLite by default so there's nothing to spin up.
## inspiration
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
# something

View File

@@ -1,105 +0,0 @@
# Reference Patterns to Adopt
Patterns from `terminal-shop-source` that we're copying into this project.
---
## 1. Footer: Command Array Pattern
### Structs (in model.go or footer.go)
```go
type footerState struct {
commands []footerCommand
}
type footerCommand struct {
key string
value string
}
```
### Setting commands (in each *Switch method)
```go
func (m Model) CartSwitch() (Model, tea.Cmd) {
m = m.SwitchPage(cartPage)
m.footer.commands = []footerCommand{
{key: "esc", value: "back"},
{key: "↑/↓", value: "items"},
{key: "+/-", value: "qty"},
{key: "c", value: "checkout"},
}
m = m.updateCartViewport()
return m, nil
}
```
### Rendering (footer.go)
```go
// Iterate commands instead of if/else chain
commands := []string{}
for _, cmd := range m.footer.commands {
commands = append(commands, bold(" "+cmd.key+" ")+base(cmd.value+" "))
}
// Error display replaces default content when m.ErrorMsg != ""
```
### Small screen: only show "m menu"
```go
if m.size == small {
return table.Render(bold("m") + base(" menu"))
}
```
---
## 2. Helper Methods
### Cart helpers (add to model.go or cart.go)
```go
func (m Model) IsCartEmpty() bool {
return len(m.Cart) == 0
}
func (m Model) CartItemCount() int {
return len(m.Cart)
}
func (m Model) CalculateSubtotal() int {
subtotal := 0
for _, item := range m.Cart {
subtotal += item.Coffee.Price * item.Quantity
}
return subtotal
}
```
### Used in reference for: header cart badge, footer conditional commands, checkout validation, review page totals.
---
## 3. Account View Split
### Reference splits into separate files per sub-view:
- `account.go` — parent: menu + delegation (~80 lines)
- `orders.go` — OrdersView() + OrdersUpdate()
- `subscriptions.go` — SubscriptionsView() + SubscriptionsUpdate()
- `tokens.go` — TokensView() + TokensUpdate()
- `apps.go` — AppsView() + AppsUpdate()
- `faq.go` — FaqView()
- `about.go` — AboutView() (in account.go, inline)
### Parent dispatches via:
```go
func (m model) GetAccountPageContent(accountPage page, width int) string {
switch accountPage {
case ordersPage:
return m.OrdersView(width, m.state.account.focused)
case addressesPage:
return m.AddressesView(width, m.state.account.focused)
// ...
}
}
```
### Each sub-view owns its own state, cursor, and deletion logic.

3
api/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Command api is the HTTP backend: it boots the router, bolts on all the
// middleware, and answers the requests the TUI throws at it.
package main

161
api/handlers/account.go Normal file
View File

@@ -0,0 +1,161 @@
package handlers
import (
"encoding/json"
"net/http"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// AccountHandler serves JWT-authed, customer-facing account-settings endpoints.
// Unlike AdminUserHandler (X-Admin-Key gated), every route here edits the
// logged-in user's own row, identified by the JWT user id in context.
type AccountHandler struct {
// maxOrderCents is the global spend cap, needed to bound a self-limit: a
// customer may only TIGHTEN their effective ceiling, never raise it.
maxOrderCents int
}
// NewAccountHandler constructs an AccountHandler. maxOrderCents is the global
// MAX_ORDER_CENTS, wired from cfg in routes.SetupRoutes (same value the cart
// handler receives).
func NewAccountHandler(maxOrderCents int) *AccountHandler {
return &AccountHandler{maxOrderCents: maxOrderCents}
}
// SetSpendLimit sets or clears the logged-in user's self-service spend limit
// (User.SelfLimitCents). Body: {"self_limit_cents": <int|null>}.
// - null -> clears the self-limit; the user reverts to the admin/global cap
// - 0 -> a real "block everything" ceiling (NOT an off-switch; that is the
// whole point of a customer-set limit — it can only tighten)
// - >0 -> custom ceiling, must be <= the effective admin/global cap
//
// Security: this endpoint is JWT-authed and edits the caller's OWN row, so a
// self-limit is LOWER-ONLY. A value above the user's effective admin/global cap
// is rejected here (HTTP 400) so a compromised session cannot lift its own
// ceiling; the min() fold in cart.go:ConvertCart is the authoritative second
// guard. Negative values are rejected at the write boundary (the ConvertCart
// read path also ignores a stray negative from a direct DB write).
//
// Mirrors AdminUserHandler.SetUserOrderCap but reads the user id from the JWT
// context instead of a {id} URL param, uses a distinct body key + audit event,
// and gates the value against the cap.
func (h *AccountHandler) SetSpendLimit(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawLimit, ok := raw["self_limit_cents"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "self_limit_cents is required (use null to clear the limit)", nil)
return
}
var limitCents *int
if string(rawLimit) != "null" {
var n int
if err := json.Unmarshal(rawLimit, &n); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "self_limit_cents must be an integer or null", nil)
return
}
if n < 0 {
utils.RespondError(w, http.StatusBadRequest, "INVALID_LIMIT", "self_limit_cents must be >= 0 (use null to clear the limit)", nil)
return
}
limitCents = &n
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// A self-limit may only tighten the effective cap, never raise it. Compute
// the user's effective admin/global ceiling exactly as cart.go:ConvertCart
// does (per-user override wins when present and non-negative; an active cap
// is > 0). Reject any self-limit above an ACTIVE admin ceiling. When no admin
// ceiling is active (global disabled and no override) there is nothing to
// raise, so any non-negative self-limit is allowed — it can only ADD a cap.
if limitCents != nil {
adminCap := h.maxOrderCents
if user.MaxOrderCents != nil && *user.MaxOrderCents >= 0 {
adminCap = *user.MaxOrderCents
}
if adminCap > 0 && *limitCents > adminCap {
utils.RespondError(w, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", "self_limit_cents may not exceed your account spend cap", map[string]any{"self_limit_cents": *limitCents, "cap_cents": adminCap})
return
}
}
// Map form so a nil pointer persists SQL NULL; a struct Updates would skip it.
if err := db.Model(&user).Updates(map[string]any{"self_limit_cents": limitCents}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update spend limit", nil)
return
}
audit.UserSelfLimitSet(user.ID, limitCents)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"self_limit_cents": limitCents,
})
}
// SetPrivacyMode sets the logged-in user's account-level privacy default
// (User.PrivacyMode). Body: {"privacy_mode": <bool>}.
//
// PrivacyMode is a SOFT default, not a server-enforced guarantee: it only
// pre-selects "don't keep" at checkout in the TUI. The checkout still sends an
// explicit per-order intent which the backend honors (cart.go:ConvertCart) —
// that is what lets a customer override the default with "save anyway". A
// stricter server-enforced "never save" would be a separate mode.
//
// Mirrors SetSpendLimit: JWT-authed, edits the caller's OWN row.
func (h *AccountHandler) SetPrivacyMode(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawMode, ok := raw["privacy_mode"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "privacy_mode is required", nil)
return
}
var enabled bool
if err := json.Unmarshal(rawMode, &enabled); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "privacy_mode must be a boolean", nil)
return
}
var user models.User
if err := db.First(&user, userID).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// Map form so an explicit false persists (a struct Updates skips a zero bool)
if err := db.Model(&user).Updates(map[string]any{"privacy_mode": enabled}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update privacy mode", nil)
return
}
audit.UserPrivacyModeSet(user.ID, enabled)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"privacy_mode": enabled,
})
}

View File

@@ -0,0 +1,61 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// TestSetPrivacyMode covers the account privacy toggle: enable, disable (a real
// true->false transition, which the map-form Updates must persist), and the two
// 400 paths (missing field, wrong type).
func TestSetPrivacyMode(t *testing.T) {
cases := []struct {
name string
body string
wantStatus int
wantValue bool
}{
{"enable", `{"privacy_mode": true}`, http.StatusOK, true},
{"disable", `{"privacy_mode": false}`, http.StatusOK, false},
{"missing field", `{}`, http.StatusBadRequest, false},
{"wrong type", `{"privacy_mode": "yes"}`, http.StatusBadRequest, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
// Seed the opposite so an OK case proves a real transition (and the
// disable case actually exercises persisting an explicit false).
if tc.wantStatus == http.StatusOK {
if err := database.GetDB().Model(&user).Update("privacy_mode", !tc.wantValue).Error; err != nil {
t.Fatalf("seed privacy_mode: %v", err)
}
}
w := httptest.NewRecorder()
NewAccountHandler(0).SetPrivacyMode(
w, authRequest("PUT", "/api/v1/account/privacy-mode", []byte(tc.body), user.ID))
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String())
}
if tc.wantStatus != http.StatusOK {
return
}
var reloaded models.User
if err := database.GetDB().First(&reloaded, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if reloaded.PrivacyMode != tc.wantValue {
t.Errorf("privacy_mode: want %v, got %v", tc.wantValue, reloaded.PrivacyMode)
}
})
}
}

View File

@@ -0,0 +1,118 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
func TestSetSpendLimit(t *testing.T) {
const globalCap = 5000
cases := []struct {
name string
seedSelf *int // starting SelfLimitCents on the row
seedAdmin *int // per-user MaxOrderCents override (effective admin cap)
body string
wantStatus int
wantCode string // error code; "" means success
wantLimit *int // expected SelfLimitCents after a successful update
}{
{"set positive under cap", nil, nil, `{"self_limit_cents":2000}`, http.StatusOK, "", intPtr(2000)},
{"set zero blocks all", nil, nil, `{"self_limit_cents":0}`, http.StatusOK, "", intPtr(0)},
{"set equal to cap", nil, nil, `{"self_limit_cents":5000}`, http.StatusOK, "", intPtr(5000)},
{"clear with null", intPtr(2000), nil, `{"self_limit_cents":null}`, http.StatusOK, "", nil},
{"overwrite existing", intPtr(2000), nil, `{"self_limit_cents":1000}`, http.StatusOK, "", intPtr(1000)},
{"above global cap rejected", nil, nil, `{"self_limit_cents":6000}`, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", nil},
{"under per-user override accepted", nil, intPtr(10000), `{"self_limit_cents":8000}`, http.StatusOK, "", intPtr(8000)},
{"above per-user override rejected", nil, intPtr(10000), `{"self_limit_cents":12000}`, http.StatusBadRequest, "SELF_LIMIT_ABOVE_CAP", nil},
{"negative rejected", nil, nil, `{"self_limit_cents":-1}`, http.StatusBadRequest, "INVALID_LIMIT", nil},
{"missing field rejected", nil, nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
{"non-integer rejected", nil, nil, `{"self_limit_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
{"malformed json rejected", nil, nil, `{`, http.StatusBadRequest, "INVALID_BODY", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
if tc.seedSelf != nil {
if err := db.Model(&user).Update("self_limit_cents", *tc.seedSelf).Error; err != nil {
t.Fatalf("seed self limit: %v", err)
}
}
if tc.seedAdmin != nil {
if err := db.Model(&user).Update("max_order_cents", *tc.seedAdmin).Error; err != nil {
t.Fatalf("seed admin cap: %v", err)
}
}
handler := NewAccountHandler(globalCap)
req := authRequest("PUT", "/api/v1/account/spend-limit", []byte(tc.body), user.ID)
w := httptest.NewRecorder()
handler.SetSpendLimit(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
}
assertErrCode(t, w, tc.wantCode)
var got models.User
if err := db.First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if tc.wantStatus == http.StatusOK {
if !eqIntPtr(got.SelfLimitCents, tc.wantLimit) {
t.Errorf("db SelfLimitCents: want %s got %s", fmtIntPtr(tc.wantLimit), fmtIntPtr(got.SelfLimitCents))
}
} else if !eqIntPtr(got.SelfLimitCents, tc.seedSelf) {
// A rejected request must not mutate the column.
t.Errorf("rejected request changed db limit: want %s got %s", fmtIntPtr(tc.seedSelf), fmtIntPtr(got.SelfLimitCents))
}
})
}
}
// TestSetSpendLimit_CapDisabledAllowsAnyLimit: with no active admin ceiling
// (global 0, no override) any non-negative self-limit is accepted — a self-limit
// can only ADD a ceiling, so there is nothing to raise.
func TestSetSpendLimit_CapDisabledAllowsAnyLimit(t *testing.T) {
user := setupOrderTestDB(t)
handler := NewAccountHandler(0) // global cap disabled
req := authRequest("PUT", "/api/v1/account/spend-limit", []byte(`{"self_limit_cents":999999}`), user.ID)
w := httptest.NewRecorder()
handler.SetSpendLimit(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: want 200 got %d (%s)", w.Code, w.Body.String())
}
var got models.User
if err := database.GetDB().First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if !eqIntPtr(got.SelfLimitCents, intPtr(999999)) {
t.Errorf("db SelfLimitCents: want 999999 got %s", fmtIntPtr(got.SelfLimitCents))
}
}
// TestSetSpendLimit_RequireAuthGate exercises the JWT gate through a real
// router: no user context => 401, the handler is never reached.
func TestSetSpendLimit_RequireAuthGate(t *testing.T) {
setupOrderTestDB(t)
router := chi.NewRouter()
router.With(middleware.RequireAuth).
Put("/api/v1/account/spend-limit", NewAccountHandler(5000).SetSpendLimit)
req := httptest.NewRequest("PUT", "/api/v1/account/spend-limit", strings.NewReader(`{"self_limit_cents":1000}`))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401 got %d", w.Code)
}
assertErrCode(t, w, "UNAUTHORIZED")
}

View File

@@ -2,7 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
@@ -16,6 +16,8 @@ import (
"terminalShop/pkg/utils" "terminalShop/pkg/utils"
) )
func addressLog() *slog.Logger { return slog.With("component", "address") }
type AddressHandler struct { type AddressHandler struct {
shippoKey string shippoKey string
bringAPIUID string bringAPIUID string
@@ -33,20 +35,20 @@ func NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey string) *AddressHa
// Retrieve all saved addresses for a user // Retrieve all saved addresses for a user
func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var addresses []models.Address var addresses []models.Address
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses) db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"addresses": addresses, "addresses": addresses,
}) })
} }
func (h *AddressHandler) CreateAddress(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()) userID := middleware.UserIDFromContext(r.Context())
@@ -80,7 +82,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
client := shippo.NewClient(h.shippoKey) client := shippo.NewClient(h.shippoKey)
validated, err := client.ValidateAddress(shippo.Address{ validated, err := client.ValidateAddress(r.Context(), shippo.Address{
Name: req.Name, Name: req.Name,
Street1: req.Street, Street1: req.Street,
Street2: req.Street2, Street2: req.Street2,
@@ -91,7 +93,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
Phone: req.Phone, Phone: req.Phone,
}) })
if err != nil { 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) utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
return return
} }
@@ -112,7 +114,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
client := bring.NewClient(h.bringAPIUID, h.bringAPIKey) client := bring.NewClient(h.bringAPIUID, h.bringAPIKey)
validated, err := client.ValidateAddress(bring.Address{ validated, err := client.ValidateAddress(r.Context(), bring.Address{
Name: req.Name, Name: req.Name,
Street1: req.Street, Street1: req.Street,
Street2: req.Street2, Street2: req.Street2,
@@ -122,7 +124,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
Phone: req.Phone, Phone: req.Phone,
}) })
if err != nil { 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) utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address is invalid", nil)
return return
} }
@@ -141,19 +143,25 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
return 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 { if err := db.Create(&address).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create address", nil) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create address", nil)
return return
} }
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ utils.RespondSuccess(w, http.StatusCreated, map[string]any{
"address": address, "address": address,
}) })
} }
// DeleteAddress deltes a saved address // DeleteAddress deltes a saved address
func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
@@ -175,7 +183,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "address successfully deleted", "message": "address successfully deleted",
}) })
} }
@@ -183,7 +191,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
// Mark an address as the users default address // Mark an address as the users default address
func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
@@ -210,7 +218,7 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"address": address, "address": address,
}) })
} }

View File

@@ -13,7 +13,7 @@ import (
func setupAddressTestDB(t *testing.T) (string, models.User) { func setupAddressTestDB(t *testing.T) (string, models.User) {
t.Helper() t.Helper()
testDB := "test_addresses.db" testDB := "test_addresses.db"
os.Remove(testDB) _ = os.Remove(testDB)
database.ResetForTesting() database.ResetForTesting()
db, err := database.Connect(testDB) db, err := database.Connect(testDB)
@@ -42,7 +42,7 @@ func setupAddressTestDB(t *testing.T) (string, models.User) {
func TestGetAddressesEmpty(t *testing.T) { func TestGetAddressesEmpty(t *testing.T) {
testDB, user := setupAddressTestDB(t) testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
handler := NewAddressHandler("", "", "") handler := NewAddressHandler("", "", "")
@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
var resp struct { var resp struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
Addresses []interface{} `json:"addresses"` Addresses []any `json:"addresses"`
} `json:"data"` } `json:"data"`
} }
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
@@ -74,7 +74,7 @@ func TestGetAddressesEmpty(t *testing.T) {
func TestGetAddressesIsolation(t *testing.T) { func TestGetAddressesIsolation(t *testing.T) {
testDB, user1 := setupAddressTestDB(t) testDB, user1 := setupAddressTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -105,7 +105,7 @@ func TestGetAddressesIsolation(t *testing.T) {
Addresses []models.Address `json:"addresses"` Addresses []models.Address `json:"addresses"`
} `json:"data"` } `json:"data"`
} }
json.NewDecoder(w.Body).Decode(&resp) _ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data.Addresses) != 1 { if len(resp.Data.Addresses) != 1 {
t.Fatalf("expexted 1 address for user1, got %d", len(resp.Data.Addresses)) 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) { func TestGetAddressesWithData(t *testing.T) {
testDB, user := setupAddressTestDB(t) testDB, user := setupAddressTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()

View File

@@ -0,0 +1,97 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
)
// AdminUserHandler serves admin-gated user-administration endpoints. Routes that
// use it are mounted behind middleware.RequireAdmin (X-Admin-Key), not the JWT
// auth used by customer-facing endpoints.
type AdminUserHandler struct{}
// NewAdminUserHandler constructs an AdminUserHandler.
func NewAdminUserHandler() *AdminUserHandler {
return &AdminUserHandler{}
}
// SetUserOrderCap sets or clears a user's per-user spend-cap override
// (User.MaxOrderCents). Body: {"max_order_cents": <int|null>}.
// - null -> clears the override; the user reverts to the global MAX_ORDER_CENTS
// - 0 -> per-user off-switch (disables the cap for this user)
// - >0 -> custom ceiling
//
// Negative values are rejected here at the write boundary (HTTP 400) to keep bad
// data out of the column. The read path in cart.go:ConvertCart independently
// defends against a negative that arrives via a direct DB write (falls back to
// the global cap), so the two layers cover each other.
//
// Adapted from the admin OrderHandler.UpdateTracking pattern in
// api/handlers/orders.go (URL id param -> body decode -> row lookup -> update ->
// audit -> respond).
func (h *AdminUserHandler) SetUserOrderCap(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 user id", nil)
return
}
// Decode raw so an explicit null (clear) is distinguishable from an absent key.
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil)
return
}
rawCap, ok := raw["max_order_cents"]
if !ok {
utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD",
"max_order_cents is required (use null to clear the override)", nil)
return
}
var capCents *int
if string(rawCap) != "null" {
var n int
if err := json.Unmarshal(rawCap, &n); err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY",
"max_order_cents must be an integer or null", nil)
return
}
if n < 0 {
utils.RespondError(w, http.StatusBadRequest, "INVALID_CAP",
"max_order_cents must be >= 0 (use null to clear the override)", nil)
return
}
capCents = &n
}
var user models.User
if err := db.First(&user, id).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return
}
// Map form so a nil pointer persists SQL NULL; a struct Updates would skip it.
if err := db.Model(&user).Updates(map[string]any{"max_order_cents": capCents}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update order cap", nil)
return
}
audit.UserOrderCapSet("admin", user.ID, capCents)
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user_id": user.ID,
"max_order_cents": capCents,
})
}

View File

@@ -0,0 +1,194 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// adminCapRequest builds a PATCH request carrying the {id} chi URL param, as the
// admin order-cap route would. No JWT — the route is gated by RequireAdmin.
func adminCapRequest(idParam, body string) *http.Request {
req := httptest.NewRequest("PATCH", "/api/v1/users/"+idParam+"/order-cap", strings.NewReader(body))
rc := chi.NewRouteContext()
rc.URLParams.Add("id", idParam)
return req.WithContext(contextWithRoute(req.Context(), rc))
}
func eqIntPtr(a, b *int) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func fmtIntPtr(p *int) string {
if p == nil {
return "<nil>"
}
return strconv.Itoa(*p)
}
func TestSetUserOrderCap(t *testing.T) {
cases := []struct {
name string
seed *int // starting MaxOrderCents on the user row
body string
wantStatus int
wantCode string // error code; "" means success
wantCap *int // expected DB value after a successful update
}{
{"set positive", nil, `{"max_order_cents":7500}`, http.StatusOK, "", intPtr(7500)},
{"set zero off-switch", nil, `{"max_order_cents":0}`, http.StatusOK, "", intPtr(0)},
{"clear with null reverts to global", intPtr(500), `{"max_order_cents":null}`, http.StatusOK, "", nil},
{"overwrite existing value", intPtr(500), `{"max_order_cents":12000}`, http.StatusOK, "", intPtr(12000)},
{"negative rejected", nil, `{"max_order_cents":-1}`, http.StatusBadRequest, "INVALID_CAP", nil},
{"missing field rejected", nil, `{}`, http.StatusBadRequest, "MISSING_FIELD", nil},
{"non-integer rejected", nil, `{"max_order_cents":"lots"}`, http.StatusBadRequest, "INVALID_BODY", nil},
{"malformed json rejected", nil, `{`, http.StatusBadRequest, "INVALID_BODY", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := setupOrderTestDB(t)
db := database.GetDB()
if tc.seed != nil {
if err := db.Model(&user).Update("max_order_cents", *tc.seed).Error; err != nil {
t.Fatalf("seed cap: %v", err)
}
}
handler := NewAdminUserHandler()
req := adminCapRequest(strconv.FormatUint(uint64(user.ID), 10), tc.body)
w := httptest.NewRecorder()
handler.SetUserOrderCap(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status: want %d got %d (%s)", tc.wantStatus, w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error.Code != tc.wantCode {
t.Errorf("error code: want %q got %q", tc.wantCode, resp.Error.Code)
}
var got models.User
if err := db.First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if tc.wantStatus == http.StatusOK {
if !eqIntPtr(got.MaxOrderCents, tc.wantCap) {
t.Errorf("db MaxOrderCents: want %s got %s", fmtIntPtr(tc.wantCap), fmtIntPtr(got.MaxOrderCents))
}
} else if !eqIntPtr(got.MaxOrderCents, tc.seed) {
// A rejected request must not mutate the column.
t.Errorf("rejected request changed db cap: want %s got %s", fmtIntPtr(tc.seed), fmtIntPtr(got.MaxOrderCents))
}
})
}
}
func TestSetUserOrderCap_InvalidID(t *testing.T) {
setupOrderTestDB(t)
w := httptest.NewRecorder()
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("abc", `{"max_order_cents":100}`))
if w.Code != http.StatusBadRequest {
t.Fatalf("status: want 400 got %d", w.Code)
}
assertErrCode(t, w, "INVALID_ID")
}
func TestSetUserOrderCap_UserNotFound(t *testing.T) {
setupOrderTestDB(t)
w := httptest.NewRecorder()
NewAdminUserHandler().SetUserOrderCap(w, adminCapRequest("99999", `{"max_order_cents":100}`))
if w.Code != http.StatusNotFound {
t.Fatalf("status: want 404 got %d", w.Code)
}
assertErrCode(t, w, "USER_NOT_FOUND")
}
// TestSetUserOrderCap_AdminGate exercises the RequireAdmin middleware through a
// real router (the handler-direct tests above bypass middleware).
func TestSetUserOrderCap_AdminGate(t *testing.T) {
user := setupOrderTestDB(t)
idStr := strconv.FormatUint(uint64(user.ID), 10)
router := func() http.Handler {
r := chi.NewRouter()
r.With(middleware.RequireAdmin).
Patch("/api/v1/users/{id}/order-cap", NewAdminUserHandler().SetUserOrderCap)
return r
}
newReq := func() *http.Request {
return httptest.NewRequest("PATCH", "/api/v1/users/"+idStr+"/order-cap",
strings.NewReader(`{"max_order_cents":100}`))
}
t.Run("disabled when ADMIN_API_KEY unset", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "")
w := httptest.NewRecorder()
router().ServeHTTP(w, newReq())
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status: want 503 got %d", w.Code)
}
assertErrCode(t, w, "ADMIN_DISABLED")
})
t.Run("rejects wrong key", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "secret")
req := newReq()
req.Header.Set("X-Admin-Key", "nope")
w := httptest.NewRecorder()
router().ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status: want 401 got %d", w.Code)
}
assertErrCode(t, w, "UNAUTHORIZED")
})
t.Run("accepts correct key", func(t *testing.T) {
t.Setenv("ADMIN_API_KEY", "secret")
req := newReq()
req.Header.Set("X-Admin-Key", "secret")
w := httptest.NewRecorder()
router().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: want 200 got %d (%s)", w.Code, w.Body.String())
}
var got models.User
if err := database.GetDB().First(&got, user.ID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if !eqIntPtr(got.MaxOrderCents, intPtr(100)) {
t.Errorf("db MaxOrderCents: want 100 got %s", fmtIntPtr(got.MaxOrderCents))
}
})
}
func assertErrCode(t *testing.T, w *httptest.ResponseRecorder, want string) {
t.Helper()
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Error.Code != want {
t.Errorf("error code: want %q got %q", want, resp.Error.Code)
}
}

View File

@@ -2,8 +2,10 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"terminalShop/api/middleware"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
@@ -12,6 +14,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
func authLog() *slog.Logger { return slog.With("component", "auth") }
// AuthHandler handles authentication-related requests // AuthHandler handles authentication-related requests
type AuthHandler struct { type AuthHandler struct {
jwtManager *auth.JWTManager jwtManager *auth.JWTManager
@@ -42,22 +46,26 @@ type TokenResponse struct {
} }
func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
var req TokenRequest var req TokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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) utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return return
} }
// validate shared secret // validate shared secret
if req.ClientSecret != h.authFingerprintKey { 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) utils.RespondError(w, http.StatusUnauthorized, "INVALID_SECRET", "invalid client secret", nil)
return return
} }
// Return if no fingerprint is found // Return if no fingerprint is found
if req.Fingerprint == "" { if req.Fingerprint == "" {
middleware.RecordAuthAttempt("missing_fingerprint")
utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint is required", nil) utils.RespondError(w, http.StatusBadRequest, "MISSING_FINGERPRINT", "fingerprint is required", nil)
return return
} }
@@ -67,6 +75,8 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
if req.SSHPublicKey == "" { 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) utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil)
return return
} }
@@ -75,36 +85,30 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
SSHPublicKey: req.SSHPublicKey, SSHPublicKey: req.SSHPublicKey,
} }
if err := db.Create(&user).Error; err != nil { 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) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil)
return return
} }
middleware.RecordAuthAttempt("registered")
authLog().Info("user registered", "user_id", user.ID)
} else if err != nil { } else if err != 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) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to look up user", nil)
return return
} }
if req.SSHPublicKey != "" {
sshKey := models.SSHKey{
UserID: user.ID,
Fingerprint: req.Fingerprint,
PublicKey: req.SSHPublicKey,
}
if err := db.Where(models.SSHKey{Fingerprint: req.Fingerprint}).FirstOrCreate(&sshKey).Error; err != nil {
// we specifically want to block users whos ssh key failed to save. this is to prevent downstream errors which i dont want to deal with now
// !TODO add a way to just log the errror and app continue without trying to save carts, cards and addresses. this way the users wouldnt
// just be kicked, they would shop normally but on their next login they would need to add all their data again.
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save ssh key", nil)
return
}
}
token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name) token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name)
if err != nil { 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) utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil)
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ middleware.RecordAuthAttempt("success")
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"access_token": token, "access_token": token,
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 1800, // 30 minutes in seconds "expires_in": 1800, // 30 minutes in seconds
@@ -123,7 +127,7 @@ type RegisterRequest struct {
// RegisterWithSSHKey registers a new user with their SSH public key // RegisterWithSSHKey registers a new user with their SSH public key
// No username required - following terminal.shop pattern // No username required - following terminal.shop pattern
func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
var req RegisterRequest var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -142,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
} }
if len(errors) > 0 { if len(errors) > 0 {
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{ utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{
"errors": errors, "errors": errors,
}) })
return return
@@ -169,14 +173,14 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
return return
} }
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ utils.RespondSuccess(w, http.StatusCreated, map[string]any{
"user": user.ToPublic(), "user": user.ToPublic(),
}) })
} }
// GetUserBySSHKey retrieves a user by their SSH key fingerprint // GetUserBySSHKey retrieves a user by their SSH key fingerprint
func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) { 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") fingerprint := r.URL.Query().Get("fingerprint")
if fingerprint == "" { if fingerprint == "" {
@@ -191,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user": user.ToPublic(), "user": user.ToPublic(),
}) })
} }

View File

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

156
api/handlers/card_test.go Normal file
View File

@@ -0,0 +1,156 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strconv"
"testing"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/go-chi/chi/v5"
)
// TestGetCards_FiltersExpiredRows drives the SQL WHERE claude used by
// GetCards. a column-name typo (e.g used_id instead of user_id) surfaces
// here as a sqlite error, not in prod
func TestGetCards_FiltersExpiredRows(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
past := now.Add(-1 * time.Hour)
future := now.Add(24 * time.Hour)
expired := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
fresh := models.Card{
UserID: user.ID, StripePaymentID: "pm_fresh", Last4: "2222",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
}
legacy := models.Card{
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "3333",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
}
for _, card := range []*models.Card{&expired, &fresh, &legacy} {
if err := db.Create(card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
}
handler := NewCardHandler("")
req := authRequest("GET", "/api/v1/cards", nil, user.ID)
w := httptest.NewRecorder()
handler.GetCards(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetCards status %d, body %s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data struct {
Cards []models.Card `json:"cards"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Data.Cards) != 2 {
t.Fatalf("want 2 visible cards (fresh + legacy NULL), got %d", len(resp.Data.Cards))
}
for _, card := range resp.Data.Cards {
if card.ID == expired.ID {
t.Fatalf("expired card leaked into GetCards response")
}
}
}
// TestSetDefaultCard_ExpiredReturns410 verifies the sync-expire branch when
// the user acts on a card whose retention deadline already elapsed
func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
past := time.Now().Add(-1 * time.Hour)
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
handler := NewCardHandler("")
idStr := strconv.FormatUint(uint64(card.ID), 10)
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("id", idStr)
req = req.WithContext(contextWithRoute(req.Context(), routeCtx))
w := httptest.NewRecorder()
handler.SetDefaultCard(w, req)
if w.Code != http.StatusGone {
t.Fatalf("want 410 gone, got %d, body %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
t.Fatalf("want CARD_STORAGE_EXPIRED code, got %q", resp.Error.Code)
}
var soft models.Card
if err := db.Unscoped().First(&soft, card.ID).Error; err != nil {
t.Fatalf("reload card unscoped: %v", err)
}
if !soft.DeletedAt.Valid {
t.Fatalf("expired card was not soft-deleted")
}
}
// TestRefreshCardStorageTTL_BumpLastUsedAndExpiry exercises the SQL Updates
// map used by both the payment_intent.succeede webhook and the duplicate-card
// add path. Column-name typos in the map surface here
func TestRefreshCardStorageTTL_BumpLastUsedAndExpiry(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
old := time.Now().Add(-30 * 24 * time.Hour)
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_refresh", Last4: "4242",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &old,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
now := time.Now()
if err := refreshCardStorageTTL(db, card.ID, now); err != nil {
t.Fatalf("refreshCardStorageTTL: %v", err)
}
var reloaded models.Card
if err := db.First(&reloaded, card.ID).Error; err != nil {
t.Fatalf("reload card: %v", err)
}
if reloaded.LastUsedAt == nil || !reloaded.StorageExpiresAt.After(now.Add(179*24*time.Hour)) {
t.Fatalf("storage_expires_at not extended, got %v", reloaded.StorageExpiresAt)
}
}

View File

@@ -5,9 +5,11 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"sync" "strings"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
@@ -24,64 +26,79 @@ import (
stripeSession "github.com/stripe/stripe-go/v78/checkout/session" stripeSession "github.com/stripe/stripe-go/v78/checkout/session"
) )
// payRedirects maps short tokens to full Stripe checkout URLs. const RedirectTTL = 10 * time.Minute
var (
payRedirects = map[string]string{}
payRedirectsMu sync.Mutex
)
func storeRedirect(stripeURL string) string { func storeRedirect(stripeURL, purpose string) (string, error) {
b := make([]byte, 16) b := make([]byte, 16)
rand.Read(b) if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
payRedirectsMu.Lock() now := time.Now()
payRedirects[token] = stripeURL rec := models.PayRedirect{
payRedirectsMu.Unlock() Token: token,
return 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. // PayRedirect resolves a short token and redirects to the Stripe checkout URL.
func PayRedirect(w http.ResponseWriter, r *http.Request) { func PayRedirect(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token") token := chi.URLParam(r, "token")
payRedirectsMu.Lock() db := database.GetDB().WithContext(r.Context())
url, ok := payRedirects[token] var rec models.PayRedirect
payRedirectsMu.Unlock() if err := db.Where("token = ?", token).First(&rec).Error; err != nil {
if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return 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. // CardHandler handles saved payment method CRUD.
type CardHandler struct { type CardHandler struct {
stripeKey string appURL string
appURL string
} }
// NewCardHandler creates a new card handler with the Stripe secret key. // NewCardHandler creates a new card handler. Stripe credentials are wired
func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler { // once at startup via stripe.Key in api/main.go.
return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL} func NewCardHandler(appURL string) *CardHandler {
return &CardHandler{appURL: appURL}
} }
// GetCards returns all saved cards for the authenticated user. // GetCards returns all saved cards for the authenticated user.
// Cards past their retention deadline are filtered out at read time;
// physical deletion runs in handlers.ReconcileExpiredCards.
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var cards []models.Card var cards []models.Card
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards) db.Where(
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
userID, time.Now(),
).Order("is_default DESC, created_at DESC").Find(&cards)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cards": cards, "cards": cards,
}) })
} }
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{}) // func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any)
// GetCard returns a single saved card by ID. // GetCard returns a single saved card by ID.
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
@@ -97,13 +114,13 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"card": card, "card": card,
}) })
} }
func (h *CardHandler) SetDefaultCard(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()) userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
@@ -119,6 +136,15 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil { 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) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
return return
@@ -130,13 +156,13 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"card": card, "card": card,
}) })
} }
func (h *CardHandler) CollectCard(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()) userID := middleware.UserIDFromContext(r.Context())
var user models.User var user models.User
@@ -145,8 +171,6 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
return return
@@ -166,8 +190,12 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
return return
} }
token := storeRedirect(sess.URL) token, err := storeRedirect(sess.URL, models.RedirectPurposeAddCard)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]any{
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token), "url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
}) })
} }
@@ -184,7 +212,7 @@ type SaveCardRequest struct {
// fingerprint, attaches to the user's Stripe customer, and saves the // fingerprint, attaches to the user's Stripe customer, and saves the
// card metadata locally. No raw card data is stored at any point. // card metadata locally. No raw card data is stored at any point.
func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var req SaveCardRequest var req SaveCardRequest
@@ -204,8 +232,6 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
// Ensure the user has a Stripe customer, creating one if needed. // Ensure the user has a Stripe customer, creating one if needed.
if err := ensureStripeCustomer(db, &user); err != nil { if err := ensureStripeCustomer(db, &user); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
@@ -248,6 +274,13 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
return 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. // Persist only the safe card metadata — no raw card data is stored.
card := models.Card{ card := models.Card{
UserID: user.ID, UserID: user.ID,
@@ -265,7 +298,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
audit.CardAdded(userID, card.ID, card.Brand, card.Last4) audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ utils.RespondSuccess(w, http.StatusCreated, map[string]any{
"card": card, "card": card,
}) })
} }
@@ -273,7 +306,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 // DeleteCard removes a saved card. If the card is currently set on the user's
// cart, the cart's card selection is cleared. // cart, the cart's card selection is cleared.
func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
@@ -298,7 +331,6 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
// Detach from Stripe (best-effort; don't block deletion on Stripe errors). // Detach from Stripe (best-effort; don't block deletion on Stripe errors).
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" { if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
stripe.Key = h.stripeKey
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}) _, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
} }
@@ -310,11 +342,57 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
audit.CardDeleted(userID, card.ID) audit.CardDeleted(userID, card.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "card deleted", "message": "card deleted",
}) })
} }
// expireStoredCard removes a card whose retention deadline elapsed.
// Local state (cart references + card row) is committed atomically;
// Stripe detach and audit run only after the local commit so retries
// cannot double-fire the audit event. Stripe detach is gated on
// stripe.Key being non-empty so tests (which never init the SDK key)
// skip the real network call without an explicit flag.
func expireStoredCard(db *gorm.DB, card *models.Card) error {
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil {
return fmt.Errorf("failed to clear expired card from cart: %w", err)
}
if err := tx.Delete(card).Error; err != nil {
return fmt.Errorf("failed to delete expired card: %w", err)
}
return nil
}); err != nil {
return err
}
if stripe.Key != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
}
}
audit.CardExpired(card.UserID, card.ID)
return nil
}
func refreshCardStorageTTL(db *gorm.DB, cardID uint, now time.Time) error {
return db.Model(&models.Card{}).Where("id = ?", cardID).Updates(map[string]any{
"last_used_at": now,
"storage_expires_at": models.CardStorageExpiresAt(now),
}).Error
}
func respondCardStorageExpired(w http.ResponseWriter) {
utils.RespondError(
w,
http.StatusGone,
"CARD_STORAGE_EXPIRED",
"saved card expired, add it again",
nil,
)
}
// ensureStripeCustomer creates a Stripe Customer for the user if they don't // ensureStripeCustomer creates a Stripe Customer for the user if they don't
// already have one and persists the customer ID. Safe to call concurrently — // already have one and persists the customer ID. Safe to call concurrently —
// a duplicate Stripe customer is harmless; the local record reflects the first // a duplicate Stripe customer is harmless; the local record reflects the first
@@ -338,7 +416,7 @@ func ensureStripeCustomer(gdb *gorm.DB, user *models.User) error {
} }
user.StripeCustomerID = cust.ID user.StripeCustomerID = cust.ID
if err := gdb.Save(user).Error; err != nil { if err := gdb.Save(user).Error; err != nil {
return fmt.Errorf("Failed to save stripe customer: %w", err) return fmt.Errorf("failed to save stripe customer: %w", err)
} }
return nil return nil
} }

View File

@@ -0,0 +1,156 @@
package handlers
import (
"context"
"os"
"testing"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// TestReconcileExpiredCards_RemovesPastDeadlineOnly drives the real SQL path
// so a column-name typo in the WHERE clause surfaces here, not in production.
func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
past := now.Add(-1 * time.Hour)
future := now.Add(24 * time.Hour)
expired := models.Card{
UserID: user.ID,
StripePaymentID: "tok_expired",
Last4: "1111",
Brand: "Visa",
ExpMonth: 1,
ExpYear: 2030,
StorageExpiresAt: &past,
}
fresh := models.Card{
UserID: user.ID,
StripePaymentID: "tok_fresh",
Last4: "2222",
Brand: "Visa",
ExpMonth: 1,
ExpYear: 2030,
StorageExpiresAt: &future,
}
noTTL := models.Card{
UserID: user.ID,
StripePaymentID: "tok_legacy",
Last4: "3333",
Brand: "Visa",
ExpMonth: 1,
ExpYear: 2030,
}
for _, c := range []*models.Card{&expired, &fresh, &noTTL} {
if err := db.Create(c).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
}
cart := models.Cart{UserID: user.ID, CardID: &expired.ID}
if err := db.Create(&cart).Error; err != nil {
t.Fatalf("seed cart: %v", err)
}
ReconcileExpiredCards(context.Background())
var remaining []models.Card
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
t.Fatalf("list cards: %v", err)
}
if len(remaining) != 2 {
t.Fatalf("want 2 cards remaining, got %d", len(remaining))
}
for _, c := range remaining {
if c.ID == expired.ID {
t.Fatalf("expired card was not deleted")
}
}
var refreshed models.Cart
if err := db.First(&refreshed, cart.ID).Error; err != nil {
t.Fatalf("reload cart: %v", err)
}
if refreshed.CardID != nil {
t.Fatalf("cart still references expired card: %v", refreshed.CardID)
}
}
// TestReconcileExpiredCards_SweepsAcrossUsers verifies the sweeper is not
// scoped to one user; it must collect every past-deadline row in one pass.
func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
past := time.Now().Add(-1 * time.Hour)
other := models.User{
SSHKeyFingerprint: "SHA256:other",
SSHPublicKey: "ssh-ed25519 AAAA other",
Name: "Other",
Email: "other@example.com",
}
if err := db.Create(&other).Error; err != nil {
t.Fatalf("seed other user: %v", err)
}
for _, uid := range []uint{user.ID, other.ID} {
c := models.Card{
UserID: uid,
StripePaymentID: "tok_expired",
Last4: "9999",
Brand: "Visa",
ExpMonth: 1,
ExpYear: 2030,
StorageExpiresAt: &past,
}
if err := db.Create(&c).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
}
ReconcileExpiredCards(context.Background())
var count int64
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
t.Fatalf("count cards: %v", err)
}
if count != 0 {
t.Fatalf("want 0 cards after sweep, got %d", count)
}
}
func setupCardsExpireTestDB(t *testing.T) (string, models.User) {
t.Helper()
testDB := "test_cards_expire.db"
_ = os.Remove(testDB)
database.ResetForTesting()
db, err := database.Connect(testDB)
if err != nil {
t.Fatalf("connect: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:cards-expire",
SSHPublicKey: "ssh-ed25519 AAAA cardsexpire",
Name: "Test User",
Email: "cards-expire@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
return testDB, user
}

View File

@@ -1,34 +1,61 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog"
"net/http" "net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentintent"
"github.com/stripe/stripe-go/v78/paymentmethod" "github.com/stripe/stripe-go/v78/paymentmethod"
"gorm.io/gorm" "gorm.io/gorm"
"go.opentelemetry.io/otel"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit" "terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/notify"
"terminalShop/pkg/utils" "terminalShop/pkg/utils"
) )
type CartHandler struct { type CartHandler struct {
stripeKey string appURL string
maxOrderCents int
} }
// NewCartHandler creates a new cart handler with the Stripe secret key // maxAuthIssuances caps how many times a single ordes's PaymentIntent can be
func NewCartHandler(stripeSecretKey string) *CartHandler { // pushed into requires_action; one initial challende plus up to three retries
return &CartHandler{stripeKey: stripeSecretKey} // before we tell the customer to use a different card. Counted via existing
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
const maxAuthIssuances = 4
// paymentIntentNewFn lets tests stub the Stripe create call without spinning
// up an httptest backend. Production callers go straight through to the real
// paymentintent.New. Mirrors paymentIntentGetFn in reconcile.go.
var paymentIntentNewFn = paymentintent.New
var (
paymentMethodNewFn = paymentmethod.New
paymentMethodDetachFn = paymentmethod.Detach
)
// NewCartHandler creates a new cart handler. Stripe credentials are wired
// once at startup via stripe.Key in api/main.go.
func NewCartHandler(appURL string, maxOrderCents int) *CartHandler {
return &CartHandler{appURL: appURL, maxOrderCents: maxOrderCents}
} }
// getOrCreateCart finds the users cart or creates one. // getOrCreateCart finds the users cart or creates one.
func getOrCreateCart(userID uint) (*models.Cart, error) { func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
db := database.GetDB() db := database.GetDB().WithContext(ctx)
var cart models.Cart var cart models.Cart
err := db.Where("user_id = ?", userID).First(&cart).Error err := db.Where("user_id = ?", userID).First(&cart).Error
if err != nil { if err != nil {
@@ -40,18 +67,18 @@ func getOrCreateCart(userID uint) (*models.Cart, error) {
return &cart, nil return &cart, nil
} }
func cartResponse(cart *models.Cart) map[string]interface{} { func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
db := database.GetDB() db := database.GetDB().WithContext(ctx)
var items []models.CartItem var items []models.CartItem
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
subtotal := 0 subtotal := 0
itemsData := make([]map[string]interface{}, 0, len(items)) itemsData := make([]map[string]any, 0, len(items))
for _, item := range items { for _, item := range items {
lineTotal := item.Coffee.Price * item.Quantity lineTotal := item.Coffee.Price * item.Quantity
subtotal += lineTotal subtotal += lineTotal
itemsData = append(itemsData, map[string]interface{}{ itemsData = append(itemsData, map[string]any{
"id": item.ID, "id": item.ID,
"coffee_id": item.CoffeeID, "coffee_id": item.CoffeeID,
"quantity": item.Quantity, "quantity": item.Quantity,
@@ -60,7 +87,7 @@ func cartResponse(cart *models.Cart) map[string]interface{} {
}) })
} }
return map[string]interface{}{ return map[string]any{
"items": itemsData, "items": itemsData,
"subtotal": subtotal, "subtotal": subtotal,
"address_id": cart.AddressID, "address_id": cart.AddressID,
@@ -73,14 +100,14 @@ func cartResponse(cart *models.Cart) map[string]interface{} {
func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) { func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
cart, err := getOrCreateCart(userID) cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cart": cartResponse(cart), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -90,7 +117,7 @@ type SetItemRequest struct {
} }
func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) { func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var req SetItemRequest var req SetItemRequest
@@ -110,7 +137,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
return return
} }
cart, err := getOrCreateCart(userID) cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return return
@@ -142,8 +169,8 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
} }
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"cart": cartResponse(cart), "cart": cartResponse(r.Context(), cart),
}) })
} }
@@ -152,7 +179,7 @@ type SetAddressRequest struct {
} }
func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) { func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var req SetAddressRequest var req SetAddressRequest
@@ -167,7 +194,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
cart, err := getOrCreateCart(userID) cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return return
@@ -178,7 +205,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "address set", "message": "address set",
}) })
} }
@@ -188,7 +215,7 @@ type SetCardRequest struct {
} }
func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) { func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var req SetCardRequest var req SetCardRequest
@@ -203,7 +230,16 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
cart, err := getOrCreateCart(userID) if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return return
@@ -214,16 +250,16 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "card set", "message": "card set",
}) })
} }
func (h *CartHandler) ClearCart(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()) userID := middleware.UserIDFromContext(r.Context())
cart, err := getOrCreateCart(userID) cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return return
@@ -234,17 +270,38 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"message": "cart cleared", "message": "cart cleared",
}) })
} }
// ConvertCartRequest carries the optional privacy-checkout controls. The body
// is optional: a saved.card checkout sends nothing and both field stay zero.
// Ephemeral opts a single order into privacy mode (ont-time card, no Card row,
// PM detached after settle); CardToken supplies that one-time card. A user with
// PrivacyMode set is ephemeral regardless of the flag.
type ConvertCartRequest struct {
Ephemeral bool `json:"ephemeral"`
CardToken string `json:"card_token"`
}
func (h *CartHandler) ConvertCart(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()) 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)
var req ConvertCartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
return
}
cart, err := getOrCreateCart(r.Context(), userID)
if err != nil { if err != nil {
middleware.RecordCartConversion("cart_lookup_failed")
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
return return
} }
@@ -253,72 +310,115 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
if len(items) == 0 { if len(items) == 0 {
middleware.RecordCartConversion("validation_empty_cart")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil) utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
return return
} }
if cart.AddressID == nil { if cart.AddressID == nil {
middleware.RecordCartConversion("validation_missing_address")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "shipping address is required", nil) utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "shipping address is required", nil)
return return
} }
if cart.CardID == nil {
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
return
}
var address models.Address var address models.Address
if err := db.First(&address, *cart.AddressID).Error; err != nil { 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) utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "shipping address not found", nil)
return return
} }
var card models.Card
if err := db.First(&card, *cart.CardID).Error; err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
return
}
var user models.User var user models.User
if err := db.First(&user, userID).Error; err != nil { 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) utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
return return
} }
stripe.Key = h.stripeKey // Per-order intent drives the charge SHAPE: req.Ephemeral picks the one-time
// token path (no saved card). Privacy mode adds a second guarantee on the
// saved-card path — the live QR add-card flow has no "save anyway" prompt, so
// a privacy user who selects a (QR-collected) card still must not have it
// retained. forgetCard charges that card normally, then detaches the
// PaymentMethod and hard-deletes the local Card row once the order settles.
// It never applies to the token path, which has no local row.
ephemeral := req.Ephemeral
forgetCard := user.PrivacyMode && !ephemeral
// Ensure the user has a Stripe customer (shared helper with cards handler). var card models.Card
if err := ensureStripeCustomer(db, &user); err != nil { var paymentMethodID string
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) if ephemeral {
return if req.CardToken == "" {
} middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "card token is required for private checkout", nil)
// All cards are now saved as pm_ PaymentMethods. This branch handles any
// legacy tok_ records that predate the server-side tokenization refactor.
paymentMethodID := card.StripePaymentID
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(paymentMethodID),
},
})
if pmErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return return
} }
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{ pm, pmerr := paymentMethodNewFn(&stripe.PaymentMethodParams{
Customer: stripe.String(user.StripeCustomerID), Type: stripe.String("card"),
}); attachErr != nil { Card: &stripe.PaymentMethodCardParams{
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil) Token: stripe.String(req.CardToken),
},
})
if pmerr != nil {
middleware.RecordCartConversion("stripe_error")
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "failed to create payment method", nil)
return return
} }
paymentMethodID = pm.ID paymentMethodID = pm.ID
card.StripePaymentID = pm.ID } else {
if err := db.Save(&card).Error; err != nil { if cart.CardID == nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil) middleware.RecordCartConversion("validation_missing_card")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
return return
} }
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
}
if card.IsStorageExpired(time.Now()) {
middleware.RecordCartConversion("validation_card_expired")
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
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
}
paymentMethodID = card.StripePaymentID
if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" {
pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{
Type: stripe.String("card"),
Card: &stripe.PaymentMethodCardParams{
Token: stripe.String(paymentMethodID),
},
})
if pmErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
return
}
if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
Customer: stripe.String(user.StripeCustomerID),
}); attachErr != nil {
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
return
}
paymentMethodID = pm.ID
card.StripePaymentID = pm.ID
if err := db.Save(&card).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil)
return
}
}
} }
var subtotal int var subtotal int
@@ -336,7 +436,52 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
total := subtotal + cart.ShippingCost total := subtotal + cart.ShippingCost
// Per-user overridable spend cap. The override may raise OR lower the global
// cap — operators set it in both directions deliberately. A negative override
// is nonsense: ignore it and fall back to the global so an erroneous DB write
// cannot silently disable a fraud control (mirrors loadMaxOrderCents in
// pkg/config/config.go, which defaults a negative MAX_ORDER_CENTS). An explicit
// 0 is a valid per-user off-switch (same semantics as the global).
capCents := h.maxOrderCents
if user.MaxOrderCents != nil {
if *user.MaxOrderCents < 0 {
slog.Warn("ignoring negative per-user order cap, using global",
"user_id", user.ID,
"user_cap_cents", *user.MaxOrderCents,
"global_cap_cents", h.maxOrderCents)
} else {
capCents = *user.MaxOrderCents
}
}
// A user-set self-limit may only TIGHTEN the effective cap, never raise it.
// a compromised account must not be able to lift its own ceiling to drain a
// stolen card. Unlike the admin/global cap (where 0 is an off-switch), a
// present self-limit always activates a ceiling, a self-limit of 0 means
// "block everything", not "disable", so it folds in via min() and can only
// lower capCents. A negative value is ignored (mirrors the maxOrderCents
// guard above); this min() is the authoritative lower-only guard.
capActive := capCents > 0
if user.SelfLimitCents != nil && *user.SelfLimitCents >= 0 {
if self := *user.SelfLimitCents; !capActive || self < capCents {
capCents = self
}
capActive = true
}
if capActive && total > capCents {
middleware.RecordCartConversion("validation_over_limit")
audit.CartRejected(userID, total, capCents)
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
fmt.Sprintf("order total must be at most $%.2f", float64(capCents)/100),
map[string]any{
"total_cents": total,
"limit_cents": capCents,
})
return
}
if total < 50 { if total < 50 {
middleware.RecordCartConversion("validation_min_amount")
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil) utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
return return
} }
@@ -347,6 +492,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
UserID: user.ID, UserID: user.ID,
CardID: card.ID, CardID: card.ID,
StripePaymentID: "", StripePaymentID: "",
Ephemeral: ephemeral || forgetCard,
Status: models.OrderStatusPending, Status: models.OrderStatusPending,
Subtotal: subtotal, Subtotal: subtotal,
ShippingCost: cart.ShippingCost, ShippingCost: cart.ShippingCost,
@@ -363,25 +509,34 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
} }
if err := db.Create(&order).Error; err != nil { 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) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil)
return return
} }
audit.OrderCreated(userID, order.ID, total) audit.OrderCreated(userID, order.ID, total)
// Charge the card. The idempotency key is tied to the order ID so retries // Charge the card. This is a Customer-Initiated Transaction (CIT): the
// never produce a double charge. // customer is present in the TUI at checkout. We deliberately do NOT set
// OffSession — that flag marks a Merchant-Initiated Transaction (MIT),
// which under PSD2/SCA may only claim an exemption when a stored mandate
// authorises the merchant to charge while the customer is away. We capture
// no mandate and run no recurring billing, so MIT would misrepresent the
// transaction type. As a CIT, an SCA challenge surfaces as
// pi.Status == requires_action (handled below), not as an
// authentication_required error. See docs/sca-psd2-compliance.md "Gap 1".
// Reference: stripe-samples accept-a-payment custom-payment-flow —
// php/public/google-pay.php drives 3DS off status==requires_action and sets
// no off_session on a customer-present confirm.
// The idempotency key is tied to the order ID so retries never double charge.
piParams := &stripe.PaymentIntentParams{ piParams := &stripe.PaymentIntentParams{
Amount: stripe.Int64(int64(total)), Amount: stripe.Int64(int64(total)),
Currency: stripe.String(string(stripe.CurrencyUSD)), Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(user.StripeCustomerID), PaymentMethod: stripe.String(paymentMethodID),
PaymentMethod: stripe.String(paymentMethodID), PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ ReturnURL: stripe.String(h.appURL + "/post-3ds"),
Enabled: stripe.Bool(true), Confirm: stripe.Bool(true),
AllowRedirects: stripe.String("never"), Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
},
Confirm: stripe.Bool(true),
Description: stripe.String(fmt.Sprintf("terminal.shop order #%d", order.ID)),
Shipping: &stripe.ShippingDetailsParams{ Shipping: &stripe.ShippingDetailsParams{
Name: stripe.String(address.Name), Name: stripe.String(address.Name),
Address: &stripe.AddressParams{ Address: &stripe.AddressParams{
@@ -399,27 +554,54 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
"order_id": fmt.Sprintf("%d", order.ID), "order_id": fmt.Sprintf("%d", order.ID),
} }
pi, err := paymentintent.New(piParams) if !ephemeral {
piParams.Customer = stripe.String(user.StripeCustomerID)
}
stripeStart := time.Now()
pi, err := paymentIntentNewFn(piParams)
stripeDur := time.Since(stripeStart).Seconds()
if err != nil { 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, ok := err.(*stripe.Error); ok {
// Defensive fallback: as CIT (no off_session) Stripe normally
// signals 3DS via status=requires_action below, not via this error.
// authentication_required can still appear if the saved card needs
// auth that could not run inline; respondRequiresAction re-Confirms
// on-session to recover. (Pre-CIT migration this was the main path.)
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 { switch stripeErr.Type {
case stripe.ErrorTypeCard: case stripe.ErrorTypeCard:
middleware.RecordCartConversion("card_declined")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) 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: case stripe.ErrorTypeInvalidRequest:
middleware.RecordCartConversion("stripe_invalid_request")
audit.OrderFailed(userID, order.ID, total, stripeErr.Msg) audit.OrderFailed(userID, order.ID, total, stripeErr.Msg)
utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil) utils.RespondError(w, http.StatusBadRequest, "INVALID_PAYMENT", stripeErr.Msg, nil)
default: default:
middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, stripeErr.Error()) audit.OrderFailed(userID, order.ID, total, stripeErr.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil) utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
} }
return return
} }
db.Model(&order).Update("status", models.OrderStatusFailed)
middleware.RecordCartConversion("stripe_error")
audit.OrderFailed(userID, order.ID, total, err.Error()) audit.OrderFailed(userID, order.ID, total, err.Error())
utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil) utils.RespondError(w, http.StatusInternalServerError, "PAYMENT_FAILED", "payment failed", nil)
return 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. // Commit payment ID and clear cart in a single transaction.
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
@@ -431,7 +613,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(cart).Updates(map[string]interface{}{ return tx.Model(cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
@@ -441,19 +623,285 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
// most dangerous failure mode: the customer paid but has no order. // most dangerous failure mode: the customer paid but has no order.
// The reconciliation job will catch this within 5 minutes. // The reconciliation job will catch this within 5 minutes.
// The audit log entry below must be treated as a critical alert. // The audit log entry below must be treated as a critical alert.
middleware.RecordCartConversion("payment_critical")
audit.PaymentCritical(order.ID, pi.ID, txErr) 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) utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "payment succeeded but failed to record order — please contact support", nil)
return return
} }
if ephemeral || forgetCard {
if _, derr := paymentMethodDetachFn(paymentMethodID, nil); derr != nil {
slog.Warn("failed to detach ephemeral payment method", "order_id", order.ID, "error", derr)
}
}
if forgetCard {
// Privacy mode: the card was persisted only long enough to select and
// charge it. Null the order's reference and hard-delete the local row so
// no card data (last4/brand/token) survives the checkout. Unscoped because
// Card uses gorm soft-delete; a plain delete would leave the data on disk.
// Mirrors upstream Card.remove (terminal-shop-source core/src/card/index.ts):
// detach the PM, drop the FK, delete the row.
if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
slog.Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
}
if err := db.Unscoped().Delete(&models.Card{}, card.ID).Error; err != nil {
slog.Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", card.ID, "error", err)
}
}
audit.OrderPaid(userID, order.ID, total, pi.ID) audit.OrderPaid(userID, order.ID, total, pi.ID)
middleware.RecordOrderCreated(string(order.Status))
middleware.RecordCartConversion("success")
middleware.ObserveOrderValueCents(total)
if err := db.Preload("Items").First(&order, order.ID).Error; err != nil { if err := db.Preload("Items").First(&order, order.ID).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to preload items", nil) utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to preload items", nil)
return return
} }
go notify.SlackOrderPaid(&order)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"order": order, "order": order,
}) })
} }
// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order
// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md);
// the payment_intent.succeeded 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 set status to requires_action so list
// UIs can distinguish "charging" from "awaiting customer 3DS". 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.OrderStatusRequiresAction,
}).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)
}
// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed
// or abandoned during the 3DS challenge, recovering the existing order rather
// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth.
//
// Stripe's PI state machine bounces back to requires_payment_method after a
// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh
// next_action.redirect_to_url — the same shape respondRequiresAction already
// handles. No idempotency key is set: Confirm mutates an existing PI and
// Stripe collapses by id (see sca-psd2-compliance.md caveat #5).
func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id")
id64, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
return
}
orderID := uint(id64)
var order models.Order
if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
return
}
// Retryable source states:
// pending - initial attempt mid-flight (pre-PI confirm page)
// requires_action - customer abandoned or failed the 3DS challenge
// failed - webhook flipped after a non-recoverable error
// terminal states (paid, shipped, delivered, cancelled, refunded) refuse.
if order.Status != models.OrderStatusPending &&
order.Status != models.OrderStatusRequiresAction &&
order.Status != models.OrderStatusFailed {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
return
}
if order.StripePaymentID == "" {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order has no payment intent to retry", nil)
return
}
// Cap is best-effort: two concurrent retries at issuances=cap-1 will both
// pass this check and both Confirm before either writes a new event row.
// Per-user RateLimitByUser(10/min) on the route bounds the worst case.
// SQLite-WAL has no SELECT ... FOR UPDATE, so a stricter guard would need
// a serializing INSERT - not worth the cimplexity at current traffic.
var issuances int64
if err := db.Model(&models.OrderEvent{}).
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
Count(&issuances).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to count auth attempts", nil)
return
}
if issuances >= maxAuthIssuances {
middleware.RecordCartConversion("retry_max_attempts")
utils.RespondError(w, http.StatusTooManyRequests, "RETRY_LIMIT", "too many authentication attempts, please use a different card", nil)
return
}
// scoped card lookup - match the initial-flow policy in ConvertCart so a
// card the user deleted (or that the storage-TTL reconciler removed)
// cannot be silently re-charged through retry. The ownership clause is
// defensive: order.CardID is enforced at SetCard time today but binding
// the retry path to that invariant explicitly costs nothing.
var card models.Card
if err := db.Where("id = ? AND user_id = ?", order.CardID, userID).First(&card).Error; err != nil {
utils.RespondError(w, http.StatusConflict, "CARD_NO_LONGER_AVAILABLE", "saved card is no longer available, add it again to retry", nil)
return
}
if card.IsStorageExpired(time.Now()) {
if err := expireStoredCard(db, &card); err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
return
}
respondCardStorageExpired(w)
return
}
stripeStart := time.Now()
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
PaymentMethod: stripe.String(card.StripePaymentID),
OffSession: stripe.Bool(false),
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
})
stripeDur := time.Since(stripeStart).Seconds()
if cerr != nil {
middleware.ObserveStripeRequest("payment_intent_confirm", "error", stripeDur)
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
db.Model(&order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(userID, order.ID, order.Total, stripeErr.Msg)
middleware.RecordCartConversion("retry_card_declined")
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
return
}
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Code == "payment_intent_unexpected_state" {
actual, gerr := paymentintent.Get(order.StripePaymentID, nil)
if gerr != nil {
middleware.RecordCartConversion("retry_stripe_error")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
return
}
switch actual.Status {
case stripe.PaymentIntentStatusSucceeded:
middleware.RecordCartConversion("retry_race_already_done")
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"status": "succeeded",
})
return
case stripe.PaymentIntentStatusCanceled:
db.Model(&order).Update("status", models.OrderStatusFailed)
audit.OrderFailed(userID, order.ID, order.Total, "payment intent canceled")
middleware.RecordCartConversion("retry_pi_canceled")
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "payment intent is canceled, please start a new order", nil)
return
default:
middleware.RecordCartConversion("retry_pi_unexpected_state")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment intent in unexpected state", nil)
return
}
}
middleware.RecordCartConversion("retry_stripe_error")
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
return
}
middleware.ObserveStripeRequest("payment_intent_confirm", "success", stripeDur)
// frictionless 3DS on retry, stripe approved without a new challenge.
// the payment_intent.succeeded webhook owns the order.status=paid
// transition (matches the initial-flow contract); tell the TUI to keep
// polling /orders/{id}/status until it lands
if confirmed.Status == stripe.PaymentIntentStatusSucceeded {
middleware.RecordCartConversion("retry_frictionless")
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
"order_id": order.ID,
"status": "succeeded",
})
return
}
h.respondRequiresAction(w, &order, card.StripePaymentID, confirmed)
}

File diff suppressed because it is too large Load Diff

3
api/handlers/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package handlers is where the real work happens, one function per endpoint:
// carts, orders, products, addresses, cards, webhooks, and the rest.
package handlers

View File

@@ -0,0 +1,421 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// seedEphemeralCart seeds an item + address on the user's cart but deliberately
// NO card — the ephemeral (privacy) path supplies a one-time token at checkout
// instead of selecting a saved card.
func seedEphemeralCart(t *testing.T, user models.User) {
t.Helper()
db := database.GetDB()
addr := models.Address{
UserID: user.ID, Name: "test", Street: "1 Main St",
City: "PDX", State: "OR", Zip: "97201", Country: "US",
}
if err := db.Create(&addr).Error; err != nil {
t.Fatalf("seed address: %v", err)
}
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", 500).Error; err != nil {
t.Fatalf("pin coffee price: %v", err)
}
h := NewCartHandler("", 0)
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": 2})
w := httptest.NewRecorder()
h.SetItem(w, authRequest("PUT", "/api/v1/cart/item", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetItem: %d %s", w.Code, w.Body.String())
}
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
w = httptest.NewRecorder()
h.SetAddress(w, authRequest("PUT", "/api/v1/cart/address", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String())
}
}
// TestConvertCart_Ephemeral_InlineSuccess proves the privacy charge path: a
// one-time PaymentMethod is created from the token, the PaymentIntent attaches
// NO Customer, the PM is detached after settle, no Card row is written, and the
// order is paid + marked Ephemeral + keeps the PaymentIntent id (so refunds
// still work). The table covers both drivers: the per-request flag and the
// account-level PrivacyMode switch.
func TestConvertCart_Ephemeral_InlineSuccess(t *testing.T) {
cases := []struct {
name string
reqEphem bool // request "ephemeral"
}{
{"per-request flag", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
seedEphemeralCart(t, user)
var pmNewToken string
var piParams *stripe.PaymentIntentParams
var detached string
origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn
defer func() {
paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach
}()
paymentMethodNewFn = func(p *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
if p.Card != nil && p.Card.Token != nil {
pmNewToken = *p.Card.Token
}
return &stripe.PaymentMethod{ID: "pm_once"}, nil
}
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piParams = p
return &stripe.PaymentIntent{ID: "pi_eph", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": tc.reqEphem, "card_token": "tok_visa"})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
}
if pmNewToken != "tok_visa" {
t.Errorf("one-time PM token: want tok_visa, got %q", pmNewToken)
}
if piParams == nil {
t.Fatal("paymentIntentNewFn was never called")
}
if piParams.Customer != nil {
t.Errorf("ephemeral charge must NOT attach a Customer; got %q", *piParams.Customer)
}
if piParams.PaymentMethod == nil || *piParams.PaymentMethod != "pm_once" {
t.Error("PI must charge the one-time PM pm_once")
}
if detached != "pm_once" {
t.Errorf("PM must be detached after settle; detached=%q", detached)
}
var cardCount int64
db.Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("ephemeral checkout must not save a Card row; found %d", cardCount)
}
var order models.Order
if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("order.Ephemeral must be true")
}
if order.CardID != 0 {
t.Errorf("ephemeral order must have CardID 0, got %d", order.CardID)
}
if order.Status != models.OrderStatusPaid {
t.Errorf("status: want paid, got %q", order.Status)
}
if order.StripePaymentID != "pi_eph" {
t.Errorf("order must keep the PaymentIntent id for refunds, got %q", order.StripePaymentID)
}
})
}
}
// TestConvertCart_Ephemeral_MissingTokenRejected: a privacy checkout with no
// card token is a 400, and we never reach Stripe.
func TestConvertCart_Ephemeral_MissingTokenRejected(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
seedEphemeralCart(t, user)
piCalled := false
origPI := paymentIntentNewFn
defer func() { paymentIntentNewFn = origPI }()
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piCalled = true
return &stripe.PaymentIntent{ID: "pi_x", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": true}) // no card_token
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d body=%s", w.Code, w.Body.String())
}
if piCalled {
t.Error("must not create a PaymentIntent when the card token is missing")
}
}
// TestConvertCart_Ephemeral_RequiresActionDefersDetach: on a 3DS challenge the
// charge has not settled, so the PM must stay attached (the webhook detaches it
// later). The order persists Ephemeral so the webhook knows to detach.
func TestConvertCart_Ephemeral_RequiresActionDefersDetach(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
seedEphemeralCart(t, user)
detachCalled := false
origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn
defer func() {
paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach
}()
paymentMethodNewFn = func(_ *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
return &stripe.PaymentMethod{ID: "pm_once_3ds"}, nil
}
paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: "pi_eph_3ds",
Status: stripe.PaymentIntentStatusRequiresAction,
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{URL: "https://hooks.stripe.com/3ds/test"},
},
}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detachCalled = true
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": true, "card_token": "tok_3ds"})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
if detachCalled {
t.Error("must NOT detach the PM while 3DS is pending; the webhook owns the deferred detach")
}
var order models.Order
if err := database.GetDB().Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("order.Ephemeral must persist so the webhook can detach later")
}
if order.Status != models.OrderStatusRequiresAction {
t.Errorf("status: want requires_action, got %q", order.Status)
}
}
// TestHandlePaymentIntentSucceeded_EphemeralDetach exercises the deferred (3DS)
// detach: an Ephemeral order finishing via webhook detaches its PM; a
// non-ephemeral one does not.
func TestHandlePaymentIntentSucceeded_EphemeralDetach(t *testing.T) {
cases := []struct {
name string
ephemeral bool
wantDetach string // expected detached PM id, "" if none
}{
{"ephemeral order detaches PM", true, "pm_eph_once"},
{"non-ephemeral order keeps PM", false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
order := models.Order{
UserID: user.ID, CardID: 0, Ephemeral: tc.ephemeral,
StripePaymentID: "pi_eph_webhook", Status: models.OrderStatusRequiresAction,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR", ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
detached := ""
origDetach := paymentMethodDetachFn
defer func() { paymentMethodDetachFn = origDetach }()
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"currency": "usd",
"payment_method": "pm_eph_once",
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, _ := json.Marshal(pi)
evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}}
NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("status: want paid, got %q", reloaded.Status)
}
if detached != tc.wantDetach {
t.Errorf("detach: want %q, got %q", tc.wantDetach, detached)
}
})
}
}
// TestConvertCart_PrivacyModeForgetsCard proves the privacy guarantee on the
// saved-card path: a privacy_mode user who checks out a saved (QR-collected) card
// is charged normally (Customer attached — the PM is customer-attached, so the
// charge requires it), but once the charge settles the PaymentMethod is detached
// and the local Card row is hard-deleted, and the order is marked Ephemeral. The
// live QR add-card flow has no "save anyway" prompt, so privacy mode is the
// authoritative signal here.
func TestConvertCart_PrivacyModeForgetsCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
if err := db.Model(&user).Update("privacy_mode", true).Error; err != nil {
t.Fatalf("set privacy_mode: %v", err)
}
seedConvertReadyCart(t, user) // seeds a SAVED card + address + item
var piParams *stripe.PaymentIntentParams
detached := ""
origPI, origDetach := paymentIntentNewFn, paymentMethodDetachFn
defer func() { paymentIntentNewFn, paymentMethodDetachFn = origPI, origDetach }()
paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
piParams = p
return &stripe.PaymentIntent{ID: "pi_forget", Status: stripe.PaymentIntentStatusSucceeded}, nil
}
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
detached = id
return &stripe.PaymentMethod{ID: id}, nil
}
body, _ := json.Marshal(map[string]any{"ephemeral": false})
w := httptest.NewRecorder()
NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID))
if w.Code != http.StatusOK {
t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String())
}
// The PM is attached to the Stripe customer, so the charge must still set
// Customer — you cannot charge an attached PM without it.
if piParams == nil || piParams.Customer == nil {
t.Error("saved-card charge must attach a Customer")
}
// ...but privacy mode forgets it afterward: detach + hard-delete.
if detached != "pm_test_cit" {
t.Errorf("forgotten card PM must be detached after settle; detached=%q", detached)
}
var cardCount int64
db.Unscoped().Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("privacy checkout must HARD-delete the local card row; found %d", cardCount)
}
var order models.Order
if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil {
t.Fatalf("load order: %v", err)
}
if !order.Ephemeral {
t.Error("forgotten-card order must be marked Ephemeral")
}
if order.CardID != 0 {
t.Errorf("forgotten-card order must null its CardID, got %d", order.CardID)
}
if order.Status != models.OrderStatusPaid {
t.Errorf("status: want paid, got %q", order.Status)
}
}
// TestHandlePaymentIntentSucceeded_ForgetsCard covers the deferred (3DS) twin of
// the inline forget-card cleanup: an Ephemeral order that still carries a local
// Card row (CardID != 0, the privacy + saved/QR-card case) has that row
// hard-deleted and its CardID nulled when the webhook marks it paid.
func TestHandlePaymentIntentSucceeded_ForgetsCard(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_forget_webhook",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID, Ephemeral: true,
StripePaymentID: "pi_forget_webhook", Status: models.OrderStatusRequiresAction,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR", ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
origDetach := paymentMethodDetachFn
defer func() { paymentMethodDetachFn = origDetach }()
paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
return &stripe.PaymentMethod{ID: id}, nil
}
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"currency": "usd",
"payment_method": "pm_forget_webhook",
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, _ := json.Marshal(pi)
evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}}
NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt)
var cardCount int64
db.Unscoped().Model(&models.Card{}).Where("id = ?", card.ID).Count(&cardCount)
if cardCount != 0 {
t.Errorf("webhook must hard-delete the forgotten card; found %d", cardCount)
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Errorf("status: want paid, got %q", reloaded.Status)
}
if reloaded.CardID != 0 {
t.Errorf("order CardID must be nulled, got %d", reloaded.CardID)
}
}

View File

@@ -1,44 +1,177 @@
package handlers package handlers
import ( import (
"encoding/json"
"fmt"
"log/slog"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings"
"time"
"github.com/go-chi/chi/v5" "terminalShop/pkg/bring"
"github.com/stripe/stripe-go/v78" "terminalShop/pkg/notify"
"github.com/stripe/stripe-go/v78/refund" "terminalShop/pkg/shipping"
"terminalShop/pkg/shippo"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/audit" "terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/utils" "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 { type OrderHandler struct {
stripeKey string bringAPIUID string
bringAPIKey string
bringCustomerNumber string
} }
func NewOrderHandler(stripeSecretKey string) *OrderHandler { type refundRequestPayload struct {
return &OrderHandler{stripeKey: stripeSecretKey} 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
// NewOrderHandler creates an order handler. Stripe credentials are wired
// once at startup via stripe.Key in api/main.go.
func NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
return &OrderHandler{
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) { func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var orders []models.Order 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, "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 // 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. // refunded. Only orders in paid or shipped status can be refunded.
func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) { func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
@@ -67,8 +200,6 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
return return
} }
stripe.Key = h.stripeKey
rf, err := refund.New(&stripe.RefundParams{ rf, err := refund.New(&stripe.RefundParams{
PaymentIntent: stripe.String(order.StripePaymentID), PaymentIntent: stripe.String(order.StripePaymentID),
}) })
@@ -88,8 +219,186 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID) audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"refund_id": rf.ID, "refund_id": rf.ID,
"status": "refunded", "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]any{
"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 // GetProducts returns all products from the database
func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) { func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
var products []models.Coffee var products []models.Coffee
@@ -30,14 +30,14 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"products": products, "products": products,
}) })
} }
// GetProduct returns a single product by ID // GetProduct returns a single product by ID
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
// Get ID from URL parameter // Get ID from URL parameter
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
@@ -53,7 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
return return
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"product": product, "product": product,
}) })
} }

View File

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

View File

@@ -1,25 +1,35 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"log" "log/slog"
"time" "time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/notify"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentintent" "github.com/stripe/stripe-go/v78/paymentintent"
"gorm.io/gorm" "gorm.io/gorm"
) )
// paymentIntentGetFn lets tests stub the Stripe Get call without spinning
// up an httptest backend. Production callers go straight through to the
// real paymentintent.Get.
var paymentIntentGetFn = paymentintent.Get
func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes // ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes // 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 // the case where the server crashed after charging but before the DB transaction
// completed. // completed.
func ReconcileOrders(stripeKey string) { func ReconcileOrders(ctx context.Context) {
stripe.Key = stripeKey db := database.GetDB().WithContext(ctx)
db := database.GetDB()
var orders []models.Order var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute) cutoff := time.Now().Add(-10 * time.Minute)
@@ -30,22 +40,28 @@ func ReconcileOrders(stripeKey string) {
return return
} }
log.Printf("[reconcile] checking %d pending orders", len(orders)) reconcileLog().Info("checking pending orders", "count", len(orders))
for _, order := range orders { for _, order := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
return
}
params := &stripe.PaymentIntentSearchParams{ params := &stripe.PaymentIntentSearchParams{
SearchParams: stripe.SearchParams{ SearchParams: stripe.SearchParams{
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID), Context: ctx,
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
}, },
} }
iter := paymentintent.Search(params) iter := paymentintent.Search(params)
for iter.Next() { for iter.Next() {
pi := iter.PaymentIntent() 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 { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{ if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -58,17 +74,193 @@ func ReconcileOrders(stripeKey string) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
return err return err
} }
return tx.Model(&cart).Updates(map[string]interface{}{ return tx.Model(&cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
}) })
if txErr != nil { 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 { 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)
} }
} }
} }
// ReconcileStale3DSOrders flips requires_action orders that have been
// awaiting the customer's 3DS challenge longer than threshold to failed,
// after re-checking the PaymentIntent with Stripe so a late
// payment_intent.succeeded webhook is respected. Deliberately separate
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
// stripe.Key is wired once at startup in api/main.go.
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
db := database.GetDB().WithContext(ctx)
var orders []models.Order
// Predicate keys off updated_at, not created_at: respondRequiresAction
// and RetryAuth both bump updated_at via GORM auto-stamping, so every
// customer-driven retry extends the grace window. Intentional - sweeping
// a row the customer just re-armed would race the new challenge.
cutoff := time.Now().Add(-threshold)
if err := db.Where("status = ? AND updated_at < ?",
models.OrderStatusRequiresAction, cutoff).Find(&orders).Error; err != nil {
reconcileLog().Error("stale 3ds scan failed", "error", err)
return
}
if len(orders) == 0 {
return
}
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
for i := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
return
}
order := &orders[i]
if order.StripePaymentID == "" {
// respondRequiresAction writes stripe_payment_id and status in
// one UPDATE so this should be unreachable; guard anyway so a
// future regression noisily logs instead of 4xx'ing Stripe.
reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID)
middleware.RecordAbandoned3DSSweep("missing_pi")
continue
}
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
if err != nil {
reconcileLog().Error("stripe get failed",
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
middleware.RecordAbandoned3DSSweep("stripe_error")
continue
}
switch actual.Status {
case stripe.PaymentIntentStatusSucceeded:
// Stripe says paid but our row still says requires_action, the
// payment_intent.succeeded webhook has not landed yet (or was
// dropped). Defer to handlePaymentIntentsucceeded; it owns the
// paid transition + cart cleanup atomically. The next sweep
// tick re-checks and surfaces the dropped webhook if it persists
reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook",
"order_id", order.ID, "pi", order.StripePaymentID)
middleware.RecordAbandoned3DSSweep("succeeded_skip")
case stripe.PaymentIntentStatusProcessing,
stripe.PaymentIntentStatusRequiresCapture:
reconcileLog().Info("payment intent still transitioning; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("processing_skip")
case stripe.PaymentIntentStatusRequiresAction,
stripe.PaymentIntentStatusRequiresPaymentMethod,
stripe.PaymentIntentStatusRequiresConfirmation,
stripe.PaymentIntentStatusCanceled:
flipStale3DSToFailed(db, order, string(actual.Status))
default:
reconcileLog().Warn("unrecognized stripe status; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("unrecognized_status")
}
}
}
// flipStale3DSToFailed transitions a single stale requires_action row
// to failed. The UPDATE is conditional on status = requires_action so a
// payment_intent.succeeded webhook that landed between the stripe get and
// this write (caveat #20 race) wins and we silently no-op
func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string) {
// Map form so the single-field UPDATE is explicit at the call site.
// GORM still auto-stamps updated_at; the row exits the sweep predicate
// naturally on the next tick because status no longer matches anyway.
res := db.Model(&models.Order{}).
Where("id = ? AND status = ?", order.ID, models.OrderStatusRequiresAction).
Updates(map[string]any{"status": models.OrderStatusFailed})
if res.Error != nil {
reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error)
middleware.RecordAbandoned3DSSweep("flip_failed")
return
}
if res.RowsAffected == 0 {
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
"order_id", order.ID)
middleware.RecordAbandoned3DSSweep("flip_noop")
return
}
audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID)
reconcileLog().Info("3ds abandoned order flipped to failed",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus)
middleware.RecordAbandoned3DSSweep("flipped")
}
// ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
// Read paths filter out expired rows so users never see them; this job drives
// the Stripe detach + audit + physical row delete out of band so request
// handlers never block on Stripe API calls during a card sweep.
func ReconcileExpiredCards(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
var cards []models.Card
if err := db.Where(
"storage_expires_at IS NOT NULL AND storage_expires_at <= ?",
time.Now(),
).Find(&cards).Error; err != nil {
reconcileLog().Error("expired card scan failed", "error", err)
return
}
if len(cards) == 0 {
return
}
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
for i := range cards {
if err := ctx.Err(); err != nil {
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
return
}
if err := expireStoredCard(db, &cards[i]); err != nil {
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
}
}
}
// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed.
// Lazy deletion on read (PayRedirect) is the primary path, this sweep only
// reclaims tokens nobody ever followed so the table stays bounded.
func ReconcilePayRedirects(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
res := db.Where("expires_at < ?", time.Now()).Delete(&models.PayRedirect{})
if res.Error != nil {
reconcileLog().Error("pay_redirects sweep failed", "error", res.Error)
return
}
if res.RowsAffected == 0 {
return
}
reconcileLog().Info("swept expired pay_redirects", "count", res.RowsAffected)
}
// ReconcileUnshipped finds paid orders older than 24h with no tracking
// number and posts a single Slack reminder. Pure read + notify.
func ReconcileUnshipped(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
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)
}

View File

@@ -0,0 +1,598 @@
package handlers
import (
"context"
"fmt"
"os"
"testing"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stripe/stripe-go/v78"
)
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
// and Stripe-Get branching: a row past the threshold whose PaymentIntent
// Stripe still reports as requires_payment_method (the customer never
// completed the challenge) transitions to failed and emits the new
// order_3ds_abandoned audit row.
func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_stale_abandoned",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
before := testutil.ToFloat64(flipped)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
var events []models.OrderEvent
if err := db.Where("order_id = ? AND type = ?",
order.ID, audit.EventOrder3DSAbandoned).Find(&events).Error; err != nil {
t.Fatalf("list events: %v", err)
}
if len(events) != 1 {
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
}
if got := testutil.ToFloat64(flipped) - before; got != 1 {
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
// excludes rows still within the grace window. A fresh requires_action row
// must not even reach the Stripe Get call.
func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_fresh_3ds",
time.Now().Add(-5*time.Minute))
called := false
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
called = true
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for in-window row")
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("status changed unexpectedly: %q", reloaded.Status)
}
}
// TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded covers the
// webhook-race guard from caveat #20. If Stripe-side already says succeeded,
// the sweep must defer to handlePaymentIntentSucceeded rather than overwrite
// the authoritative end state.
func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_succeeded_late_webhook",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusSucceeded,
}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
// UPDATE. If the row flipped to paid between the Stripe Get and the flip,
// the WHERE status = requires_action clause prevents clobber.
func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_race_paid",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
db.Model(&models.Order{}).Where("id = ?", order.ID).
Update("status", models.OrderStatusPaid)
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
before := testutil.ToFloat64(noop)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(noop) - before; got != 1 {
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
// Stripe-terminal-canceled maps to OrderStatusFailed (matches RetryAuth's
// existing precedent — see api/handlers/cart.go RetryAuth canceled branch).
func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_canceled_stale",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusCanceled,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("canceled PI must flip to failed; got %q", reloaded.Status)
}
}
// TestReconcileStale3DSOrders_DoesNotConsumeRetryCap regression-tests the
// caveat #17 decoupling: the new sweep emits EventOrder3DSAbandoned, never
// EventOrderRequiresAction, so a swept-then-stuck order does not silently
// shrink the retry ceiling for any future user-driven retry attempts.
func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_decoupled_cap",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var retryCount int64
if err := db.Model(&models.OrderEvent{}).
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
Count(&retryCount).Error; err != nil {
t.Fatalf("count retry events: %v", err)
}
if retryCount != 0 {
t.Fatalf("sweep must not emit EventOrderRequiresAction; got %d", retryCount)
}
}
// TestReconcileStale3DSOrders_SkipsTransitioningStatuses covers the
// processing and requires_capture branches: Stripe still owns the PI's
// next move, so the sweep must not flip even past the threshold.
func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
cases := []struct {
name string
status stripe.PaymentIntentStatus
}{
{"processing", stripe.PaymentIntentStatusProcessing},
{"requires_capture", stripe.PaymentIntentStatusRequiresCapture},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID,
"pi_transitioning_"+tc.name, time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 processing_skip increment, got %v", got)
}
})
}
}
// TestReconcileStale3DSOrders_SkipsUnknownStatus covers the switch's default
// arm: an unrecognized Stripe status logs and skips rather than guessing a
// transition. Defends against new PI statuses appearing in future SDKs.
func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_unknown_status",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatus("some_new_future_status"),
}, nil
}
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
before := testutil.ToFloat64(unknown)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(unknown) - before; got != 1 {
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
// failure logs and leaves the row alone. Transient network errors must not
// look like an abandoned 3DS.
func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_stripe_error",
time.Now().Add(-1*time.Hour))
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
return nil, fmt.Errorf("simulated stripe outage")
}
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
before := testutil.ToFloat64(stripeErr)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
// defensive empty-PI short-circuit: even past the threshold, a row missing
// stripe_payment_id must not reach paymentintent.Get (which would 4xx).
func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
order := seedRequiresActionOrder(t, user.ID, "pi_will_be_blanked",
time.Now().Add(-1*time.Hour))
if err := db.Model(&order).UpdateColumn("stripe_payment_id", "").Error; err != nil {
t.Fatalf("blank PI id: %v", err)
}
called := false
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(_ string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
called = true
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
}
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
before := testutil.ToFloat64(missing)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
if called {
t.Fatalf("stripe Get called for empty-PI row")
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(missing) - before; got != 1 {
t.Fatalf("want 1 missing_pi increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
// ctx.Err() check stops the sweep once the supervising context cancels, so a
// SIGTERM during a large stale-3DS batch does not block the binary past the
// 30s graceful-shutdown window in api/main.go
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
calls++
if calls == 1 {
cancel()
}
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(ctx, 30*time.Minute)
if calls != 1 {
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
}
var stillPending int64
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
t.Fatalf("count requires_action rows: %v", err)
}
if stillPending < 1 {
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
}
}
// seedRequiresActionOrder inserts a card + order pair in the requires_action
// state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of
// UpdatedAt on Create does not bump the row out of the sweep predicate.
func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt time.Time) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: "pm_test_" + piID,
Last4: "4242", Brand: "Visa",
ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: userID, CardID: card.ID,
StripePaymentID: piID,
Status: models.OrderStatusRequiresAction,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
if err := db.Model(&order).UpdateColumn("updated_at", updatedAt).Error; err != nil {
t.Fatalf("backdate updated_at: %v", err)
}
return order
}
// TestReconcilePayRedirects_DeleteExpiredKeepsLive verifies the predicate
// boundary: a row past ExpiresAt is hard-deleted while a row whose ExpiresAt
// is still in the future survives. Seeding both sides through GORM keeps the
// stored timestamp format identical to the one the sweep binds for time.Now(),
// so the comparison is exercised exactly as it runs in production.
func TestReconcilePayRedirects_DeleteExpiredKeepsLive(t *testing.T) {
testDB := setupTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
expired := models.PayRedirect{
Token: "tok_expired",
URL: "https://example.test/expired",
Purpose: models.RedirectPurposeCheckout3DS,
CreatedAt: now.Add(-20 * time.Minute),
ExpiresAt: now.Add(-10 * time.Minute),
}
live := models.PayRedirect{
Token: "tok_live",
URL: "https://example.test/live",
Purpose: models.RedirectPurposeAddCard,
CreatedAt: now,
ExpiresAt: now.Add(10 * time.Minute),
}
if err := db.Create(&expired).Error; err != nil {
t.Fatalf("seed expired: %v", err)
}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("seed live: %v", err)
}
ReconcilePayRedirects(context.Background())
var remaining []models.PayRedirect
if err := db.Find(&remaining).Error; err != nil {
t.Fatalf("list remaining: %v", err)
}
if len(remaining) != 1 {
t.Fatalf("want 1 row remaining, got %d", len(remaining))
}
if remaining[0].Token != "tok_live" {
t.Fatalf("wrong row survived: want tok_live, got %q", remaining[0].Token)
}
}
func TestReconcilePayRedirects_NoExpiredIsNoOp(t *testing.T) {
testDB := setupTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
live := models.PayRedirect{
Token: "tok_live_only",
URL: "https://example.test/live",
Purpose: models.RedirectPurposeCheckout3DS,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute),
}
if err := db.Create(&live).Error; err != nil {
t.Fatalf("seed live: %v", err)
}
ReconcilePayRedirects(context.Background())
var count int64
if err := db.Model(&models.PayRedirect{}).Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("want 1 row preserved, got %d", count)
}
}

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

View File

@@ -1,58 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"terminalShop/api/middleware"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"terminalShop/pkg/utils"
"github.com/go-chi/chi/v5"
)
type SSHKeyHandler struct{}
func NewSSHKeyHandler() *SSHKeyHandler {
return &SSHKeyHandler{}
}
// GetSSHKeys returns all ssh keys for the authenticated user.
func (h *SSHKeyHandler) GetSSHKeys(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
userID := middleware.UserIDFromContext(r.Context())
var keys []models.SSHKey
db.Where("user_id = ?", userID).Order("created_at ASC").Find(&keys)
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"ssh_keys": keys,
})
}
// DeleteSSHKeys removes one of the users ssh keys by ID
func (h *SSHKeyHandler) DeleteSSHKeys(w http.ResponseWriter, r *http.Request) {
db := database.GetDB()
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 ssh key id", nil)
return
}
var key models.SSHKey
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&key).Error; err != nil {
utils.RespondError(w, http.StatusNotFound, "SSH_KEY_NOT_FOUND", "ssh key not found", nil)
return
}
if err := db.Delete(&key).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete ssh key", nil)
return
}
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
"message": "ssh key deleted",
})
}

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

@@ -2,13 +2,17 @@ package handlers
import ( import (
"net/http" "net/http"
"time"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/utils" "terminalShop/pkg/utils"
"gorm.io/gorm"
) )
// ViewHandler handles bulk TUI bootstrap data
type ViewHandler struct{} type ViewHandler struct{}
func NewViewHandler() *ViewHandler { func NewViewHandler() *ViewHandler {
@@ -16,7 +20,7 @@ func NewViewHandler() *ViewHandler {
} }
func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) { func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db := database.GetDB() db := database.GetDB().WithContext(r.Context())
userID := middleware.UserIDFromContext(r.Context()) userID := middleware.UserIDFromContext(r.Context())
var user models.User var user models.User
@@ -35,25 +39,27 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
db.Where("user_id = ?", userID).Find(&addresses) db.Where("user_id = ?", userID).Find(&addresses)
var cards []models.Card var cards []models.Card
db.Where("user_id = ?", userID).Find(&cards) db.Where(
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
var sshKeys []models.SSHKey userID, time.Now(),
db.Where("user_id = ?", userID).Order("created_at ASC").Find(&sshKeys) ).Order("is_default DESC, created_at DESC").Find(&cards)
var orders []models.Order 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 var cartItems []models.CartItem
if cart.ID != 0 { if cart.ID != 0 {
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems) db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
} }
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ utils.RespondSuccess(w, http.StatusOK, map[string]any{
"user": user.ToPublic(), "user": user.ToPublic(),
"products": products, "products": products,
"cart": cartItems, "cart": cartItems,
"addresses": addresses, "addresses": addresses,
"cards": cards, "cards": cards,
"ssh_keys": sshKeys,
"orders": orders, "orders": orders,
}) })
} }

70
api/handlers/view_test.go Normal file
View File

@@ -0,0 +1,70 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// TestGetViewInit_FiltersExpiredCardsAndOrders drives the WHERE + ORDER BY
// clauses in GetViewInit. a column-name typo in either clause (e.g.
// id_default instead of is_default) surfaces here as a sqlite error
func TestGetViewInit_FiltersExpiredCardsAndOrders(t *testing.T) {
testDB, user := setupCardsExpireTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
now := time.Now()
past := now.Add(-1 * time.Hour)
future := now.Add((24 * time.Hour))
expired := models.Card{
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
}
freshDefault := models.Card{
UserID: user.ID, StripePaymentID: "pm_fresh_d", Last4: "2222",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
IsDefault: true,
}
freshOther := models.Card{
UserID: user.ID, StripePaymentID: "pm_fresh_o", Last4: "3333",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
}
for _, card := range []*models.Card{&expired, &freshDefault, &freshOther} {
if err := db.Create(card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
}
handler := NewViewHandler()
req := authRequest("GET", "/api/v1/view/init", nil, user.ID)
w := httptest.NewRecorder()
handler.GetViewInit(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetViewInit status %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Cards []models.Card `json:"cards"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Data.Cards) != 2 {
t.Fatalf("want 2 visible cards, got %d", len(resp.Data.Cards))
}
if resp.Data.Cards[0].ID != freshDefault.ID {
t.Fatalf("default card should sort first, got id=%d (want %d)", resp.Data.Cards[0].ID, freshDefault.ID)
}
}

View File

@@ -1,11 +1,15 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log/slog"
"net/http" "net/http"
"os"
"strings"
"time"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/paymentmethod" "github.com/stripe/stripe-go/v78/paymentmethod"
@@ -16,18 +20,22 @@ import (
"terminalShop/pkg/audit" "terminalShop/pkg/audit"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/notify"
"terminalShop/pkg/utils" "terminalShop/pkg/utils"
) )
func webhookLog() *slog.Logger { return slog.With("component", "webhook") }
// WebhookHandler handles inbound Stripe webhook events. // WebhookHandler handles inbound Stripe webhook events.
type WebhookHandler struct { type WebhookHandler struct {
webhookSecret string webhookSecret string
stripeKey string shippoSecret string
} }
// NewWebhookHandler creates a new webhook handler. // NewWebhookHandler creates a new webhook handler. Stripe credentials are
func NewWebhookHandler(webhookSecret string, stripeKey string) *WebhookHandler { // wired once at startup via stripe.Key in api/main.go.
return &WebhookHandler{webhookSecret: webhookSecret, stripeKey: stripeKey} func NewWebhookHandler(webhookSecret, shippoSecret string) *WebhookHandler {
return &WebhookHandler{webhookSecret: webhookSecret, shippoSecret: shippoSecret}
} }
// HandleStripe validates the Stripe signature and routes the event. // HandleStripe validates the Stripe signature and routes the event.
@@ -45,14 +53,23 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Verify the Stripe signature. Reject anything that doesn't match. // Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" { if h.webhookSecret == "" {
// No secret configured — log a loud warning but still process in dev. // Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
log.Println("WARNING: STRIPE_WEBHOOK_SECRET is not set; skipping signature verification") // in production, so the server should never boot into this state; this is
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
// Slack handlers, which already refuse to run without their secret.
if os.Getenv("ENVIRONMENT") == "production" {
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
return
}
// Development only; warn loudly but still process so local testing works.
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
} else { } else {
sig := r.Header.Get("Stripe-Signature") sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{ if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true, IgnoreAPIVersionMismatch: true,
}); err != nil { }); err != nil {
log.Printf("[webhook] signature verification failed: %v", err) webhookLog().Warn("signature verification failed", "error", err)
utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil) utils.RespondError(w, http.StatusUnauthorized, "INVALID_SIGNATURE", "webhook signature verification failed", nil)
return return
} }
@@ -67,35 +84,35 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
switch event.Type { switch event.Type {
case "checkout.session.completed": case "checkout.session.completed":
h.handleCheckoutSessionCompleted(event) h.handleCheckoutSessionCompleted(r.Context(), event)
// payment_intent.succeeded fires when a charge completes asynchronously // payment_intent.succeeded fires when a charge completes asynchronously
// (e.g. 3DS, bank redirects). For confirm=true intents this is normally // (e.g. 3DS, bank redirects). For confirm=true intents this is normally
// already handled by ConvertCart, but the webhook is the authoritative source. // already handled by ConvertCart, but the webhook is the authoritative source.
case "payment_intent.succeeded": case "payment_intent.succeeded":
h.handlePaymentIntentSucceeded(event) h.handlePaymentIntentSucceeded(r.Context(), event)
// payment_intent.payment_failed fires when a payment fails after initial // payment_intent.payment_failed fires when a payment fails after initial
// confirmation (e.g. bank decline on a deferred 3DS flow). // confirmation (e.g. bank decline on a deferred 3DS flow).
case "payment_intent.payment_failed": case "payment_intent.payment_failed":
h.handlePaymentIntentFailed(event) h.handlePaymentIntentFailed(r.Context(), event)
// Sync local card state when Stripe's side changes. // Sync local card state when Stripe's side changes.
case "payment_method.attached", case "payment_method.attached",
"payment_method.detached", "payment_method.detached",
"payment_method.updated": "payment_method.updated":
// Handled by the reconcile job and direct API calls; log only. // 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. // Always return 200 so Stripe doesn't retry.
w.WriteHeader(http.StatusOK) 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 var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { 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 return
} }
@@ -105,10 +122,10 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
return return
} }
db := database.GetDB() db := database.GetDB().WithContext(ctx)
var order models.Order var order models.Order
if err := db.Where("id = ?", orderIDStr).First(&order).Error; err != nil { if err := db.Preload("Items").Where("id = ?", orderIDStr).First(&order).Error; err != nil {
log.Printf("[webhook] order %s not found for pi %s", orderIDStr, pi.ID) webhookLog().Warn("order not found for payment intent", "order_id", orderIDStr, "pi", pi.ID)
return return
} }
@@ -117,8 +134,22 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
return return
} }
// Defense in depth: even behind a valid signature, refuse to mark an order
// paid unless the PaymentIntent's amount and currency match what we charged.
// Under ConvertCart these always match (Amount == order.Total at creation), so
// a mismatch means a malformed or unexpected event we should not trust.
if pi.Amount != int64(order.Total) {
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
return
}
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
return
}
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]interface{}{ if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,
"stripe_payment_id": pi.ID, "stripe_payment_id": pi.ID,
}).Error; err != nil { }).Error; err != nil {
@@ -132,24 +163,53 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(event stripe.Event) {
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { 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 fmt.Errorf("failed to delete cart item: %w", err)
} }
return tx.Model(&cart).Updates(map[string]interface{}{ return tx.Model(&cart).Updates(map[string]any{
"address_id": nil, "address_id": nil,
"card_id": nil, "card_id": nil,
}).Error }).Error
}) })
if txErr != nil { 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 return
} }
// A retained (non-ephemeral) card gets its storage TTL refreshed on use.
// Ephemeral orders are about to lose their card entirely, so skip the refresh.
if order.CardID != 0 && !order.Ephemeral {
if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil {
webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err)
}
}
if order.Ephemeral && pi.PaymentMethod != nil && pi.PaymentMethod.ID != "" {
if _, derr := paymentMethodDetachFn(pi.PaymentMethod.ID, nil); derr != nil {
webhookLog().Warn("failed to detach ephemeral payment method",
"order_id", order.ID, "pi", pi.ID, "error", derr)
}
}
// Deferred (3DS) twin of the inline forget-card cleanup in ConvertCart: a
// privacy-mode order that selected a saved/QR card carries CardID != 0. Now
// that it is paid, hard-delete the local row (Unscoped: Card soft-deletes by
// default). Token-ephemeral orders have CardID == 0 and skip this.
if order.Ephemeral && order.CardID != 0 {
if err := db.Unscoped().Delete(&models.Card{}, order.CardID).Error; err != nil {
webhookLog().Warn("failed to delete forgotten card", "order_id", order.ID, "card_id", order.CardID, "error", err)
} else if err := db.Model(&order).Update("card_id", 0).Error; err != nil {
webhookLog().Warn("failed to null forgotten card reference", "order_id", order.ID, "error", err)
}
}
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) 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 var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { 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 return
} }
@@ -158,23 +218,33 @@ func (h *WebhookHandler) handlePaymentIntentFailed(event stripe.Event) {
return return
} }
db := database.GetDB() // authentication_required is recoverable — the cart handler re-confirms the
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending). // 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 IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction).
Update("status", models.OrderStatusFailed).Error; err != nil { Update("status", models.OrderStatusFailed).Error; err != nil {
log.Printf("[webhook] failed to mark order %s as failed: %v", orderIDStr, err) webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
} }
errMsg := "unknown" errMsg := "unknown"
if pi.LastPaymentError != nil { if pi.LastPaymentError != nil {
errMsg = pi.LastPaymentError.Msg 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 var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { 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 return
} }
@@ -183,42 +253,72 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
} }
if sess.SetupIntent == nil || sess.Customer == nil { 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 return
} }
stripe.Key = h.stripeKey
si, err := setupintent.Get(sess.SetupIntent.ID, nil) si, err := setupintent.Get(sess.SetupIntent.ID, nil)
if err != 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 return
} }
if si.PaymentMethod == nil { 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 return
} }
pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil) pm, err := paymentmethod.Get(si.PaymentMethod.ID, nil)
if err != 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 return
} }
db := database.GetDB() db := database.GetDB().WithContext(ctx)
var user models.User var user models.User
if err := db.Where("stripe_customer_id = ?", sess.Customer.ID).First(&user).Error; err != nil { 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 return
} }
// Idempotency: skip if this payment method is already saved. // Idempotency: skip if this exact PaymentMethod is already in use
var existing models.Card var existing models.Card
if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil { if err := db.Where("user_id = ? AND stripe_payment_id = ?", user.ID, pm.ID).First(&existing).Error; err == nil {
return 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. storage_expires_at is
// reset because the user just re-added the card; this is a card-add
// path, not a payment-success refresh, so last_used_at stays as-is.
now := time.Now()
if uerr := db.Model(&dup).Updates(map[string]any{
"updated_at": now,
"storage_expires_at": models.CardStorageExpiresAt(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{ card := models.Card{
UserID: user.ID, UserID: user.ID,
StripePaymentID: pm.ID, StripePaymentID: pm.ID,
@@ -227,12 +327,178 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(event stripe.Event) {
ExpMonth: int(pm.Card.ExpMonth), ExpMonth: int(pm.Card.ExpMonth),
ExpYear: int(pm.Card.ExpYear), ExpYear: int(pm.Card.ExpYear),
} }
card.InitializeStorageTTL(time.Now())
if err := db.Create(&card).Error; err != nil { 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 return
} }
audit.CardAdded(user.ID, card.ID, card.Brand, card.Last4) 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)
} }

View File

@@ -0,0 +1,259 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
// a PaymentIntent referencing the given order via metadata. errCode is set on
// last_payment_error to drive the authentication_required short-circuit
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
t.Helper()
pi := map[string]any{
"id": piID,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
}
if errCode != "" {
pi["last_payment_error"] = map[string]any{"code": errCode}
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
return stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
}
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: userID, CardID: card.ID,
StripePaymentID: piID,
Status: status,
Subtotal: 500, Total: 500,
ShippingName: "Test", ShippingStreet: "1 Main St",
ShippingCity: "PDX", ShippingState: "OR",
ShippingZip: "97201", ShippingCountry: "US",
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("seed order: %v", err)
}
return order
}
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
// widened predicate (IN (pending, requires_action)) catches a row in the new
// requires_action state. Without this case, a card_declined webhook arriving
// after respondRequiresAction wrote requires_action would leave the row
// stuck forever.
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_failed")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
// still fires when the row is in requires_action and the error code is
// authentication_required. Without the short-circuit, the recovery flow
// would race the webhook and surface a momentarily-failed state to the TUI.
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_authreq")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
// pending -> failed transition against a regression where the widened
// predicate accidentally dropped pending from the source set.
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
// the predicate: a paid row must NEVER be flipped to failed by a stale
// payment_failed webhook. Without this guard, a future maintainer could
// widen the IN clause to include paid (e.g. for some refund-retry flow)
// and silently break idempotency.
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
h := NewWebhookHandler("", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
// short-circuit so the test file exercises every early-return branch in
// handlePaymentIntentFailed touched by the predicate work.
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
testDB, _ := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
pi := map[string]any{"id": "pi_test_no_meta"}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentFailed(context.Background(), evt) // must not panic
}
func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid")
pi := map[string]any{
"id": order.StripePaymentID,
"amount": order.Total,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.succeeded",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentSucceeded(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("status: want %q, got %q", models.OrderStatusPaid, reloaded.Status)
}
if reloaded.StripePaymentID != order.StripePaymentID {
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
}
}
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
// PI claims 1 cent; seed order.Total is 500.
pi := map[string]any{
"id": order.StripePaymentID,
"amount": 1,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.succeeded",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentSucceeded(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reloaded order: %v", err)
}
if reloaded.Status == models.OrderStatusPaid {
t.Fatalf("order marked paid despite amount mismatch")
}
}

View File

@@ -11,15 +11,43 @@ import (
"time" "time"
"terminalShop/api/handlers" "terminalShop/api/handlers"
"terminalShop/api/middleware"
"terminalShop/api/routes" "terminalShop/api/routes"
"terminalShop/pkg/audit"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"terminalShop/pkg/config" "terminalShop/pkg/config"
"terminalShop/pkg/database" "terminalShop/pkg/database"
"terminalShop/pkg/observability"
"terminalShop/pkg/stripeclient"
"github.com/stripe/stripe-go/v78"
) )
const version = "v0.1.0" const version = "v0.1.0"
// everyTick runs fn on its own goroutine every d until ctx is cancelled.
// Shared by the reconciler sweeps so the ticker + shutdown plumbing lives in
// one place. ReconcileUnshipped intentionally does not use it, it needs a
// staggered warm-up run before its first tick.
func everyTick(ctx context.Context, d time.Duration, fn func(context.Context)) {
go func() {
ticker := time.NewTicker(d)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fn(ctx)
case <-ctx.Done():
return
}
}
}()
}
func main() { func main() {
observability.InitLogger()
middleware.SetBuildInfo(version)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Load configuration // Load configuration
@@ -33,23 +61,44 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
defer database.Close()
defer func() { _ = database.Close() }()
// Run migrations // Run migrations
if err := database.Migrate(db); err != nil { if err := database.Migrate(db); err != nil {
log.Fatalf("Failed to run migrations: %v", err) log.Fatalf("Failed to run migrations: %v", err)
} }
audit.SetDB(db)
// wire the stripe SDK's package-level key once at startup so concurrent
// reconcilers and the request path don't race on stripe.Key writes.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripeSecretKey
// Seed database with initial data // Seed database with initial data
if err := database.Seed(db); err != nil { if err := database.Seed(db); err != nil {
log.Fatalf("Failed to seed database: %v", err) 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) // Init JWT manager (same secret + duration as SSH server)
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
// Setup routes // 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.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents)
// Create HTTP server // Create HTTP server
server := &http.Server{ server := &http.Server{
@@ -76,21 +125,43 @@ func main() {
} }
}() }()
// Start reconciliation job. runs every 5 minutes to catch any orders // Catch orders charged but not recorded due to a crash. Every 5 minutes
// that were charged but not recorded due to a crash. everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders)
go func() { go func() {
ticker := time.NewTicker(5 * time.Minute) select {
case <-time.After(30 * time.Second):
case <-ctx.Done():
return
}
handlers.ReconcileUnshipped(ctx)
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
handlers.ReconcileOrders(cfg.StripeSecretKey) handlers.ReconcileUnshipped(ctx)
case <-ctx.Done(): case <-ctx.Done():
return return
} }
} }
}() }()
// Sweep saved cards whose retention deadline elapsed. Read paths filter
// expired rows from query results, so this sweeper only drives the Stripe
// detach + audit + physical row delete asynchronously. Hourly.
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards)
// Flip abandoned 3DS orders to failed. Every 15 minutes.
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
everyTick(ctx, 15*time.Minute, func(ctx context.Context) {
handlers.ReconcileStale3DSOrders(ctx, threshold)
})
// Sweep expired pay_redirects rows so the table stays bounded. Lazy delete
// on read is the primary path; this only reclaims tokens nobody follows.
everyTick(ctx, 10*time.Minute, handlers.ReconcilePayRedirects)
log.Println("API server started. Press Ctrl+C to shutdown.") log.Println("API server started. Press Ctrl+C to shutdown.")
// Block until signal received // Block until signal received
@@ -99,10 +170,10 @@ func main() {
log.Println("Server is shutting down...") log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout // Graceful shutdown with 30 second timeout
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer shutdownCancel()
if err := server.Shutdown(ctx); err != nil { if err := server.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err) log.Fatalf("Server forced to shutdown: %v", err)
} }

92
api/main_test.go Normal file
View File

@@ -0,0 +1,92 @@
package main
import (
"context"
"testing"
"time"
)
// TestEveryTick_CallsFnOnTick verifies the helper invokes fn repeatedly on its
// interval. A non-blocking send keeps the spawned goroutine from ever blocking
// on a full channel, and the 1s timeout per tick is generous vs the 1ms
// interval so the test is not timing-flaky.
func TestEveryTick_CallsFnOnTick(t *testing.T) {
ctx := t.Context()
fired := make(chan struct{}, 1)
everyTick(ctx, time.Millisecond, func(context.Context) {
select {
case fired <- struct{}{}:
default:
}
})
for i := range 3 {
select {
case <-fired:
case <-time.After(time.Second):
t.Fatalf("tick %d did not fire within 1s", i+1)
}
}
}
// TestEveryTick_StopsOnContextCancel verifies cancelling ctx stops the loop.
// After the first tick we cancel inside the inter-tick gap, so everyTick's
// select sees only ctx.Done ready (ticker.C is cold) and returns
// deterministically — there is no random pick between two ready cases. We then
// confirm no further tick arrives within a full interval, which a live loop
// could not satisfy. This is the shutdown guarantee the reconcilers depend on.
func TestEveryTick_StopsOnContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
const interval = 50 * time.Millisecond
ticks := make(chan struct{}) // unbuffered: fn blocks until the test receives
everyTick(ctx, interval, func(context.Context) {
ticks <- struct{}{}
})
// First tick proves the loop is running and leaves the goroutine back in
// select with ticker.C cold for the next ~interval.
select {
case <-ticks:
case <-time.After(time.Second):
t.Fatal("everyTick never called fn")
}
cancel()
// Cancel landed in the cold gap, so the loop must have taken ctx.Done and
// returned. A live loop would tick again at ~interval; a stopped one never
// will.
select {
case <-ticks:
t.Fatal("everyTick ticked again after cancel")
case <-time.After(interval + 50*time.Millisecond):
// good: silent past a full interval — the loop returned
}
}
func TestEveryTick_PassesContextToFn(t *testing.T) {
type ctxKey string
const marker ctxKey = "marker"
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), marker, "present"))
defer cancel()
got := make(chan string, 1)
everyTick(ctx, time.Millisecond, func(c context.Context) {
v, _ := c.Value(marker).(string)
select {
case got <- v:
default:
}
})
select {
case v := <-got:
if v != "present" {
t.Fatalf("fn received wrong context: value = %q, want %q", v, "present")
}
case <-time.After(time.Second):
t.Fatalf("fn never ran")
}
}

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)
})
}

4
api/middleware/doc.go Normal file
View File

@@ -0,0 +1,4 @@
// Package middleware holds the stuff that runs on every request before a
// handler ever sees it: auth, CORS, logging, rate limiting, metrics, and a
// recovery net for panics.
package middleware

View File

@@ -4,16 +4,19 @@ import (
"log" "log"
"net/http" "net/http"
"time" "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 { type responseWriter struct {
http.ResponseWriter http.ResponseWriter
statusCode int statusCode int
bytesWritten int64
} }
func newResponseWriter(w http.ResponseWriter) *responseWriter { func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{w, http.StatusOK} return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
} }
func (rw *responseWriter) WriteHeader(code int) { func (rw *responseWriter) WriteHeader(code int) {
@@ -21,6 +24,12 @@ func (rw *responseWriter) WriteHeader(code int) {
rw.ResponseWriter.WriteHeader(code) 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 // Logger logs HTTP requests
func Logger(next http.Handler) http.Handler { func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -29,14 +38,21 @@ func Logger(next http.Handler) http.Handler {
wrapped := newResponseWriter(w) wrapped := newResponseWriter(w)
next.ServeHTTP(wrapped, r) next.ServeHTTP(wrapped, r)
sc := trace.SpanContextFromContext(r.Context())
tid := ""
if sc.HasTraceID() {
tid = sc.TraceID().String()
}
duration := time.Since(start) duration := time.Since(start)
log.Printf( log.Printf(
"%s %s %d %s", "%s %s %d %s trace_id=%s",
r.Method, r.Method,
r.RequestURI, r.URL.Path,
wrapped.statusCode, wrapped.statusCode,
duration, duration,
tid,
) )
}) })
} }

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

@@ -0,0 +1,215 @@
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"},
)
abandoned3DSSweepTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "abandoned_3ds_sweep_total",
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
},
[]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)
}
// RecordAbandoned3DSSweep records the outcome of one row processed by
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
// Help string.
func RecordAbandoned3DSSweep(outcome string) {
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
}
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
// per-label values via prometheus testutil. Not used in production code.
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
return abandoned3DSSweepTotal
}
// 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

@@ -16,6 +16,7 @@ func RateLimitByIP(limit int, window time.Duration) func(http.Handler) http.Hand
limit, window, limit, window,
httprate.WithKeyFuncs(httprate.KeyByIP), httprate.WithKeyFuncs(httprate.KeyByIP),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
RecordRateLimitRejection("ip")
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil) utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
}), }),
) )
@@ -34,6 +35,7 @@ func RateLimitByUser(limit int, window time.Duration) func(http.Handler) http.Ha
return "user:" + strconv.FormatUint(uint64(userID), 10), nil return "user:" + strconv.FormatUint(uint64(userID), 10), nil
}), }),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
RecordRateLimitRejection("user")
utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil) utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests", nil)
}), }),
) )

View File

@@ -1,7 +1,7 @@
package middleware package middleware
import ( import (
"log" "log/slog"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
@@ -13,7 +13,12 @@ func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
if err := recover(); err != nil { 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( utils.RespondError(
w, w,

3
api/routes/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package routes is the switchboard: it maps every URL to the handler that
// knows what to do with it.
package routes

View File

@@ -9,26 +9,35 @@ import (
"terminalShop/api/handlers" "terminalShop/api/handlers"
"terminalShop/api/middleware" "terminalShop/api/middleware"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"github.com/riandyrn/otelchi"
) )
// SetupRoutes configures all API routes // SetupRoutes configures all API routes
func SetupRoutes( func SetupRoutes(
version string, version string,
stripeSecretKey string,
stripeWebhookSecret string, stripeWebhookSecret string,
jwtManager *auth.JWTManager, jwtManager *auth.JWTManager,
authFingerprintKey string, authFingerprintKey string,
shippoAPIKey string, shippoAPIKey string,
bringAPIUID string, bringAPIUID string,
bringAPIKey string, bringAPIKey string,
bringCustomerNumber string,
shippoWebhookSecret string,
appURL string, appURL string,
slackSigningSecret string,
adminAPIKey string,
apiPort string,
maxOrderCents int,
) *chi.Mux { ) *chi.Mux {
r := chi.NewRouter() r := chi.NewRouter()
// Global middleware // Global middleware
r.Use(otelchi.Middleware("terminalshop-api", otelchi.WithChiRoutes(r)))
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.CORS()) r.Use(middleware.CORS())
r.Use(middleware.Recovery) r.Use(middleware.Recovery)
r.Use(middleware.Metrics)
r.Use(middleware.Auth(jwtManager)) r.Use(middleware.Auth(jwtManager))
// Broad IP-level rate limit: 200 req/min per IP across all endpoints. // Broad IP-level rate limit: 200 req/min per IP across all endpoints.
r.Use(middleware.RateLimitByIP(200, time.Minute)) r.Use(middleware.RateLimitByIP(200, time.Minute))
@@ -37,30 +46,47 @@ func SetupRoutes(
healthHandler := handlers.NewHealthHandler(version) healthHandler := handlers.NewHealthHandler(version)
productHandler := handlers.NewProductHandler() productHandler := handlers.NewProductHandler()
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
cartHandler := handlers.NewCartHandler(stripeSecretKey) cartHandler := handlers.NewCartHandler(appURL, maxOrderCents)
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) cardHandler := handlers.NewCardHandler(appURL)
orderHandler := handlers.NewOrderHandler(stripeSecretKey) orderHandler := handlers.NewOrderHandler(bringAPIUID, bringAPIKey, bringCustomerNumber)
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
viewHandler := handlers.NewViewHandler() viewHandler := handlers.NewViewHandler()
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey) webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, shippoWebhookSecret)
sshKeyHandler := handlers.NewSSHKeyHandler() slackHandler := handlers.NewSlackHandler(slackSigningSecret, adminAPIKey, apiPort, shippoWebhookSecret)
adminUserHandler := handlers.NewAdminUserHandler()
accountHandler := handlers.NewAccountHandler(maxOrderCents)
// Short payment redirect and success page — no auth required. // Short payment redirect and success page — no auth required.
r.Get("/pay/{token}", handlers.PayRedirect) r.Get("/pay/{token}", handlers.PayRedirect)
r.Get("/t/{id}", handlers.TrackingRedirect)
r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) { r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") 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) { r.Route("/api/v1", func(r chi.Router) {
// Health // Health — accept HEAD for cheap probes from monitoring tools.
r.Get("/health", healthHandler.GetHealth) r.Get("/health", healthHandler.GetHealth)
r.Head("/health", healthHandler.GetHealth)
r.Get("/ping", healthHandler.Ping) r.Get("/ping", healthHandler.Ping)
r.Head("/ping", healthHandler.Ping)
// Stripe webhooks — unauthenticated, verified via HMAC signature. // Stripe webhooks — unauthenticated, verified via HMAC signature.
// Apply a generous IP rate limit here (Stripe retries from many IPs). // Apply a generous IP rate limit here (Stripe retries from many IPs).
r.With(middleware.RateLimitByIP(60, time.Minute)). r.With(middleware.RateLimitByIP(60, time.Minute)).
Post("/webhooks/stripe", webhookHandler.HandleStripe) 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. // Auth — stricter IP rate limit to protect against brute-force.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
@@ -80,6 +106,12 @@ func SetupRoutes(
r.Get("/view/init", viewHandler.GetViewInit) r.Get("/view/init", viewHandler.GetViewInit)
// Account self-service settings, JWT-authed, edits the callers
// own row (user id from context, never a URL param). Distinct from
// the admin /users/{id}/order-cap setter in the RequireAdmin group.
r.Put("/account/spend-limit", accountHandler.SetSpendLimit)
r.Put("/account/privacy-mode", accountHandler.SetPrivacyMode)
// Cart // Cart
r.Route("/cart", func(r chi.Router) { r.Route("/cart", func(r chi.Router) {
r.Get("/", cartHandler.GetCart) r.Get("/", cartHandler.GetCart)
@@ -106,6 +138,9 @@ func SetupRoutes(
// Orders // Orders
r.Get("/orders", orderHandler.GetOrders) r.Get("/orders", orderHandler.GetOrders)
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
r.With(middleware.RateLimitByUser(10, time.Minute)).Post("/orders/{id}/retry-auth", cartHandler.RetryAuth)
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
r.Post("/orders/{id}/refund", orderHandler.RefundOrder) r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
// Addresses // Addresses
@@ -115,14 +150,15 @@ func SetupRoutes(
r.Delete("/{id}", addressHandler.DeleteAddress) r.Delete("/{id}", addressHandler.DeleteAddress)
r.Put("/{id}/default", addressHandler.SetDefaultAddress) r.Put("/{id}/default", addressHandler.SetDefaultAddress)
}) })
})
// SSH Keys // Admin endpoints, gated by ADMIN_API_KEY only, no JWT required
r.Route("/ssh-keys", func(r chi.Router) { r.Group(func(r chi.Router) {
// GET doesnt need id because it gets every ssh key r.Use(middleware.RequireAdmin)
r.Get("/", sshKeyHandler.GetSSHKeys) r.Patch("/orders/{id}/tracking", orderHandler.UpdateTracking)
// Delete needs an id to delete only one key r.Post("/orders/{id}/label", orderHandler.PurchaseLabel)
r.Delete("/{id}", sshKeyHandler.DeleteSSHKeys) // Set or clear a user's per-user order spend-cap override.
}) r.Patch("/users/{id}/order-cap", adminUserHandler.SetUserOrderCap)
}) })
}) })

View File

@@ -0,0 +1,3 @@
// Command reconcile-cards hunts down saved cards that Stripe no longer knows
// about and detaches them. Run it with -dry-run first if you're nervous.
package main

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]

129
go.mod
View File

@@ -1,73 +1,130 @@
module terminalShop module terminalShop
go 1.25.7 go 1.25.9
require ( require (
github.com/charmbracelet/bubbletea v1.3.10 charm.land/bubbles/v2 v2.1.0
github.com/charmbracelet/huh v0.8.0 charm.land/bubbletea/v2 v2.0.6
github.com/charmbracelet/lipgloss v1.1.0 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/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/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2 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/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.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/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
gorm.io/plugin/opentelemetry v0.1.16
rsc.io/qr v0.2.0
) )
require ( 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/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // 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/bitfield/gotestdox v0.2.2 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/log v0.4.1 // indirect github.com/charmbracelet/keygen v0.5.4 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect github.com/charmbracelet/x/conpty v0.1.1 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // 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/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/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/creack/pty v1.1.24 // indirect github.com/creack/pty v1.1.24 // indirect
github.com/dnephin/pflag v1.0.7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-chi/httprate v0.15.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // 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/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gotesttools/gotestfmt/v2 v2.5.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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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.23 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // 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/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/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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
golang.org/x/sync v0.19.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
golang.org/x/sys v0.41.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
golang.org/x/text v0.34.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
rsc.io/qr v0.2.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.34.0 // 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/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.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
gotest.tools/gotestsum v1.13.0 // indirect
)
tool (
github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt
gotest.tools/gotestsum
) )

323
go.sum
View File

@@ -1,82 +1,128 @@
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 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 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 h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 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/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
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/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= 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/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/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 h1:4EG8pCHK5fa8dIxv97VHC8hdkJAz6QNm1WB9BuD/WhY=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7/go.mod h1:O2BTD/aMVQDmrvqroIO3fB6zXUuU07ZpVt21QTmZjRg=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
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-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 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 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 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 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 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.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
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 h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU= github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= 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/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 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= 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-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
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/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4=
github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4=
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -91,70 +137,205 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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-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-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= 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/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 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 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/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 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/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0vSF5yt4=
github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 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/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
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/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-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.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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-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/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= 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 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= 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 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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=
gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= 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' } });
}

42
main.go
View File

@@ -8,24 +8,20 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "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/ssh"
"github.com/charmbracelet/wish" "github.com/stripe/stripe-go/v78"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"terminalShop/pkg/auth" "terminalShop/pkg/auth"
"terminalShop/pkg/config" "terminalShop/pkg/config"
"terminalShop/pkg/tui" "terminalShop/pkg/tui"
) )
const (
port = "23456"
)
func listenHost() string { func listenHost() string {
if h := os.Getenv("SSH_HOST"); h != "" { if h := os.Getenv("SSH_HOST"); h != "" {
return h return h
@@ -33,23 +29,12 @@ func listenHost() string {
return "localhost" return "localhost"
} }
func listenPort() string {
if p := os.Getenv("SSH_PORT"); p != "" {
return p
}
return port
}
var ( var (
apiURL string apiURL string
authFingerprintKey string authFingerprintKey string
stripePublicKey string
) )
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
renderer := bubbletea.MakeRenderer(s)
// Extract SSH public key from session
pubKey := s.PublicKey() pubKey := s.PublicKey()
var fingerprint, pubKeyStr string var fingerprint, pubKeyStr string
@@ -58,9 +43,8 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pubKeyStr = auth.FormatSSHPublicKey(pubKey) pubKeyStr = auth.FormatSSHPublicKey(pubKey)
} }
// Create TUI model with user context (no registration screen needed!) m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey)
m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) return m, nil
return m, []tea.ProgramOption{tea.WithAltScreen()}
} }
func main() { func main() {
@@ -73,12 +57,20 @@ func main() {
// Set API URL and auth key from config for TUI connections // Set API URL and auth key from config for TUI connections
apiURL = cfg.APIURL apiURL = cfg.APIURL
authFingerprintKey = cfg.AuthFingerprintKey authFingerprintKey = cfg.AuthFingerprintKey
stripePublicKey = cfg.StripePublicKey
// Wire the publishable key into stripe-go's package-level global once.
// TUI uses the legacy Tokens API (stripetoken.New) which accepts the
// publishable key; per-session writes would race across concurrent SSH
// sessions even though they'd all write the same value.
// NOTE: assumes single-process — if you ever embed both the SSH and API
// binaries together, the last writer wins and one of them silently breaks.
stripe.Key = cfg.StripePublicKey
log.Printf("TUI will connect to API at: %s", apiURL) log.Printf("TUI will connect to API at: %s", apiURL)
// Create SSH server // Create SSH server
host := listenHost() host := listenHost()
p := listenPort() p := cfg.SSHPort
s, err := wish.NewServer( s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%s", host, p)), wish.WithAddress(fmt.Sprintf("%s:%s", host, p)),
wish.WithHostKeyPath(".ssh/term_info_ed25519"), wish.WithHostKeyPath(".ssh/term_info_ed25519"),

View File

@@ -56,7 +56,6 @@ type TokenResponse struct {
} }
func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) { func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) {
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL) url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
reqBody := TokenRequest{ reqBody := TokenRequest{
@@ -80,7 +79,7 @@ func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (
if err != nil { if err != nil {
return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err) 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 { if resp.StatusCode != http.StatusOK {
return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode) return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode)
@@ -105,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
// APIResponse represents the standard API response format // APIResponse represents the standard API response format
type APIResponse struct { type APIResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data map[string]interface{} `json:"data"` Data map[string]any `json:"data"`
Error *APIError `json:"error,omitempty"` Error *APIError `json:"error,omitempty"`
} }
// APIError represents an error from the API // APIError represents an error from the API
type APIError struct { type APIError struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"` Details map[string]any `json:"details,omitempty"`
} }
// ProductsResponse represents the products API response // ProductsResponse represents the products API response
@@ -137,7 +136,7 @@ func (c *Client) GetProducts() ([]models.Coffee, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch products: %w", err) return nil, fmt.Errorf("failed to fetch products: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode) return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -167,7 +166,7 @@ func (c *Client) Health() error {
if err != nil { if err != nil {
return fmt.Errorf("health check failed: %w", err) return fmt.Errorf("health check failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode) return fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -217,7 +216,7 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to register user: %w", err) return nil, fmt.Errorf("failed to register user: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var registerResp RegisterResponse var registerResp RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&registerResp); err != nil {
@@ -281,14 +280,31 @@ type CardResponse struct {
} }
// ConvertCartResponse represents the cart conversion (checkout) API response. // 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 { type ConvertCartResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { 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"` } `json:"data"`
Error *APIError `json:"error,omitempty"` 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. // GetCart fetches the user's current cart.
func (c *Client) GetCart() (*CartData, error) { func (c *Client) GetCart() (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
@@ -302,7 +318,7 @@ func (c *Client) GetCart() (*CartData, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch cart: %w", err) return nil, fmt.Errorf("failed to fetch cart: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
@@ -323,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) { func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"coffee_id": coffeeID, "coffee_id": coffeeID,
"quantity": quantity, "quantity": quantity,
} }
@@ -342,7 +358,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to set cart item: %w", err) return nil, fmt.Errorf("failed to set cart item: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var cartResp CartResponse var cartResp CartResponse
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
@@ -363,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
func (c *Client) SetCartAddress(addressID uint) error { func (c *Client) SetCartAddress(addressID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"address_id": addressID, "address_id": addressID,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)
@@ -381,7 +397,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set cart address: %w", err) return fmt.Errorf("failed to set cart address: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result APIResponse var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -402,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
func (c *Client) SetCartCard(cardID uint) error { func (c *Client) SetCartCard(cardID uint) error {
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
body := map[string]interface{}{ body := map[string]any{
"card_id": cardID, "card_id": cardID,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)
@@ -420,7 +436,7 @@ func (c *Client) SetCartCard(cardID uint) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to set cart card: %w", err) return fmt.Errorf("failed to set cart card: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result APIResponse var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -437,6 +453,77 @@ func (c *Client) SetCartCard(cardID uint) error {
return nil return nil
} }
func (c *Client) SetSpendLimit(cents *int) error {
url := fmt.Sprintf("%s/api/v1/account/spend-limit", c.BaseURL)
body := map[string]any{
"self_limit_cents": cents,
}
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to set spend limit: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if !result.Success {
if result.Error != nil {
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
}
return fmt.Errorf("failed to set spend limit")
}
return nil
}
// SetPrivacyMode sets the users account-level privacy default ("keep as little
// as possible"). A soft default the checkout can override per order.
func (c *Client) SetPrivacyMode(on bool) error {
url := fmt.Sprintf("%s/api/v1/account/privacy-mode", c.BaseURL)
jsonData, err := json.Marshal(map[string]any{"privacy_mode": on})
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("PUT", url, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to set privacy mode: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if !result.Success {
if result.Error != nil {
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
}
}
return nil
}
// ClearCart removes all items from the user's cart. // ClearCart removes all items from the user's cart.
func (c *Client) ClearCart() error { func (c *Client) ClearCart() error {
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
@@ -450,7 +537,7 @@ func (c *Client) ClearCart() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to clear cart: %w", err) return fmt.Errorf("failed to clear cart: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result APIResponse var result APIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -468,10 +555,41 @@ func (c *Client) ClearCart() error {
} }
// ConvertCart converts the cart into an order, charging the saved card. // 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) {
return c.convertCart(nil)
}
// ConvertCartEphemeral converts the cart in privacy mode: it charges a one-time
// card (cardToken) that is never saved and is detached after the charge settles.
// Used when the customer leaves "save this card" unchecked at checkout.
func (c *Client) ConvertCartEphemeral(cardToken string) (*CheckoutOutcome, error) {
jsonData, err := json.Marshal(map[string]any{
"ephemeral": true,
"card_token": cardToken,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
return c.convertCart(jsonData)
}
// convertCart POSTs to /cart/convert with an optional JSON body (nil = saved-card
// flow) parses the shared response into a CheckoutOutcome.
func (c *Client) convertCart(jsonData []byte) (*CheckoutOutcome, error) {
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL) url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
req, err := http.NewRequest("POST", url, nil) var req *http.Request
var err error
if jsonData != nil {
req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err == nil {
req.Header.Set("Content-Type", "application/json")
}
} else {
req, err = http.NewRequest("POST", url, nil)
}
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
@@ -480,7 +598,7 @@ func (c *Client) ConvertCart() (*models.Order, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert cart: %w", err) return nil, fmt.Errorf("failed to convert cart: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var convertResp ConvertCartResponse var convertResp ConvertCartResponse
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
@@ -494,7 +612,107 @@ func (c *Client) ConvertCart() (*models.Order, error) {
return nil, fmt.Errorf("failed to convert cart") 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
}
// RetryAuthOutcome is the result of POST /orders/{id}/retry-auth.
// status=="requires_action" -> RedirectURL is a fresh QR target; resume polling.
// Status=="succeeded" -> stripe approved frictionless; keep polling for the
// webhook to flip status=paid (RedirectURL will be empty)
type RetryAuthOutcome struct {
OrderID uint
Status string
RedirectURL string
}
type retryAuthResponse struct {
Success bool `json:"success"`
Data struct {
OrderID uint `json:"order_id"`
Status string `json:"status"`
RedirectURL string `json:"redirect_url,omitempty"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
// RetryAuth asks the server to re-Confirm the existing PaymentIntent for the
// order, producing a new 3DS challenge URL. Used by the TUI's authFailed
// screen so the customer can recover without re-entering the checkout flow.
func (c *Client) RetryAuth(orderID uint) (*RetryAuthOutcome, error) {
url := fmt.Sprintf("%s/api/v1/orders/%d/retry-auth", c.BaseURL, orderID)
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create retry request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to send retry request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
var rResp retryAuthResponse
if err := json.NewDecoder(resp.Body).Decode(&rResp); err != nil {
return nil, fmt.Errorf("failed to decode retry response: %w", err)
}
if !rResp.Success {
if rResp.Error != nil {
return nil, fmt.Errorf("%s: %s", rResp.Error.Code, rResp.Error.Message)
}
return nil, fmt.Errorf("failed to retry authentication")
}
return &RetryAuthOutcome{
OrderID: rResp.Data.OrderID,
Status: rResp.Data.Status,
RedirectURL: rResp.Data.RedirectURL,
}, nil
} }
// GetCards fetches all saved cards for the user. // GetCards fetches all saved cards for the user.
@@ -510,7 +728,7 @@ func (c *Client) GetCards() ([]models.Card, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch cards: %w", err) return nil, fmt.Errorf("failed to fetch cards: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var cardsResp CardsResponse var cardsResp CardsResponse
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
@@ -553,7 +771,7 @@ func (c *Client) SaveCard(params SaveCardRequest) (*models.Card, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to save card: %w", err) return nil, fmt.Errorf("failed to save card: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var cardResp CardResponse var cardResp CardResponse
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
@@ -590,7 +808,7 @@ func (c *Client) CollectCard() (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("failed to collect card: %w", err) return "", fmt.Errorf("failed to collect card: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var collectResp CollectCardResponse var collectResp CollectCardResponse
if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&collectResp); err != nil {
@@ -619,7 +837,7 @@ func (c *Client) DeleteCard(id uint) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to delete card: %w", err) return fmt.Errorf("failed to delete card: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete card failed with status %d", resp.StatusCode) return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
@@ -628,64 +846,6 @@ func (c *Client) DeleteCard(id uint) error {
return nil return nil
} }
type SSHKeyResponse struct {
Success bool `json:"success"`
Data struct {
SSHKeys []models.SSHKey `json:"ssh_keys"`
} `json:"data"`
Error *APIError `json:"error,omitempty"`
}
func (c *Client) GetSSHKeys() ([]models.SSHKey, error) {
url := fmt.Sprintf("%s/api/v1/ssh-keys", c.BaseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch ssh keys: %w", err)
}
defer resp.Body.Close()
var keysResp SSHKeyResponse
if err := json.NewDecoder(resp.Body).Decode(&keysResp); err != nil {
return nil, fmt.Errorf("failed to decode ssh keys response: %w", err)
}
if !keysResp.Success {
if keysResp.Error != nil {
return nil, fmt.Errorf("%s: %s", keysResp.Error.Code, keysResp.Error.Message)
}
return nil, fmt.Errorf("failed to fetch ssh keys")
}
return keysResp.Data.SSHKeys, nil
}
func (c *Client) DeleteSSHKey(id uint) error {
url := fmt.Sprintf("%s/api/v1/ssh-keys/%d", c.BaseURL, id)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
resp, err := c.doRequest(req)
if err != nil {
return fmt.Errorf("failed to delete ssh key: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete ssh key failed with status %d", resp.StatusCode)
}
return nil
}
type OrderResponse struct { type OrderResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
@@ -694,6 +854,20 @@ type OrderResponse struct {
Error *APIError `json:"error,omitempty"` 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) { func (c *Client) GetOrders() ([]models.Order, error) {
url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL) url := fmt.Sprintf("%s/api/v1/orders", c.BaseURL)
@@ -706,7 +880,7 @@ func (c *Client) GetOrders() ([]models.Order, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch orders: %w", err) return nil, fmt.Errorf("failed to fetch orders: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var ordersResp OrderResponse var ordersResp OrderResponse
if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&ordersResp); err != nil {
@@ -722,6 +896,42 @@ func (c *Client) GetOrders() ([]models.Order, error) {
return ordersResp.Data.Orders, nil 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 // The Addresses API response
type AddressResponse struct { type AddressResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -744,7 +954,7 @@ func (c *Client) GetAddresses() ([]models.Address, error) {
return nil, fmt.Errorf("failed to fetch addresses: %w", err) return nil, fmt.Errorf("failed to fetch addresses: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var addrResp AddressResponse var addrResp AddressResponse
if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&addrResp); err != nil {
@@ -790,7 +1000,7 @@ func (c *Client) CreateAddress(addrReq CreateAddressRequest) (*models.Address, e
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create address: %w", err) return nil, fmt.Errorf("failed to create address: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var result struct { var result struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -827,7 +1037,7 @@ func (c *Client) DeleteAddress(id uint) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to delete address: %w", err) return fmt.Errorf("failed to delete address: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete address failed with status %d", resp.StatusCode) return fmt.Errorf("delete address failed with status %d", resp.StatusCode)
@@ -842,7 +1052,6 @@ type ViewInitData struct {
Cart []CartItemData `json:"cart"` Cart []CartItemData `json:"cart"`
Addresses []models.Address `json:"addresses"` Addresses []models.Address `json:"addresses"`
Cards []models.Card `json:"cards"` Cards []models.Card `json:"cards"`
SSHKeys []models.SSHKey `json:"ssh_keys"`
Orders []models.Order `json:"orders"` Orders []models.Order `json:"orders"`
} }
@@ -863,7 +1072,7 @@ func (c *Client) GetViewInit() (ViewInitData, error) {
if err != nil { if err != nil {
return ViewInitData{}, fmt.Errorf("view init request failed: %w", err) return ViewInitData{}, fmt.Errorf("view init request failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode) return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode)
} }

3
pkg/api/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package api is the client side of the house: it talks to our HTTP backend
// so other Go code doesn't have to hand-roll requests.
package api

View File

@@ -1,124 +1,446 @@
// 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. // Every card addition, order creation, payment, and refund is recorded here.
// Pipe this output to your log aggregator (Datadog, Loki, CloudWatch, etc.). // Pipe this output to your log aggregator (Datadog, Loki, CloudWatch, etc.).
package audit package audit
import ( import (
"encoding/json" "encoding/json"
"log" "fmt"
"time" "log/slog"
"terminalShop/pkg/models"
"gorm.io/gorm"
) )
// EventOrderRequiresAction is the audit event-type string emitted whenever a
// PaymentIntent is pushed into requires_action. Exposed so cart-flow handler
// can count retry attempts without hardcoding the string literal.
const EventOrderRequiresAction = "order_requires_action"
// EventOrder3DSAbandoned is the audit event-type string emitted by the
// abandoned-3DS sweep when it flips a stale requires_action row to failed.
// Distinct from EventOrderRequiresAction (caveat #17) so the retry-cap count
// stays decoupled from sweep activity
const EventOrder3DSAbandoned = "order_3ds_abandoned"
// EventCartRejected is the audit event-type string emitted when the per-order
// spend cap blocks a checkout BEFORE an order row is created. The event lives
// in slog only, persist() no-ops on zero order id, which is the correct shape
// here (cap rejection is upstream of order state). See caveat #13.
const EventCartRejected = "cart_rejected"
type eventType string type eventType string
const ( const (
eventCardAdded eventType = "card_added" eventCardAdded eventType = "card_added"
eventCardDeleted eventType = "card_deleted" eventCardDeleted eventType = "card_deleted"
eventOrderCreated eventType = "order_created" eventCardExpired eventType = "card_expired"
eventOrderPaid eventType = "order_paid" eventOrderCreated eventType = "order_created"
eventOrderFailed eventType = "order_failed" eventOrderPaid eventType = "order_paid"
eventOrderRefunded eventType = "order_refunded" eventOrderShipped eventType = "order_shipped"
eventPaymentCritical eventType = "payment_critical" eventOrderFailed eventType = "order_failed"
eventOrderRequiresAction eventType = EventOrderRequiresAction
eventOrder3DSAbandoned eventType = EventOrder3DSAbandoned
eventCartRejected eventType = EventCartRejected
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"
eventUserOrderCapSet eventType = "user_order_cap_set"
eventUserSelfLimitSet eventType = "user_self_limit_set"
eventUserPrivacyModeSet eventType = "user_privacy_mode_set"
) )
type event struct { type event struct {
Time time.Time `json:"time"` Event eventType
Event eventType `json:"event"` UserID uint
UserID uint `json:"user_id,omitempty"` OrderID uint
OrderID uint `json:"order_id,omitempty"` CardID uint
CardID uint `json:"card_id,omitempty"` Amount int
Amount int `json:"amount_cents,omitempty"` StripeID string
StripeID string `json:"stripe_id,omitempty"` CardLast4 string
CardLast4 string `json:"card_last4,omitempty"` CardBrand string
CardBrand string `json:"card_brand,omitempty"` Carrier string
Error string `json:"error,omitempty"` Tracking string
Extra map[string]string `json:"extra,omitempty"` Status string
Error string
Actor string
TotalCents int
CapCents int
} }
func emit(e event) { func (e event) attrs() []any {
e.Time = time.Now().UTC() a := []any{"event", string(e.Event)}
b, err := json.Marshal(e) if e.UserID != 0 {
if err != nil { a = append(a, "user_id", e.UserID)
log.Printf("[audit] failed to marshal event: %v", err)
return
} }
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)
}
if e.TotalCents != 0 {
a = append(a, "total_cents", e.TotalCents)
}
if e.CapCents != 0 {
a = append(a, "cap_cents", e.CapCents)
}
return a
} }
// CardAdded records that a user saved a new payment method. // CardAdded records that a user saved a new payment method.
func CardAdded(userID, cardID uint, brand, last4 string) { func CardAdded(userID, cardID uint, brand, last4 string) {
emit(event{ e := event{
Event: eventCardAdded, Event: eventCardAdded,
UserID: userID, UserID: userID,
CardID: cardID, CardID: cardID,
CardBrand: brand, CardBrand: brand,
CardLast4: last4, CardLast4: last4,
}) }
slog.Info("audit", e.attrs()...)
persist(0, eventCardAdded, e)
} }
// CardDeleted records that a user removed a saved payment method. // CardDeleted records that a user removed a saved payment method.
func CardDeleted(userID, cardID uint) { func CardDeleted(userID, cardID uint) {
emit(event{ e := event{
Event: eventCardDeleted, Event: eventCardDeleted,
UserID: userID, UserID: userID,
CardID: cardID, CardID: cardID,
}) }
slog.Info("audit", e.attrs()...)
persist(0, eventCardDeleted, e)
}
// CardExpired records that a saved payment method expired from inactivity
func CardExpired(userID, cardID uint) {
e := event{
Event: eventCardExpired,
UserID: userID,
CardID: cardID,
}
slog.Info("audit", e.attrs()...)
persist(0, eventCardExpired, e)
} }
// OrderCreated records that an order record was created (before payment). // OrderCreated records that an order record was created (before payment).
func OrderCreated(userID, orderID uint, amountCents int) { func OrderCreated(userID, orderID uint, amountCents int) {
emit(event{ e := event{
Event: eventOrderCreated, Event: eventOrderCreated,
UserID: userID, UserID: userID,
OrderID: orderID, OrderID: orderID,
Amount: amountCents, Amount: amountCents,
}) }
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderCreated, e)
} }
// OrderPaid records a successful charge. // OrderPaid records a successful charge.
func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID string) { func OrderPaid(userID, orderID uint, amountCents int, stripePaymentIntentID string) {
emit(event{ e := event{
Event: eventOrderPaid, Event: eventOrderPaid,
UserID: userID, UserID: userID,
OrderID: orderID, OrderID: orderID,
Amount: amountCents, Amount: amountCents,
StripeID: stripePaymentIntentID, 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) { func OrderFailed(userID, orderID uint, amountCents int, reason string) {
emit(event{ e := event{
Event: eventOrderFailed, Event: eventOrderFailed,
UserID: userID, UserID: userID,
OrderID: orderID, OrderID: orderID,
Amount: amountCents, Amount: amountCents,
Error: reason, Error: reason,
}) }
slog.Warn("audit", e.attrs()...)
persist(orderID, eventOrderFailed, e)
} }
// OrderRefunded records a refund. // OrderRefunded records a refund.
func OrderRefunded(userID, orderID uint, amountCents int, stripeRefundID string) { func OrderRefunded(userID, orderID uint, amountCents int, stripeRefundID string) {
emit(event{ e := event{
Event: eventOrderRefunded, Event: eventOrderRefunded,
UserID: userID, UserID: userID,
OrderID: orderID, OrderID: orderID,
Amount: amountCents, Amount: amountCents,
StripeID: stripeRefundID, StripeID: stripeRefundID,
}) }
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrderRefunded, e)
} }
// PaymentCritical records the dangerous state where Stripe charged the card // PaymentCritical records the dangerous state where Stripe charged the card
// but the local database transaction failed. This MUST be investigated and // 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) { func PaymentCritical(orderID uint, stripePaymentIntentID string, err error) {
emit(event{ e := event{
Event: eventPaymentCritical, Event: eventPaymentCritical,
OrderID: orderID, OrderID: orderID,
StripeID: stripePaymentIntentID, StripeID: stripePaymentIntentID,
Error: err.Error(), Error: err.Error(),
}) }
// Second line so it's impossible to miss in plain-text log tails. slog.Error("audit", e.attrs()...)
log.Printf("[AUDIT][CRITICAL] order %d was charged (pi=%s) but DB commit failed: %v — REQUIRES MANUAL RECONCILIATION", persist(orderID, eventPaymentCritical, e)
orderID, stripePaymentIntentID, err) }
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
}
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)
}
// Order3DSAbandoned records the abandoned-3DS sweep flipping a stale
// requires_action row to failed because Stripe still reports the underlying
// PaymentIntent as outstanding past the sweep threshold.
func Order3DSAbandoned(userID, orderID uint, paymentIntentID string) {
e := event{
Event: eventOrder3DSAbandoned,
UserID: userID,
OrderID: orderID,
StripeID: paymentIntentID,
}
slog.Info("audit", e.attrs()...)
persist(orderID, eventOrder3DSAbandoned, e)
}
// CartRejected records the per-order cap blocking a checkout BEFORE an
// order row is created. Slog-only by design: cap rejection runs upstream
// of order creation so there is no order id to scope a persistent
// OrderEvent row. persist() is intentionally not called - future refactors
// of the persist() zero-id guard must not silently start writing rows here.
// Counterpart to the validation_over_limit Prometheus counter.
func CartRejected(userID uint, totalCents, capCents int) {
e := event{
Event: eventCartRejected,
UserID: userID,
TotalCents: totalCents,
CapCents: capCents,
}
slog.Info("audit", e.attrs()...)
}
// UserOrderCapSet records an operator changing a user's per-user order-spend
// cap override (User.MaxOrderCents), an admin-gated action. A nil capCents means
// the override was cleared and the user reverts to the global MAX_ORDER_CENTS;
// Status distinguishes "set" from "cleared" so a cap set to 0 (off-switch, which
// attrs() omits because cap_cents == 0) is still legible in the log.
func UserOrderCapSet(actor string, userID uint, capCents *int) {
e := event{
Event: eventUserOrderCapSet,
Actor: actor,
UserID: userID,
}
if capCents == nil {
e.Status = "cleared"
} else {
e.Status = "set"
e.CapCents = *capCents
}
slog.Info("audit", e.attrs()...)
}
// UserSelfLimitSet records a customer changing their own self-service spend
// limit (User.SelfLimitCents) via the JWT-authed account endpoint. Kept
// deliberately distinct from UserOrderCapSet (the operator's admin-gated
// override) so the customer action and the operator action are never conflated
// in the audit trail. A nil capCents means the self-limit was cleared (the user
// reverts to the admin/global cap). Unlike the admin cap, an explicit 0 here is
// a real "block everything" ceiling, not an off-switch, so Status distinguishes
// "set" from "cleared" and a cap of 0 (which attrs() omits) stays legible.
func UserSelfLimitSet(userID uint, capCents *int) {
e := event{
Event: eventUserSelfLimitSet,
Actor: "self",
UserID: userID,
}
if capCents == nil {
e.Status = "cleared"
} else {
e.Status = "set"
e.CapCents = *capCents
}
slog.Info("audit", e.attrs()...)
}
// UserPrivacyModeSet records a customer toggling their account-level privacy
// ("keep as little as possible") default via the JWT-authed account endpoint.
// Actor "self" mirrors UserSelfLimitSet; Status carries the new state so the
// trail reads cleanly without a dedicated column.
func UserPrivacyModeSet(userID uint, enabled bool) {
e := event{
Event: eventUserPrivacyModeSet,
Actor: "self",
UserID: userID,
}
if enabled {
e.Status = "enabled"
} else {
e.Status = "disabled"
}
slog.Info("audit", e.attrs()...)
} }

41
pkg/audit/audit_test.go Normal file
View File

@@ -0,0 +1,41 @@
package audit
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestCartRejected(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
CartRejected(42, 25000, 20000)
raw := strings.TrimSpace(buf.String())
if raw == "" {
t.Fatalf("CartRejected: no slog output captured")
}
var rec map[string]any
if err := json.Unmarshal([]byte(raw), &rec); err != nil {
t.Fatalf("decode slog record: %v\nraw: %s", err, raw)
}
if got, want := rec["event"], "cart_rejected"; got != want {
t.Errorf("event: want %q, got %v", want, got)
}
if n, _ := rec["user_id"].(float64); int(n) != 42 {
t.Errorf("user_id want 42, got %v", rec["user_id"])
}
if n, _ := rec["total_cents"].(float64); int(n) != 25000 {
t.Errorf("total_cents: want 25000, got %v", rec["total_cents"])
}
if n, _ := rec["cap_cents"].(float64); int(n) != 20000 {
t.Errorf("cap_cents: want 20000, got %v", rec["cap_cents"])
}
}

3
pkg/auth/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package auth sorts out who you are: logging in with an SSH key and minting
// the JWT you carry around afterwards.
package auth

View File

@@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string,
// ValidateToken validates a JWT token and returns the claims // ValidateToken validates a JWT token and returns the claims
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) { func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
// Verify signing method // Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])

View File

@@ -17,7 +17,7 @@ import (
func setupAuthTestDB(t *testing.T) string { func setupAuthTestDB(t *testing.T) string {
t.Helper() t.Helper()
testDB := "test_ssh_auth.db" testDB := "test_ssh_auth.db"
os.Remove(testDB) _ = os.Remove(testDB)
database.ResetForTesting() database.ResetForTesting()
db, err := database.Connect(testDB) db, err := database.Connect(testDB)
@@ -47,7 +47,7 @@ func newTestSSHKey(t *testing.T) gossh.PublicKey {
func TestAuthenticateSSHKeyNewUser(t *testing.T) { func TestAuthenticateSSHKeyNewUser(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -72,7 +72,7 @@ func TestAuthenticateSSHKeyNewUser(t *testing.T) {
func TestAuthenticateSSHKeyExistingUser(t *testing.T) { func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -96,7 +96,7 @@ func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
func TestRegisterWithSSHKey(t *testing.T) { func TestRegisterWithSSHKey(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -121,7 +121,7 @@ func TestRegisterWithSSHKey(t *testing.T) {
func TestRegisterWithSSHKeyDuplicate(t *testing.T) { func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -145,7 +145,7 @@ func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
func TestIsSSHKeyRegistered(t *testing.T) { func TestIsSSHKeyRegistered(t *testing.T) {
testDB := setupAuthTestDB(t) testDB := setupAuthTestDB(t)
defer os.Remove(testDB) defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting() defer database.ResetForTesting()
db := database.GetDB() db := database.GetDB()
@@ -164,7 +164,7 @@ func TestIsSSHKeyRegistered(t *testing.T) {
} }
// After authentication (which auto-creates) // After authentication (which auto-creates)
svc.AuthenticateSSHKey(key) _, _, _ = svc.AuthenticateSSHKey(key)
registered, err = svc.IsSSHKeyRegistered(key) registered, err = svc.IsSSHKeyRegistered(key)
if err != nil { 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 package bring
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -9,6 +10,8 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
const baseURL = "https://api.bring.com/address" const baseURL = "https://api.bring.com/address"
@@ -26,7 +29,8 @@ func NewClient(apiUID, apiKey string) *Client {
apiUID: apiUID, apiUID: apiUID,
apiKey: apiKey, apiKey: apiKey,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport),
}, },
} }
} }
@@ -79,7 +83,7 @@ func parseStreet(street string) (name string, number string) {
return strings.TrimSpace(street), "" 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) streetName, streetNumber := parseStreet(addr.Street1)
params := url.Values{} params := url.Values{}
@@ -93,7 +97,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
reqURL := fmt.Sprintf("%s/api/no/validation?%s", baseURL, params.Encode()) 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 { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
@@ -106,7 +110,7 @@ func (c *Client) ValidateAddress(addr Address) (*Address, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("bring request failed: %w", err) return nil, fmt.Errorf("bring request failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {

3
pkg/bring/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package bring talks to Bring, the Norwegian postal service, for booking
// shipments and printing labels.
package bring

View File

@@ -3,8 +3,10 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/url" "net/url"
"os" "os"
"strconv"
"strings" "strings"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@@ -12,20 +14,26 @@ import (
// Config holds all application configuration // Config holds all application configuration
type Config struct { type Config struct {
APIPort string APIPort string
APIURL string APIURL string
AppURL string AppURL string
SSHPort string SSHPort string
Environment string Environment string
DatabaseURL string DatabaseURL string
JWTSecret string JWTSecret string
StripeSecretKey string StripeSecretKey string
StripePublicKey string StripePublicKey string
StripeWebhookSecret string StripeWebhookSecret string
ShippoAPIKey string ShippoAPIKey string
AuthFingerprintKey string AuthFingerprintKey string
BringAPIUID string BringAPIUID string
BringAPIKey string BringAPIKey string
BringCustomerNumber string
ShippoWebhookSecret string
SlackSigningSecret string
AdminAPIKey string
MaxOrderCents int
Abandoned3DSThresholdMinutes int
} }
// Load reads configuration from environment variables and validates required secrets. // Load reads configuration from environment variables and validates required secrets.
@@ -44,20 +52,26 @@ func Load() (*Config, error) {
apiPort := getEnvOrDefault("API_PORT", "8080") apiPort := getEnvOrDefault("API_PORT", "8080")
apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort)) apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort))
cfg := &Config{ cfg := &Config{
APIPort: apiPort, APIPort: apiPort,
APIURL: apiURL, APIURL: apiURL,
AppURL: getEnvOrDefault("APP_URL", apiURL), AppURL: getEnvOrDefault("APP_URL", apiURL),
SSHPort: getEnvOrDefault("SSH_PORT", "23457"), SSHPort: getEnvOrDefault("SSH_PORT", "23457"),
Environment: getEnvOrDefault("ENVIRONMENT", "development"), Environment: getEnvOrDefault("ENVIRONMENT", "development"),
DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"), DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"),
JWTSecret: os.Getenv("JWT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"), StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"), ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"),
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"), AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
BringAPIUID: os.Getenv("BRING_API_UID"), BringAPIUID: os.Getenv("BRING_API_UID"),
BringAPIKey: os.Getenv("BRING_API_KEY"), 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"),
MaxOrderCents: loadMaxOrderCents(),
Abandoned3DSThresholdMinutes: loadAbandoned3DSThresholdMinutes(),
} }
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
@@ -78,6 +92,7 @@ func (c *Config) validate() error {
{"JWT_SECRET", c.JWTSecret}, {"JWT_SECRET", c.JWTSecret},
{"STRIPE_SECRET_KEY", c.StripeSecretKey}, {"STRIPE_SECRET_KEY", c.StripeSecretKey},
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey}, {"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
} }
var missing []string var missing []string
@@ -122,3 +137,48 @@ func getEnvOrDefault(key, defaultValue string) string {
} }
return defaultValue return defaultValue
} }
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — roughly tracks the
// PSD2 TRA exemption ceiling of €500 (≈ $540, FX-dependent), above which Stripe
// always requests SCA, so the cap is only meaningful below it. We charge in USD
// and the regulatory ceiling is in EUR, so this is an approximate anchor, not an
// exact equivalence — $500 trips slightly below the true ceiling, which is the
// safe direction. Explicit 0 disables the cap. Garbage or negative values fall
// back to the default with a warning so an operator typo cannot silently disable
// a fraud control.
func loadMaxOrderCents() int {
const defaultCap = 50000
raw := os.Getenv("MAX_ORDER_CENTS")
if raw == "" {
return defaultCap
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
fmt.Printf("WARNING: MAX_ORDER_CENTS=%q invalid, falling back to default %d\n", raw, defaultCap)
return defaultCap
}
return n
}
// loadAbandoned3DSThresholdMinutes read ABANDONED_3DS_THRESHOLD_MINUTES.
// The abandoned-3DS sweep only flips requires_action rows whose updated_at iconfigs
// older than this many minutes. Default 30 min. Values below 1 fall back to
// the default so a typo cannot collapse the grace window the TUI polls inside.
func loadAbandoned3DSThresholdMinutes() int {
const defaultMinutes = 30
raw := os.Getenv("ABANDONED_3DS_THRESHOLD_MINUTES")
if raw == "" {
return defaultMinutes
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
slog.Warn("invalid ABANDONED_3DS_THRESHOLD_MINUTES, falling back",
"raw", raw, "default", defaultMinutes)
return defaultMinutes
}
if n < 10 {
slog.Warn("ABANDONED_3DS_THRESHOLD_MINUTES below 10 may race in-flight 3ds challenges",
"value", n)
}
return n
}

78
pkg/config/config_test.go Normal file
View File

@@ -0,0 +1,78 @@
package config
import (
"io"
"log/slog"
"os"
"testing"
)
// TestMain silences the package's slog warnings so the negative/garbage
// env-var cases plus the sub-10 valid-minimum case don't spam test output.
// The actual fall-back behavior is still asserted via the case tables.
func TestMain(m *testing.M) {
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
code := m.Run()
slog.SetDefault(prev)
os.Exit(code)
}
func TestLoadMaxOrderCents(t *testing.T) {
cases := []struct {
name string
env string // "" means unset
want int
}{
{"empty falls back to default", "", 50000},
{"explicit zero disables", "0", 0},
{"valid override", "30000", 30000},
{"negative falls back to default", "-1", 50000},
{"garbage falls back to default", "two hundred", 50000},
{"empty after trim falls back to default", "", 50000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.env == "" {
t.Setenv("MAX_ORDER_CENTS", "")
} else {
t.Setenv("MAX_ORDER_CENTS", tc.env)
}
got := loadMaxOrderCents()
if got != tc.want {
t.Errorf("loadMaxOrderCents() = %d, want %d", got, tc.want)
}
})
}
}
// TestLoadAbandoned3DSThresholdMinutes covers the env-parsing surface for
// the sweep threshold. Zero and negative fall back to the default (unlike
// MaxOrderCents where zero is a deliberate off-switch) because a zero
// threshold would sweep every requires_action row on the first tick and
// race the customer's in-progress challenge.
func TestLoadAbandoned3DSThresholdMinutes(t *testing.T) {
cases := []struct {
name string
env string // "" means unset
want int
}{
{"empty falls back to default", "", 30},
{"valid override", "45", 45},
{"zero falls back to default", "0", 30},
{"negative falls back to default", "-5", 30},
{"garbage falls back to default", "half an hour", 30},
{"valid minimum (warns)", "1", 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ABANDONED_3DS_THRESHOLD_MINUTES", tc.env)
got := loadAbandoned3DSThresholdMinutes()
if got != tc.want {
t.Errorf("loadAbandoned3DSThresholdMinutes() = %d, want %d", got, tc.want)
}
})
}
}

3
pkg/config/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package config reads everything out of the environment so secrets and
// settings stay out of the source code.
package config

View File

@@ -11,6 +11,8 @@ import (
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"gorm.io/plugin/opentelemetry/tracing"
) )
var ( var (
@@ -42,6 +44,14 @@ func Connect(dsn string) (*gorm.DB, error) {
return 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. // Configure connection pool for PostgreSQL.
if isPostgres { if isPostgres {
sqlDB, poolErr := db.DB() sqlDB, poolErr := db.DB()
@@ -91,7 +101,7 @@ func Close() error {
// WARNING: Only use this in tests! // WARNING: Only use this in tests!
func ResetForTesting() { func ResetForTesting() {
if db != nil { if db != nil {
Close() _ = Close()
db = nil db = nil
once = sync.Once{} once = sync.Once{}
} }

View File

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

3
pkg/database/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package database opens the SQLite connection, runs the migrations, and
// drops in some seed data so the shop isn't empty on a fresh boot.
package database

View File

@@ -3,9 +3,11 @@ package database
import ( import (
"fmt" "fmt"
"log" "log"
"time"
"terminalShop/pkg/models"
"gorm.io/gorm" "gorm.io/gorm"
"terminalShop/pkg/models"
) )
// Migrate runs database migrations // Migrate runs database migrations
@@ -15,19 +17,32 @@ func Migrate(db *gorm.DB) error {
// AutoMigrate will create tables, missing columns, and indexes // AutoMigrate will create tables, missing columns, and indexes
// It will NOT delete unused columns or change column types // It will NOT delete unused columns or change column types
if err := db.AutoMigrate( if err := db.AutoMigrate(
&models.Cart{}, // Server-side shipping cart &models.Cart{}, // Server-side shopping cart
&models.CartItem{}, // Cart line items &models.CartItem{}, // Cart line items
&models.Coffee{}, &models.Coffee{}, // Coffees
&models.User{}, // SSH key-based authentication &models.User{}, // SSH key-based authentication
&models.Card{}, // Saved payment methods &models.Card{}, // Saved payment methods
&models.Order{}, // Completed purchases &models.Order{}, // Completed purchases
&models.OrderItem{}, // Line items within orders &models.OrderItem{}, // Line items within orders
&models.Address{}, // Saved addresses &models.Address{}, // Saved addresses
&models.SSHKey{}, // One of the users ssh keys &models.OrderEvent{},
&models.PayRedirect{}, // short-token -> stripe url store (10 min TTL)
); err != nil { ); err != nil {
return fmt.Errorf("migration failed: %w", err) return fmt.Errorf("migration failed: %w", err)
} }
if err := backfillCardStorageExpiration(db); err != nil {
return err
}
log.Println("Database migrations completed successfully") log.Println("Database migrations completed successfully")
return nil return nil
} }
func backfillCardStorageExpiration(db *gorm.DB) error {
expiresAt := models.CardStorageExpiresAt(time.Now())
if err := db.Model(&models.Card{}).Where("storage_expires_at IS NULL").Update("storage_expires_at", expiresAt).Error; err != nil {
return fmt.Errorf("card storage ttl backfill failed: %w", err)
}
return nil
}

View File

@@ -0,0 +1,67 @@
package database
import (
"os"
"testing"
"time"
"terminalShop/pkg/models"
)
func TestBackfillCardStorageExpiration_FillsNullRowsOnly(t *testing.T) {
testDB := "test_backfill.db"
defer func() { _ = os.Remove(testDB) }()
defer ResetForTesting()
db, err := Connect(testDB)
if err != nil {
t.Fatalf("connect: %v", err)
}
if err := Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
user := models.User{
SSHKeyFingerprint: "SHA256:backfill",
SSHPublicKey: "ssh-ed25519 AAAA backfill",
Name: "Backfill",
Email: "backfill@example.com",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
legacy := models.Card{
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "0000",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
}
if err := db.Create(&legacy).Error; err != nil {
t.Fatalf("seed legacy: %v", err)
}
fixed := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
preset := models.Card{
UserID: user.ID, StripePaymentID: "pm_preset", Last4: "9999",
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &fixed,
}
if err := db.Create(&preset).Error; err != nil {
t.Fatalf("ssed preset: %v", err)
}
if err := backfillCardStorageExpiration(db); err != nil {
t.Fatalf("seed preset: %v", err)
}
var reloadedLegacy, reloadedPreset models.Card
if err := db.First(&reloadedLegacy, legacy.ID).Error; err != nil {
t.Fatalf("reload legacy: %v", err)
}
if err := db.First(&reloadedPreset, preset.ID).Error; err != nil {
t.Fatalf("reload preset: %v", err)
}
if reloadedLegacy.StorageExpiresAt == nil {
t.Fatalf("legacy card storage_expires_at still NULL after backfill")
}
if reloadedPreset.StorageExpiresAt == nil || !reloadedPreset.StorageExpiresAt.Equal(fixed) {
t.Fatalf("preset card storage_expires_at was overwritten: %v", reloadedPreset.StorageExpiresAt)
}
}

View File

@@ -4,8 +4,9 @@ import (
"fmt" "fmt"
"log" "log"
"gorm.io/gorm"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"gorm.io/gorm"
) )
// Seed populates the database with initial data // Seed populates the database with initial data
@@ -25,54 +26,60 @@ func Seed(db *gorm.DB) error {
{ {
Name: "Espresso", Name: "Espresso",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 5,
Ounces: 2, Ounces: 2,
BeanType: "Arabica", BeanType: "Arabica",
Price: 350, // $3.50 Price: 1800, // $18.00
Color: "#8B4513", Color: "#8B4513",
Description: "Bold and intense coffee with a rich crema", Description: "Bold and intense coffee with a rich crema",
}, },
{ {
Name: "Americano", Name: "Americano",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 8, Ounces: 8,
BeanType: "Arabica", BeanType: "Arabica",
Price: 400, // $4.00 Price: 2200, // $22.00
Color: "#A0522D", Color: "#A0522D",
Description: "Smooth and balanced espresso diluted with hot water", Description: "Smooth and balanced espresso diluted with hot water",
}, },
{ {
Name: "Cappuccino", Name: "Cappuccino",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 3,
Ounces: 6, Ounces: 6,
BeanType: "Arabica", BeanType: "Arabica",
Price: 450, // $4.50 Price: 2200, // $22.00
Color: "#C4A574", Color: "#C4A574",
Description: "Espresso with steamed milk and a thick layer of foam", Description: "Espresso with steamed milk and a thick layer of foam",
}, },
{ {
Name: "Latte", Name: "Latte",
RoastType: "Medium Roast", RoastType: "Medium Roast",
RoastLevel: 2,
Ounces: 12, Ounces: 12,
BeanType: "Arabica", BeanType: "Arabica",
Price: 500, // $5.00 Price: 2500, // $25.00
Color: "#D2B48C", Color: "#D2B48C",
Description: "Creamy espresso with plenty of steamed milk and a touch of foam", Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
}, },
{ {
Name: "Mocha", Name: "Mocha",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 12, Ounces: 12,
BeanType: "Arabica", BeanType: "Arabica",
Price: 550, // $5.50 Price: 3000, // $30.00
Color: "#8B4513", Color: "#8B4513",
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.", Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
}, },
{ {
Name: "Macchiato", Name: "Macchiato",
RoastType: "Dark Roast", RoastType: "Dark Roast",
RoastLevel: 4,
Ounces: 3, Ounces: 3,
BeanType: "Robusta Blend", BeanType: "Robusta Blend",
Price: 425, // $4.25 Price: 1800, // $18.00
Color: "#DAA520", Color: "#DAA520",
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.", Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
}, },

View File

@@ -6,21 +6,25 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const cardStorageTTL = 180 * 24 * time.Hour
// Card represents a saved payment method linked to a Stripe token. // Card represents a saved payment method linked to a Stripe token.
// The actual card details live on Stripe's servers — we only store // The actual card details live on Stripe's servers — we only store
// a reference and display info (last 4 digits, brand). // a reference and display info (last 4 digits, brand).
type Card struct { type Card struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"` UserID uint `gorm:"not null;index" json:"user_id"`
StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID
Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc. Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
ExpMonth int `gorm:"not null" json:"exp_month"` ExpMonth int `gorm:"not null" json:"exp_month"`
ExpYear int `gorm:"not null" json:"exp_year"` ExpYear int `gorm:"not null" json:"exp_year"`
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
StorageExpiresAt *time.Time `gorm:"index" json:"storage_expires_at,omitempty"`
// Associations // Associations
User User `gorm:"foreignKey:UserID" json:"-"` User User `gorm:"foreignKey:UserID" json:"-"`
@@ -30,3 +34,20 @@ type Card struct {
func (Card) TableName() string { func (Card) TableName() string {
return "cards" return "cards"
} }
// CardStorageExpiresAt returns the inactivity expiration for a saved card
func CardStorageExpiresAt(now time.Time) *time.Time {
expiresAt := now.Add(cardStorageTTL)
return &expiresAt
}
// InitializeStorageTTL sets the initial retention deadline without marking
// the card as used for payment
func (c *Card) InitializeStorageTTL(now time.Time) {
c.StorageExpiresAt = CardStorageExpiresAt(now)
}
// IsStorageExpired reports whether the saved card retention deadline elapsed
func (c *Card) IsStorageExpired(now time.Time) bool {
return c.StorageExpiresAt != nil && !c.StorageExpiresAt.After(now)
}

26
pkg/models/card_test.go Normal file
View File

@@ -0,0 +1,26 @@
package models
import (
"testing"
"time"
)
func TestCardStorageTTL(t *testing.T) {
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
var card Card
card.InitializeStorageTTL(now)
if card.LastUsedAt != nil {
t.Fatalf("InitializeStorageTTL should not mark card as used")
}
if card.StorageExpiresAt == nil || !card.StorageExpiresAt.Equal(now.Add(cardStorageTTL)) {
t.Fatalf("unexpected initial expiration: %v", card.StorageExpiresAt)
}
if card.IsStorageExpired(now.Add(cardStorageTTL - time.Second)) {
t.Fatalf("card expired before ttl elapsed")
}
if !card.IsStorageExpired(now.Add(cardStorageTTL)) {
t.Fatalf("card should expire at ttl boundary")
}
}

View File

@@ -11,6 +11,7 @@ type Coffee struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"` Name string `gorm:"size:255;not null" json:"name"`
RoastType string `gorm:"size:100" json:"roast_type"` RoastType string `gorm:"size:100" json:"roast_type"`
RoastLevel int `json:"roast_level"` // 1-5 roast-intensity
Ounces int `json:"ounces"` Ounces int `json:"ounces"`
BeanType string `gorm:"size:100" json:"bean_type"` BeanType string `gorm:"size:100" json:"bean_type"`
Price int `gorm:"not null" json:"price"` Price int `gorm:"not null" json:"price"`
@@ -28,10 +29,12 @@ func (Coffee) TableName() string {
// AccountMenuItems // AccountMenuItems
var AccountMenuItems = []string{ var AccountMenuItems = []string{
"active orders",
"order history", "order history",
"addresses", "addresses",
"cards", "cards",
"ssh keys", "spend limit",
"privacy",
"faq", "faq",
"about", "about",
} }

3
pkg/models/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package models is the shared vocabulary of the app: coffee, carts, orders,
// addresses, cards, users, and the other nouns everything passes around.
package models

View File

@@ -10,41 +10,77 @@ import (
type OrderStatus string type OrderStatus string
const ( const (
OrderStatusPending OrderStatus = "pending" OrderStatusPending OrderStatus = "pending"
OrderStatusPaid OrderStatus = "paid" OrderStatusRequiresAction OrderStatus = "requires_action"
OrderStatusShipped OrderStatus = "shipped" OrderStatusPaid OrderStatus = "paid"
OrderStatusDelivered OrderStatus = "delivered" OrderStatusShipped OrderStatus = "shipped"
OrderStatusCancelled OrderStatus = "cancelled" OrderStatusDelivered OrderStatus = "delivered"
OrderStatusRefunded OrderStatus = "refunded" OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusFailed OrderStatus = "failed" OrderStatusRefunded OrderStatus = "refunded"
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. // Order represents a completed purchase.
type Order struct { type Order struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"` UserID uint `gorm:"not null;index" json:"user_id"`
CardID uint `gorm:"not null" json:"card_id"` CardID uint `gorm:"not null" json:"card_id"`
StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID
Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"` Ephemeral bool `gorm:"default:false" json:"ephemeral"`
Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"`
ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents
Total int `gorm:"not null" json:"total"` // Amount in cents ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents
ShippingName string `gorm:"size:255" json:"shipping_name"` Total int `gorm:"not null" json:"total"` // Amount in cents
ShippingStreet string `gorm:"size:255" json:"shipping_street"` ShippingName string `gorm:"size:255" json:"shipping_name"` // The name of the person the shipment is tied to
ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` ShippingStreet string `gorm:"size:255" json:"shipping_street"` // The street address the shipment is tied to
ShippingCity string `gorm:"size:255" json:"shipping_city"` ShippingStreet2 string `gorm:"size:255" json:"shipping_street2"` // The alternate street address the shipment is going to if first is valid for whatever reason
ShippingState string `gorm:"size:50" json:"shipping_state"` ShippingCity string `gorm:"size:255" json:"shipping_city"` // The city the shipment is going to
ShippingZip string `gorm:"size:20" json:"shipping_zip"` ShippingState string `gorm:"size:50" json:"shipping_state"` // The state the shipment is tied to
ShippingCountry string `gorm:"size:2" json:"shipping_country"` ShippingZip string `gorm:"size:20" json:"shipping_zip"` // The zip code the order is tied to
ShippingPhone string `gorm:"size:20" json:"shipping_phone"` ShippingCountry string `gorm:"size:2" json:"shipping_country"` // The country the shipment is tied to
CreatedAt time.Time `json:"created_at"` ShippingPhone string `gorm:"size:20" json:"shipping_phone"` // Phone number the shipment is tied to
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 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 // Associations
User User `gorm:"foreignKey:UserID" json:"-"` User User `gorm:"foreignKey:UserID" json:"-"`
Card Card `gorm:"foreignKey:CardID" json:"-"` Card Card `gorm:"foreignKey:CardID" json:"-"`
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"`
Events []OrderEvent `gorm:"foreignKey:OrderID" json:"events,omitempty"`
} }
// TableName specifies the table name for the Order model. // TableName specifies the table name for the Order model.
@@ -77,3 +113,41 @@ func (OrderItem) TableName() string {
func (o *Order) TotalInDollars() float64 { func (o *Order) TotalInDollars() float64 {
return float64(o.Total) / 100 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
}

100
pkg/models/order_display.go Normal file
View File

@@ -0,0 +1,100 @@
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 OrderStatusRequiresAction:
return "Awaiting authentication"
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 OrderStatusRequiresAction:
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,46 @@
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},
{"requires_action", Order{Status: OrderStatusRequiresAction}, "Awaiting authentication", 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"
}

View File

@@ -9,16 +9,26 @@ import (
// User represents a user authenticated via SSH public key // User represents a user authenticated via SSH public key
// No passwords - SSH key is the ONLY authentication method (matches terminal.shop exactly) // No passwords - SSH key is the ONLY authentication method (matches terminal.shop exactly)
type User struct { type User struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
SSHKeyFingerprint string `gorm:"uniqueIndex;size:100;not null" json:"ssh_key_fingerprint"` // SHA256 fingerprint - PRIMARY identifier SSHKeyFingerprint string `gorm:"uniqueIndex;size:100;not null" json:"ssh_key_fingerprint"` // SHA256 fingerprint - PRIMARY identifier
SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key SSHPublicKey string `gorm:"uniqueIndex;type:text;not null" json:"-"` // Full SSH public key
Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels) Name string `gorm:"size:255" json:"name,omitempty"` // Real name (for shipping labels)
Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts) Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts)
Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key
StripeCustomerID string `gorm:"size:255" json:"-"` StripeCustomerID string `gorm:"size:255" json:"-"`
CreatedAt time.Time `json:"created_at"` PrivacyMode bool `gorm:"default:false" json:"privacy_mode"`
UpdatedAt time.Time `json:"updated_at"` // MaxOrderCents is an optional per-user override of the global MAX_ORDER_CENTS
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // spend cap. It may raise OR lower the global cap — both directions are
// intentional (e.g. a vetted corporate buyer gets a higher ceiling; a flagged
// account gets a lower one). States: nil = inherit the global; explicit 0 =
// per-user off-switch (same semantics as the global); >0 = custom ceiling. A
// negative value is treated as "no override" (falls back to global) with a
// warning — see ConvertCart. Operator-set out-of-band; kept out of API payloads.
MaxOrderCents *int `json:"-"`
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
} }
// TableName specifies the table name for the User model // TableName specifies the table name for the User model
@@ -32,6 +42,8 @@ type PublicUser struct {
SSHKeyFingerprint string `json:"ssh_key_fingerprint"` SSHKeyFingerprint string `json:"ssh_key_fingerprint"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
SelfLimitCents *int `json:"self_limit_cents,omitempty"`
PrivacyMode bool `json:"privacy_mode"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -42,6 +54,8 @@ func (u *User) ToPublic() PublicUser {
SSHKeyFingerprint: u.SSHKeyFingerprint, SSHKeyFingerprint: u.SSHKeyFingerprint,
Name: u.Name, Name: u.Name,
Email: u.Email, Email: u.Email,
SelfLimitCents: u.SelfLimitCents,
PrivacyMode: u.PrivacyMode,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
} }
} }

15
pkg/models/user_test.go Normal file
View File

@@ -0,0 +1,15 @@
package models
import "testing"
// TestToPublic_IncludesPrivacyMode guards the converter: PublicUser must carry
// the user's PrivacyMode so the TUI can reflect the account toggle's real state.
// (Regression: ToPublic originally omitted this field and always reported false.)
func TestToPublic_IncludesPrivacyMode(t *testing.T) {
for _, want := range []bool{true, false} {
u := User{ID: 1, PrivacyMode: want}
if got := u.ToPublic().PrivacyMode; got != want {
t.Errorf("ToPublic().PrivacyMode = %v, want %v", got, want)
}
}
}

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)
}

3
pkg/observability/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package observability sets up tracing and structured logs so that when
// something breaks at 3am you stand a chance of figuring out why.
package observability

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
}

3
pkg/shipping/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package shipping holds the plain shipping types that the rest of the app
// passes back and forth.
package shipping

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
}

2
pkg/shippo/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package shippo talks to the Shippo API for shipping rates and labels.
package shippo

View File

@@ -2,15 +2,27 @@ package shippo
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"sort"
"strconv"
"terminalShop/pkg/shipping"
"time" "time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
const baseURL = "https://api.goshippo.com" const baseURL = "https://api.goshippo.com"
const (
labelPollInterval = 1 * time.Second
labelPollTimeout = 30 * time.Second
)
// Client handles communication with the Shippo API. // Client handles communication with the Shippo API.
type Client struct { type Client struct {
apiKey string apiKey string
@@ -22,11 +34,325 @@ func NewClient(apiKey string) *Client {
return &Client{ return &Client{
apiKey: apiKey, apiKey: apiKey,
httpClient: &http.Client{ 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. // Address represents a shipping address for validation.
type Address struct { type Address struct {
Name string `json:"name"` Name string `json:"name"`
@@ -37,6 +363,7 @@ type Address struct {
Country string `json:"country"` Country string `json:"country"`
Zip string `json:"zip"` Zip string `json:"zip"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"`
} }
// addressRequest is the payload sent to Shippo's address validation endpoint. // 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. // ValidateAddress sends an address to Shippo for validation.
// Returns the normalized/corrected address if valid, or an error if invalid. // 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{ reqBody := addressRequest{
Name: addr.Name, Name: addr.Name,
Street1: addr.Street1, 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("shippo request failed: %w", err) return nil, fmt.Errorf("shippo request failed: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {

3
pkg/stripeclient/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package stripeclient wires up the Stripe SDK once at startup (with tracing
// baked in) so everyone else can just make calls.
package stripeclient

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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
package tui
func (m Model) AddressesView(width int) string {
contentStyle := m.theme.TextBody().Width(width)
lines := ""
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
}

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

@@ -0,0 +1,31 @@
package tui
import (
"fmt"
)
func (m Model) CardsView(width int) string {
contentStyle := m.theme.TextBody().Width(width)
lines := ""
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
}

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

@@ -0,0 +1,340 @@
package tui
import (
"fmt"
"strings"
"terminalShop/pkg/models"
"charm.land/lipgloss/v2"
)
func (m Model) OrdersView(width int) string {
return m.orderListView(m.HistoryOrders(), width)
}
func (m Model) ActiveOrdersView(width int) string {
return m.orderListView(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
}
// orderCardHeight is the rendered height of one order card:
// border (2) + content lines (2), no vertical padding.
const orderCardHeight = 4
// orderListPageSize returns how many whole order cards fit in the detail
// area. Both scroll arrows render outside the list, in the gap rows above
// and below the body, so the cards use the full viewport height.
func (m Model) orderListPageSize() int {
n := m.account.detailViewport.Height() / orderCardHeight
if n < 1 {
n = 1
}
return n
}
// orderHiddenAbove returns how many order cards are scrolled off above the
// windowed list, or 0 when the windowed order list isn't showing.
func (m Model) orderHiddenAbove() int {
if m.account.orderViewState != 1 || !m.OrdersLoaded {
return 0
}
orders := m.currentOrderList()
if len(orders) == 0 {
return 0
}
page := m.orderListPageSize()
start := m.account.orderWindowStart
if start > len(orders)-page {
start = len(orders) - page
}
if start < 0 {
start = 0
}
return start
}
// orderHiddenBelow returns how many order cards are scrolled off below the
// windowed list, or 0 when the windowed order list isn't showing.
func (m Model) orderHiddenBelow() int {
if m.account.orderViewState == 2 || !m.OrdersLoaded {
return 0
}
orders := m.currentOrderList()
if len(orders) == 0 {
return 0
}
below := len(orders) - (m.orderHiddenAbove() + m.orderListPageSize())
if below < 0 {
below = 0
}
return below
}
// orderScrollIndicator renders the dim "more items" arrow row, centered
// across the card width.
func orderScrollIndicator(arrow string, count, width int) string {
return pDim.Width(width).Align(lipgloss.Center).
Render(fmt.Sprintf("%s %d more", arrow, count))
}
func (m Model) orderListView(orders []models.Order, width int) string {
contentStyle := m.theme.TextBody().Width(width)
if !m.OrdersLoaded {
return contentStyle.Render("Loading orders...")
}
if len(orders) == 0 {
return contentStyle.Render("No orders yet")
}
if m.account.orderViewState == 2 {
return m.buildOrderDetailView(orders[m.account.orderCursor], width)
}
// Whole-card windowing: render only fully-visible cards, never clipping a
// card mid-border. Both scroll arrows render in BuildAccountView's gap
// rows, so the cards span exactly the menu panel's rows.
page := m.orderListPageSize()
start := m.orderHiddenAbove()
end := start + page
if end > len(orders) {
end = len(orders)
}
var rows []string
for i := start; i < end; i++ {
isSelected := m.account.orderViewState == 1 && i == m.account.orderCursor
rows = append(rows, m.buildOrderCard(orders[i], width, isSelected))
}
return lipgloss.JoinVertical(lipgloss.Left, rows...)
}
// 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()
}

119
pkg/tui/account_test.go Normal file
View File

@@ -0,0 +1,119 @@
package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
"terminalShop/pkg/models"
)
func TestParseSpendLimitInput(t *testing.T) {
cases := []struct {
in string
want int
wantNil bool
wantErr bool
}{
{"", 0, true, false}, // blank clears
{"0", 0, false, false}, // $0 is block-all, NOT a clear
{"25", 2500, false, false}, // whole dollars
{"25.50", 2550, false, false}, // dollars + cents
{"25.5", 2550, false, false}, // one decimal => tenths
{"0.05", 5, false, false}, // sub-dollar
{" $25.00 ", 2500, false, false}, // strips $ and whitespace
{".50", 50, false, false}, // leading dot
{"-5", 0, false, true}, // negative rejected
{"abc", 0, false, true}, // non-numeric rejected
{"25.999", 0, false, true}, // too many decimals rejected
}
for _, tc := range cases {
got, err := parseSpendLimitInput(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("%q: want error, got value %v", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error: %v", tc.in, err)
continue
}
if tc.wantNil {
if got != nil {
t.Errorf("%q: want nil, got %d", tc.in, *got)
}
continue
}
if got == nil || *got != tc.want {
t.Errorf("%q: want %d, got %v", tc.in, tc.want, got)
}
}
}
func TestSetSpendLimitCmd_NilClient(t *testing.T) {
m := NewModel("test")
m.APIClient = nil
cmd := m.setSpendLimitCmd(nil)
if cmd == nil {
t.Fatal("setSpendLimitCmd returned nil cmd")
}
saved, ok := cmd().(spendLimitSavedMsg)
if !ok {
t.Fatalf("expected spendLimitSavedMsg, got %T", cmd())
}
if saved.Err == nil {
t.Error("expected error when API client is nil")
}
}
// TestSpendLimitFocusFlow: enter focuses the input and prefills from the user's
// current limit; esc cancels without mutating the stored limit.
func TestSpendLimitFocusFlow(t *testing.T) {
m := NewModel("test")
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
m.User = &models.User{ID: 1, SelfLimitCents: intp(2500)}
for i, item := range models.AccountMenuItems {
if item == "spend limit" {
m.account.cursor = i
}
}
m, _ = m.AccountUpdate(tea.KeyPressMsg{Code: tea.KeyEnter})
if !m.account.spendLimitFocused {
t.Fatal("enter did not focus the spend-limit input")
}
if got := m.account.spendLimitInput.Value(); got != "25.00" {
t.Errorf("prefill: want \"25.00\", got %q", got)
}
m, _ = m.AccountUpdate(tea.KeyPressMsg{Code: tea.KeyEsc})
if m.account.spendLimitFocused {
t.Error("esc did not blur the spend-limit input")
}
if m.User.SelfLimitCents == nil || *m.User.SelfLimitCents != 2500 {
t.Error("esc must not change the stored limit")
}
}
// TestSpendLimitSavedMsg: the async-result handler updates the local user and
// clears focus on success.
func TestSpendLimitSavedMsg(t *testing.T) {
m := NewModel("test")
m = m.updateLayout(100, 40)
m, _ = m.AccountSwitch()
m.User = &models.User{ID: 1}
m.account.spendLimitFocused = true
m.account.spendLimitSaving = true
m, _ = m.AccountUpdate(spendLimitSavedMsg{Cents: intp(750)})
if m.account.spendLimitFocused {
t.Error("successful save should blur the input")
}
if m.User.SelfLimitCents == nil || *m.User.SelfLimitCents != 750 {
t.Errorf("save should update local user limit to 750, got %v", m.User.SelfLimitCents)
}
}
func intp(i int) *int { return &i }

View File

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

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