Known Issues & Triage
The single prioritized index of every bug, security gap, dead-code smell, and doc discrepancy surfaced while writing this wiki — grouped by severity, each linked to the detail page. Start here.
Every issue found while documenting the codebase, in one place. Each row links to the page with the full context. Nothing here is speculative — all were confirmed against source with file:line citations on the linked pages.
How to read this
Severity reflects blast radius if left unfixed, not effort to fix. 🔴 = money loss, data corruption, cross-tenant leakage, or a live security hole. 🟠 = wrong behavior / missing safety rail with a bounded impact. 🟢 = dead code, stale comments, or doc drift — safe to defer, cheap to clean.
🔴 Critical — money, correctness, tenancy, security
| Issue | Area | Where | Detail |
|---|---|---|---|
Payout pipeline wired but not driven — POST /payouts debits the wallet then stops at created; processor/status-sync crons unregistered, QueuePayout uncalled → nothing ever reaches Merit/Ledig | Payouts | main.go:227, payout_service.go:391 | Payouts |
| No payout refund path — wallet debited but never re-credited on failure/cancel (fund loss) | Payouts | payout_service.go:518 | Payouts |
Shopify writes client_id = 0 on every ingest insert (order/customer/item) → FK failure or cross-tenant mis-attribution | Shopify | fetch_shopify_orders_job.go:258/368/448 | Shopify |
| Shopify fulfilment-failure persists status DELIVERED → Shopify order stuck unfulfilled forever, excluded from retry | Shopify | process_pending_shopify_orders_job.go:291/335 | Shopify |
services/wallet_refund.go is dead — refunds re-implemented inline in handlers; two parallel impls, tests pin the unused one | Wallet | wallet_refund.go vs handlers | Wallet & Ledger |
| Top-up webhook not concurrency-safe — no row-lock, no terminal short-circuit, no auto-refund on FAILED → status regression + un-refunded failures | Top-up | vendor_webhook_service.go:282 | Top-up & eSIM |
All-FAILED voucher order reads PENDING, never refunds — getOrderDeliveryStatus maps "all items failed, none delivered" to OrderPending not OrderFailed; failed items are excluded from the next retry filter → funds stay debited, order trapped until an admin cancels/refunds | Orders | create_voucher_order.go:437, :2251 | Troubleshooting |
| Dead supplier leaves recharges/eSIM PENDING forever, un-refunded — the transient-failure branch no longer auto-FAILs on max-retry (removed as conflicting with the recreate budget), so a down supplier loops on backoff with money debited until the recreate budget (5) parks it for admin; no circuit breaker on the top-up/eSIM path | Top-up/eSIM | services/esim_order.go:826, :981 | Troubleshooting |
Admin cancel/reset never release inventory — allocated stock stays ALLOCATED forever (leak); reset hard-deletes the referencing order_items → orphaned beyond recovery; the pool member was already ZREM'd so it's double-lost | Admin/Inventory | admin_ui.go:2668/3248, inventory.go:1485 | Inventory Flow, Admin Order Actions |
Payout engine designed but dead — payout-processor + payout-status-sync crons never registered, QueuePayout uncalled → only created+cancelled reachable | Payouts | main.go:227 | State Machines, Cron Catalog |
| Admin cancel flags REFUNDED even when the wallet credit fails (best-effort) → un-refunded-but-flagged orders that then block the Refund endpoint | Admin/Wallet | admin_ui.go:2745 vs :2825 | Admin Order Actions |
CSRF disabled — middleware defined but commented out; admin panel relies solely on SameSite=Strict | Auth | main.go:513 | Auth findings |
| No rate limiting anywhere — none on OTP/2FA, login, forgot-password, or refresh | Auth | codebase-wide | Auth findings |
| TOTP secrets stored plaintext at rest (backup codes are hashed; seeds are not) | Auth | repo/client_user.go:485, repo/admin_user.go:428 | Auth findings |
JWT secret hardcoded fallback "your-super-secret-key" when JWT_SECRET unset — no startup assert | Auth | utils/jwt.go + 4 sites | Auth findings |
Committed secrets — root .env (Twilio/SendGrid/OpenAI/JWT/APP_KEY), wrangler.jsonc (ENCRYPTION_KEY/ADMIN_TOKEN/…), settings.local.json (sandbox PGPASSWORD), compose defaults | Secrets | .env:16-23, wrangler.jsonc:27,156, docker-compose.yaml | Secrets & Config, Deploy |
SEAGM logs secret_key in plaintext at Info level ("TEMP DIAGNOSTIC") | Vendor | seagm adapter | SEAGM |
Grasshopper deriveKey is not a KDF — pads/truncates the raw key to 32 bytes (no salt/PBKDF2); no key rotation/versioning so rotating ENCRYPTION_KEY orphans all D1 ciphertext | Grasshopper | crypto.ts:9 | Grasshopper |
Grasshopper admin auth has no rate limiting — ADMIN_RATE_LIMITER declared (wrangler comment claims brute-force protection) but never called | Grasshopper | admin-auth.ts:6 | Grasshopper |
All vendor credentials stored plaintext — vendor_attributes.value TEXT has no encryption, no is_secret, no masking; the payout table's is_secret flag is ignored | Vendor/Secrets | vendor_attributes.go | Vendor Config |
🟠 High / Medium — wrong behavior, missing safety rails
| Issue | Area | Where | Detail |
|---|---|---|---|
| "Automation blocker" middleware doesn't exist — CLAUDE.md + route comments reference a production control with no implementation | Auth | routes.go:60 | Auth findings |
Per-currency precision bug — all money forced to 2 dp; currencies.precision ignored (KWD/BHD/JPY mis-round); ledger forex_rate NUMERIC(8,2) truncates/overflows | Wallet | utils/money.go, migrations | Wallet & Ledger |
created_by silently dropped in the debit stored proc — user attribution always NULL | Wallet | …for_fx.sql INSERT | Wallet & Ledger |
Wallet webhook fires pre-commit from a goroutine → can emit wallet.debited for a rolled-back tx | Wallet | wallet_debit.go:129 | Wallet & Ledger |
Race-loser insufficient-funds surfaces as raw pg error, not ErrInsufficientBalance | Wallet | repo/wallet.go:465 | Wallet & Ledger |
TriggerPayoutEvent has zero callers → payout client webhooks never fire | Payouts | payout_webhook_service.go:41 | Payouts |
Beneficiaries need an admin-linked provider or CreatePayout fails resolution silently | Payouts | beneficiary-create paths | Payouts |
OrderNotifier unwired → voucher partial/cancelled emails never sent (templates exist) | Notifications | services/order_notifier.go | Notifications |
SMTP transport drops attachments — bulk-voucher XLSX not delivered under EMAIL_PROVIDER=smtp | Notifications | smtp.go:210 | Notifications |
Shopify orders mis-tagged source=API — SourceShopify constant dead | Shopify | create_voucher_order.go:804 | Shopify |
Shopify: no rate-limit / query-cost handling — no backoff on THROTTLED | Shopify | client.go:131 | Shopify |
| eSIM inbound webhook has no idempotency-key row — relies solely on terminal-state lock; a non-terminal duplicate could double-process | eSIM | esim_webhook.go:137 | Top-up & eSIM |
Inventory expiring >1yr out is pumped but never allocatable — pump admits expires>=now+30d (no upper bound) but allocation caps at now+365d → paid-for stock stranded | Inventory | inventory.go:672 vs :118 | Inventory Flow |
| Allocation has no DB fallback on cache miss — empty/unpumped pool → treated as insufficient inventory even when DB has AVAILABLE rows | Inventory | create_voucher_order.go:1723 | Inventory Flow |
| G2A concurrency loser gets 500 (not a clean 200/409) — safe (no double-mint) but rough | G2A | g2a_order.go:154 | G2A |
G2A price-mismatch — unit_price/auction_base_price stored but ignored; billing uses mapping denomination | G2A | g2a order path | G2A |
TRS CancelVoucher is a no-op that returns success without calling the API; no tests, no mock | Vendor | trs adapter | TRS |
| No zero-downtime / no DB rollback / no version stamping — stop→migrate→start every deploy; binary rollback leaves schema forward | Deploy | deploy/update-octopus.sh | Deploy & Release |
migrate fresh drops the DB, unguarded on prod (guarded only on sandbox) | Deploy | migration.go:265 | Deploy & Release |
| Admin destructive actions confirm client-side only (~13 of ~30 views) — server enforces only write/super gating | Admin | views/admin/ | Admin Panel |
| Client-config payout limits computed & shown but not enforced on create | Payouts | payout_service.go | Payouts |
| No client-side login lockout (admin has a 5-strike; client has none) | Auth | client_auth.go:18 | Auth findings |
Client portal sends no CSRF token — cookie auth + withCredentials with zero XSRF handling; rests entirely on backend SameSite=Lax — confirm the backend enforces origin/CSRF for state-changing /client/* calls | Client Portal | services/api/client.ts | Client Portal Auth |
Client-portal webhook signing secret may be readable on list — the card renders a reveal/copy control for token on every listed webhook, contradicting the "only returned on create" contract; verify the Go GET /webhooks handler | Client Portal | settings/webhooks/WebhookCard.tsx | Client Portal Settings |
Grasshopper topup/eSIM reservation isn't concurrency-safe — the reserve UPDATE gates on is_redeemed=false but never sets it, so concurrent submits fire multiple Octopus order calls (only plain gift-card is single-winner); pinned by a TODO(BUG) witness test asserting octopusCalls > 1 | Grasshopper | src/api/claim.ts:849 | Test Catalog |
Large CI blind spots — services (incl. payout engine), email, middleware, jobs (shopify jobs, payout job), http/handler (incl. admin_ui.go, create_voucher_order.go), test/{admin,clientportal,flows,frontend}, and all Grasshopper vitest are real suites that no workflow runs | Testing | .github/workflows/test-backend-api.yml | Test Catalog |
mocky-balboa /_chaos is unauthenticated and chaos rules are global rows in a shared D1 — against the deployed mock, concurrent CI runs' DisableAll/arm interfere (documented flakiness); its custom domain is also absent from wrangler.jsonc (dashboard-only) | Testing/Mock | mocky routes/_chaos.ts, wrangler.jsonc | Mocky-Balboa |
Grasshopper poll recovery creates orders inside a GET — idempotency rests entirely on Octopus honoring the reused client_reference; exposed to browser prefetch/retry | Grasshopper | claim.ts:1192 | Grasshopper |
Grasshopper rate limiters fail open on a missing binding; RATE_LIMIT_FAIL_OPEN=true ships in .dev.vars.example (copy-to-prod hazard) | Grasshopper | rate-limit.ts:39 | Grasshopper |
Grasshopper vouchers has no indexes — code/poll_token/topup_client_ref lookups are unindexed table scans; unsalted SHA-256 for 6-digit PINs | Grasshopper | schema.ts | Grasshopper |
currencies.is_active enforced nowhere — every currency lookup filters only deleted_at IS NULL, so "deactivating" a currency does not stop orders in it; the only real blocks are soft-delete, remove the product, or drop the FX rate | Money | repo/currency.go:270, repo/forex.go:26 | Troubleshooting |
Voucher retry cap (10) vs recreate budget (5) mismatch — orders at retry_count 6–10 are still selected by pending-order-retry but refuse to recreate; above 10 they silently drop out of the query with no alert, no FAILED, no refund (top-up cron has the same 10-vs-5 split) | Orders/Top-up | repo/order.go:1557, bulk_vendor_order_checkpoints.go:695 | Troubleshooting |
🟢 Low — dead code, stale comments, doc drift
| Issue | Area | Where |
|---|---|---|
direct_topup_webhooks table/repo appears dead — webhooks persist to vendor_webhook_logs | Vendor (DTONE) | DT One |
RechargeStatusRecharged dead; "PROCESSING" literal guard is a dead branch | Top-up | Top-up & eSIM |
eSIM sub-statuses REFUNDED/NOT_REFUNDED/COMPLETED defined but never assigned | eSIM | Top-up & eSIM |
wallet_refund.go dead trio, payout.refunded event, WISE/REVOLUT codes, POSTPAID type, GST unimplemented | Wallet/Payouts | Wallet, Payouts |
shopify_order_item_vouchers table orphaned; shopify_last_sync_at written but never read | Shopify | Shopify |
G2A webhook_secret, G2AReservation.G2AOrderID, unused status constants | G2A | G2A |
OrderSource SHOPIFY/DASHBOARD constants defined but never assigned | Order | Order Lifecycle |
Referenced-but-absent: automation/scripts/run-go-tests.sh, make setup, database/repo/TESTING.md | Testing | Testing |
| Stale cron-schedule comments (trust the cron string, not the comment); retry "Max 5" is really 10 | Jobs | Jobs & Observability |
recharges.sub_status column exists (migration 20260427000000) but the Recharge model has no field and the repo never selects it → write-only, invisible to the app read path | Top-up | Troubleshooting |
GetMissingForexRatesForWallets hardcodes wallet currencies to {INR, USD} — a client whose base currency is neither won't be reported as missing a rate | Money | Troubleshooting |
client_vendor_blacklists checked inconsistently — the hide-list path filters is_active, the vendor-attach path filters only deleted_at | Catalog | Troubleshooting |
| Two competing docs deploy paths (Cloudflare Pages CI vs nginx-static on box) | Deploy | Deploy & Release |
latest image tags for valkey/otel-collector — non-reproducible pulls | Deploy | Deploy & Release |
| Two default-discount policies diverge (−1.0 vs −4.0) | Catalog | Products & Catalog |
bcrypt cost mismatch (admin 12 vs client/API 10); dead CookieAccessExpiry=15m; duplicated checkIPWhitelist | Auth | Auth findings |
Client portal deploy doc is stale — the app migrated to a standalone Node/systemd + nginx deploy (output:'standalone', deploy/octopus-client.service), but frontend/client/ARCHITECTURE.md + a leftover out/ dir still describe Cloudflare Pages static export | Client Portal | Client Portal |
Client portal: Shopify not implemented (feature flag + dead #! upsell only); IP-whitelist has no client-side CIDR validation and empty=allow-all with no-confirm disable; Account Info is read-only (no update endpoint) | Client Portal | Client Portal Settings |
Grasshopper dead code: ErrorPage.tsx, createClaimSession/verifyClaimSession, isPending=true const, PRIVATE var, octopus_esim_product_id (fulfilment reads octopus_topup_product_id), PATCH /api/vouchers/code/:code/redeem (no Go caller — GH self-redeems) | Grasshopper | Grasshopper, API |
Grasshopper esc() doesn't escape ' yet escaped values sit inside single-quoted inline onclick JS strings (ICCID copy buttons); voucherInfoBar/redeemedHtml skip esc() on some DB values | Grasshopper | UI |
Dual-hash split — GH stores SHA-256(code) for D1 lookups but Octopus redemption matches SHA-3-256 (voucher_redemption.go:32); assuming they're the same hash is a footgun | Grasshopper/Voucher | API |
GET /api/vouchers/code/:code returns the entire row (incl. pin_hash, poll_token, encrypted notification_email) to any admin token | Grasshopper | API |
Grasshopper CI runs plain wrangler deploy (no --minify that the npm script uses); reset-remote.sh uses an order-dependent sed on the first database_id | Grasshopper | Grasshopper |
Seeders have no prod guard — running a vendor/credential seeder on a shared env overwrites live creds with localhost mock values; committed dev creds in seeders (GH api_key, Runa whsec_, admin default password) | Seeders | Seed Catalog |
Grasshopper L4 e2e absent (test/e2e/ missing so test:e2e runs nothing); fuzz iteration counts capped low to avoid isolate OOM; RateLimit branches only stub-tested | Testing | Test Catalog |
Seeder ordering fragility — voucher orchestration suites self-seed because the shared vendor_product seeder only assigns variants to the first 10 vendors | Seeders | Seed Catalog |
Adapter-level test gaps — TRS/GH/iRewardify have no adapter tests; Wupex-topup/DTOne-eSIM have no contract test; SEAGM money-mapping fixed-but-untested (the ~60× overcharge path); FailureInfo declared 3× (DRY); Svix webhook verifier untested | Vendor/Testing | Vendor Adapter Tests |
| No orchestration test suite for TRS / EpinForce / Grasshopper; TRS wholly untested | Testing | Testing |
Suggested first sprint
If picking up cold, the highest value-per-effort order:
- Rotate every committed secret (§🔴 committed secrets) — cheapest, highest risk. Then scrub or
.gitignore-verify each source. - Fix the Shopify
client_id=0bug — one-line struct fix, unblocks all multi-tenant ingest. - Decide the payout pipeline — either register the crons + wire
QueuePayout+ add the refund branch, or feature-flag payouts off until it's finished. Today it silently debits wallets with no fulfilment. - Harden the top-up webhook to eSIM-webhook parity (lock + terminal short-circuit + auto-refund).
- Wire CSRF back on and add rate limiting to the auth endpoints.
- Consolidate the two refund implementations and delete the dead
wallet_refund.go(or wire it in).
This index is a snapshot from the documentation pass. As issues are fixed, delete the row (and update the linked page) — a shrinking triage list is the clearest signal of handover progress.