From 016394757f14c018cb52853d3c663a6c2f94876a Mon Sep 17 00:00:00 2001 From: Daniel Popoola Date: Thu, 2 Jul 2026 13:01:48 +0100 Subject: [PATCH] Refactor payment and queue services for clarity and maintainability - Removed redundant comments in the PaymentService and QueueService to enhance code readability. - Clarified the purpose of the failureFlow and validateClaimForPayment methods in PaymentService by removing outdated comments. - Updated the Abandon method in QueueService to reflect that only WAITING entries can be abandoned, improving documentation accuracy. - Adjusted the Join method in QueueCoordinator to indicate that Redis position tracking is a best-effort operation, ensuring clearer logging practices. --- .dokugen-backup.md | 102 ++++ .dokugen-cache.json | 88 +++ README.md | 737 ++++++++++++++++++++++++-- internal/service/payments.go | 2 - internal/service/queue.go | 2 - internal/service/queue_coordinator.go | 2 - 6 files changed, 872 insertions(+), 61 deletions(-) create mode 100644 .dokugen-backup.md create mode 100644 .dokugen-cache.json diff --git a/.dokugen-backup.md b/.dokugen-backup.md new file mode 100644 index 0000000..38f433a --- /dev/null +++ b/.dokugen-backup.md @@ -0,0 +1,102 @@ +# FairQueue + +A virtual queue and inventory allocation system for high-demand live events in Nigeria. Built to handle the moment 50,000 people try to buy 5,000 tickets at exactly the same time — without overselling, without crashes, and without bots. + +## The Problem + +When a high-demand event goes on sale, three things happen simultaneously: + +- The website crashes under the sudden spike in traffic +- Bots grab the inventory in milliseconds before real fans get a chance +- Payment failures silently lose tickets that were already claimed + +FairQueue solves all three. It absorbs the traffic spike into a virtual queue, admits customers at a controlled rate, and guarantees that inventory allocation is atomic — two people can never get the same ticket. + +## Quick Start + +```bash +git clone https://github.com/DanielPopoola/fairqueue +cd fairqueue +cp .env.example .env # fill in your Paystack keys; everything else works out of the box +docker compose up --build +``` + +API is available at `http://localhost:8080`. Swagger UI is at `http://localhost:8080/swagger/index.html`. + +## How It Works + +A customer's journey through the system has four stages: + +**1. Queue** — When the sale opens, everyone hits `POST /events/{id}/queue` at once. This is a cheap Redis write (O(log N)), so it absorbs any traffic volume without touching the database. Each customer gets a queue position. + +**2. Admission** — A background worker runs every 5 seconds. It pops the next batch of customers from the waiting queue, moves them to the admitted set, and pushes them a signed admission token via WebSocket. Customers who miss the push can poll `GET /events/{id}/queue/position` to retrieve their token. + +**3. Claim** — An admitted customer presents their token to `POST /events/{id}/claims`. The system atomically checks and decrements the Redis inventory counter. If that succeeds, it inserts a claim row in Postgres. A unique constraint on `(event_id, customer_id)` is the last line of defence against any race condition. + +**4. Payment** — The customer calls `POST /claims/{id}/payments`, which writes a payment record to Postgres *before* calling Paystack. This means a crash can never produce a charge with no record. The reconciliation worker finds and heals any payments stuck in intermediate states. + +## Key Design Decisions + +**PostgreSQL is the only source of truth.** Redis holds nothing that cannot be reconstructed from Postgres. If Redis is wiped, the startup recovery function rebuilds the queue and the reconciliation worker heals the inventory count. Redis makes things fast; Postgres makes things correct. + +**Two-layer concurrency shield.** A Redis `SET NX` lock stops concurrent claim attempts before they reach the database. A Postgres unique constraint on `(event_id, customer_id)` is the inviolable correctness guarantee that holds even if the lock is unavailable. Both layers must fail for an oversell to occur. + +**Outbox pattern for payment safety.** A `Payment` row is always written in `INITIALIZING` state before the Paystack API is called. A crash at any point leaves a recoverable record. The reconciliation worker finds stale `INITIALIZING` records and retries the gateway call. + +**Postgres-first writes.** The Redis inventory counter is only decremented *after* the Postgres insert commits. If the server crashes between the commit and the Redis write, Redis shows more tickets than exist — the reconciliation worker heals this within 30 seconds. The alternative (Redis first) risks permanently locking out valid users. Temporary inflation is the only acceptable failure mode. + +For the full reasoning behind every decision, see [TRADEOFFS.md](./TRADEOFFS.md). + +## Architecture + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for a component diagram showing how all the pieces fit together. + +## Project Structure + +``` +cmd/api/ Entry point and dependency wiring +internal/ + domain/ State machines and domain errors — no infrastructure dependencies + service/ Business logic: claims, queue, payments, events + store/ + postgres/ PostgreSQL store implementations + redis/ Redis store implementations (inventory, queue, lock) + worker/ Background workers: admission, expiry, reconciliation, recovery + api/ HTTP handlers, middleware, WebSocket hub + gateway/paystack/ Paystack payment gateway adapter + auth/ JWT tokenizers (organizer + customer), argon2id password hashing + config/ Environment-based configuration loading and validation + infra/ + migrate/ Embedded SQL migrations + retry/ Generic retry with exponential backoff +``` + +## Running Tests + +```bash +# Domain logic only — fast, no infrastructure required +make test-unit + +# Service and worker tests — spins up real Postgres and Redis via testcontainers +make test-integration + +# Full end-to-end flow tests +make test-e2e + +# Everything +make test +``` + +Integration tests run against real infrastructure. The only mock in the codebase is the Paystack gateway — because mocking the database tells you nothing about whether the unique constraint fires. + +## Stack + +| Layer | Technology | +|---|---| +| Language | Go | +| Database | PostgreSQL 16 | +| Cache / Queue | Redis 7 | +| Payment | Paystack | +| HTTP router | Chi | +| WebSocket | coder/websocket | +| Container | Docker + Compose | \ No newline at end of file diff --git a/.dokugen-cache.json b/.dokugen-cache.json new file mode 100644 index 0000000..2d29041 --- /dev/null +++ b/.dokugen-cache.json @@ -0,0 +1,88 @@ +{ + "version": "1.0", + "files": { + "TRADEOFFS.md": "49c3f91e8e0cb9d7bac4a913840febf13b438f5b554825da72cd4f7d4f69f767", + ".env.example": "3674e82e92d7574e25e0af688e94955aa6212816d100d7070a4ff596d6cab180", + "ARCHITECTURE.md": "db3d568f9f1797b2ee83de6a987290b938db7989671c6821cc54e8cfa6b7c5b6", + ".golangci.yaml": "bdba2d37a6025a453817bdbdf40ac7db7770fc84e9de9d088f230a1ffa8ff861", + ".dokugen-backup.md": "c31e1b0f42fd8fa93efea49d346f57369f2cf87f89a6b1ded068359393ae8761", + "Dockerfile.loadtest": "0fae4c563cdacb5c33f5efe771ab77d90c93e937d6adcad48e322d0bffb29fed", + "go.sum": "4f95f7cedeb1093d3ae091a766dba6c99d1af143c268d6c9dd7221b56ea4914c", + ".env.loadtest.example": "7a8afd167da23961804dccbda4b0eca01aa8564ae3489df3017e211f807e5417", + "README.md": "20e49c4c5b598e4a2b4fa03ba36ab4bde268b8d4ad70570663d320a51e54956c", + "docker-compose.loadtest.yml": "8ce66568198bb7139fd29bc032ac1d56815e2da77f19a95010ae117039b0c0b5", + "go.mod": "9b6c6a466f01c034922d29f88f9af29eb7341aae03930feed2c788ca66e5da07", + "internal/store/redis/lock.go": "08a0edc238b91be2ac4c64217ba37097601f67ca839b4300130bc30eaef3186d", + "internal/store/redis/lock_test.go": "0aa952229fce8931810464dfafc8e91dc9528cd1b96ba061c77ea15e541649e9", + "internal/store/redis/queue.go": "2bdb997e0de52790b9e8655b368eeb316d194c7607ada07ec8b0af6bdfec83e9", + "internal/store/redis/client.go": "0335a6afd5ffd5381080de296c2a508da55976133776a52c32545e52eb5ba996", + "internal/store/redis/queue_test.go": "0ba9fe659ffaf4168573707bf9a4acc2298e666fe533d4830df746444cf67e31", + "internal/store/redis/inventory.go": "e69b194435d584f8abe3d477bc6ebf4af2dd3a6adb1ba13ed7cd98f77001f6d6", + "internal/store/postgres/organizer_store.go": "9ef054940f4d1f274b8ac1d355c0e3d4620609979a422aa8d5464bade64a4c02", + "internal/store/postgres/db.go": "8dea17b5ca426d804918be44c2477845ae4c654715d8509b5054de6b4254251b", + "internal/store/postgres/customer_store.go": "40436ea1d1ca84e91cb413c151677b3652f37cb975d00dcd4594862b57bde1ef", + "internal/store/postgres/db_test.go": "7f21d28ef7160e3a6b55205dfdb48d235c1db378267cf99b817c2ff8f23a7198", + "internal/store/postgres/payment_store.go": "f9a773af8d7d93d241b56b3e591ce37b8fe114986c73e93c2b1c9d889026a7c0", + "internal/store/postgres/claim_store_test.go": "10f317667905e9a4dfce3d9581467b91169d9aaf94ff13f749e3b3c759b6ecda", + "internal/store/postgres/event_store.go": "9d3658d58c0405a70679442865cf0e84b047001a88a1491dc248e67992e0eea2", + "internal/store/postgres/queue_store.go": "450f34eca3063ce48d4793b536c382f09ce446ccec1b810337252d1915f484c7", + "internal/store/postgres/claim_store.go": "95256d3744a1e036fe1b723eb459e4d2588ffecf6dd5c16af1c64803503edb38", + "internal/store/testhelpers/fixtures.go": "f46436a698b51f6d4741297fd7fdb39ec18aeb0869120c60b6254fd151ee2bbc", + "internal/store/testhelpers/migrations.go": "7008ec78983e39371e1b0a3f6f4f3a8e64808256dc00dc1a8c1c17aabf66950d", + "internal/store/testhelpers/containers.go": "e05d4b2be9717d629787af4726300000c069270a2d9b5af5520f084452e150cb", + "internal/store/testhelpers/helpers.go": "e3c1a2f2b64862c2c6eda92e13762f05e981e2fbab6aa554f784e19544fe16db", + "internal/metrics/metrics.go": "f69ef99091818e61e68bb584e6dc413a90e6d2f06a0e2f42d09936edaade2aaf", + "internal/infra/migrate/migrate.go": "2b80872daa1646eecd7ade9119ba79e69693a8cbde98d99cbc8ef86ccf5e2501", + "internal/infra/migrate/sql/000001_initial_schema.up.sql": "ff1266d881b1616d6451669476323d5c6a8d075ea0194fc67772d4a8a714520d", + "internal/infra/migrate/sql/000001_initial_schema.down.sql": "fe332643b9597da1b737dc96317b36dabffde6b03c53696f81c28514bb475db1", + "internal/infra/retry/retry.go": "fa34b680037827a28f8e49292143334a88e52cb6cbacf6eb7ea5625056790cbf", + "internal/api/hub.go": "295d4de395b27a673ab04f6f0f08019b4833799d826f06be10b47b5cab7a9723", + "internal/api/middleware.go": "80f90aa736d1e481f58685a5d03f4525ba75ee0cc221da879323f0722908973c", + "internal/api/handlers.go": "4ab7af0654e8c74621363424c698b3b1f93cbb83f8eaf9eb227fcb3d4a1352d2", + "internal/api/server.go": "5011f57911b2105ccaa961b031a319386387a2df5d56a8b0bb7de04ccf70e2fc", + "internal/service/queue_coordinator.go": "84169ff051c40d8fd8e1675ddead2e7d3c9bc104672769d266d5be3f7d173a00", + "internal/service/payments_test.go": "325b0de142b3154dfadff67d56f49cad91f3cc873b867cb9018f5aa30b4c4c86", + "internal/service/claims_test.go": "a213f685a4375f8b05265eeeabb33106497b34d888118c93d45091498f6866f7", + "internal/service/queue.go": "c77ff5bf2e0cbe31b8301af62c2d2e915cac58b3801d44a0d9bff53e0f443bbc", + "internal/service/event.go": "2b7b95dd3edad587fd794c1d77abf5129ea128846ecbaf0c8839a37b84890fd3", + "internal/service/claims.go": "700bceef2cc1772a647f580d0529a62ed38f39aa85f3f12d183ed9ad699ac71a", + "internal/service/inventory_coordinator.go": "61d6e7bde486c158158b64a2d144ead792deac7ed5f9535cb76659f11a752bcc", + "internal/service/payments.go": "0c256f3b104a0cb1e5c16b058f6ebe389f85ab2cc9acc7a6c109567b6b8c508b", + "internal/service/queue_test.go": "49ed2dc2dd96df50ba594feca2edd85955ab323315d20094fb2cb615de699e40", + "internal/domain/organizer.go": "6aa634161c0e311e9dbe55068e870497eb52791ce0c215c22a3a4c4f0e92b634", + "internal/domain/payment.go": "7e06e194de550371086b34652dec4a63f7b80b86a474832e6e779175b29caced", + "internal/domain/queue.go": "76bb51d91d6684a7268f84a807991bed725c04cedb08c549c166c89f96c1f127", + "internal/domain/event_test.go": "dbd21afc97c5a71e2a5ef2931c97a1fd71ff964572f1e2abb120e5e7e3e11caa", + "internal/domain/event.go": "39079ddbd282319a6cce4d9d7bd374928f212a593edc49a750ee2394cfc252c8", + "internal/domain/errors.go": "65b082c44f58e3a5afcf1ddd4c0c39fc5d59b19e4909c0584719bcaf717fc69c", + "internal/domain/customer.go": "f880080e655c7df5a7fc0940ed5dd146aeab7f5dca9dbf822c6204ffd04ff498", + "internal/domain/claim.go": "098f6624dc92b2fc529dbbabd6f91ec72a199c7a5ed43a2dba72ff09007c25ef", + "internal/domain/payment_test.go": "0439d01a6df7f9461e28c44727d262b3fca2f612b82314fcf2b6b2e1a23e95ff", + "internal/domain/claim_test.go": "c46d073d1786f4152c856fd8bdcca1dc89456edec44a1a33e3f45b26115e4a7d", + "internal/domain/queue_test.go": "cc0a5d5addbb24b9f329ac3776c0c73988580fc623c7b1c440a5d0c04097e2fd", + "internal/worker/reconciliation.go": "e91974823323f3effe681ba2e0d2c07b735013f2b39ec036a85d786a62e63cd9", + "internal/worker/expiry.go": "c684c95cead985dde84f0151d8f89e9f8bbbb7af91def1606569c0ab8c065961", + "internal/worker/scheduler.go": "91b3cd36f732d51417134de88a5eee308d9a24adce3d2db2b2edad8550331ee8", + "internal/worker/admission.go": "fe3082d7712c8fe3701cf9c5e93e97bbe8bae6c0ffc56bff7c79d9f30aecc819", + "internal/worker/workers_test.go": "92e0f5f74895ba64e92735294187ff9146e2a4a0a124b9d16a575cbefa91fef1", + "internal/worker/recovery.go": "121bbce850501b792766920bd094e71c9efa118198017b1938fcb5310c08f850", + "internal/auth/token.go": "c16e64bbf9f057a04b5ee6560e706e8b59c0df12727e2b3cd67bf69630d6987c", + "internal/auth/auth_additions.go": "05d7e3346632e350c7a418c0a5ce891ebc20bce0bce9dcb195ae5917d80d2ceb", + "internal/auth/token_test.go": "d8d5cce50669939bfafeae5609bae5140520bf1213c6a4c82e0b7e01dc528ed9", + "internal/gateway/payment_gateway.go": "9bc02318242b987c2fc5efcaafae10aa7e5bb8247cbcfef3899c3a8eab7f77d5", + "internal/gateway/mock/gateway.go": "dd65f4c4d622d0240765c28ea0e1c3291d472772624344df544edbd883654263", + "internal/gateway/paystack/dto.go": "494a997f18fa608f8d931599314d6abb15a3e792030be95016455722bea2f6ff", + "internal/gateway/paystack/errors.go": "9ea7afe4285764eb52c667df53e0671a8ea392e67a1fe41f15a62749af56a324", + "internal/gateway/paystack/client.go": "0946c12ceb20511bc274f7430ed6a23ea5aecd73bee72a65df708452c4aa5253", + "internal/gateway/mocks/PaymentGateway.go": "bdda40678ea04156cfbeee6e1b496ea4a6751dd9ca531b47e748f3e40d264264", + "loadtest/03_spike.js": "2d8fa4ee0733bfc4f549278e855bed97e28919b4ef325fe58b682c793fe35042", + "loadtest/generate_tokens.js": "b848d15895bc6789d934b507ce2a3e48a06571f4de06c42dd552d436a6fa65ce", + "loadtest/06_polling.js": "b5e1dd933f3ea42189ad842e30f7b18a3671bfdf141200e7a89e50d83cd5685d", + "loadtest/04_breakpoint.js": "28e6e2802694a8eb65e481e19b3422275fc5a940101a501f2b11e8f80158a4e9", + "loadtest/05_contention.js": "a428f9761fbcc5f2d8f8fc259281dabcd9ba951b13f7b2790866d24185d1d26c", + "loadtest/02_sustained.js": "f8c3eadb21f5169321d5d92e0365672a7c01f8009c6f45a9762a4995f10f822e", + "loadtest/01_baseline.js": "94909be18d4ee83f8b6fcd7cea213b8d64dfe7c36fc794382234debb7cb32384", + "cmd/api/main.go": "9e03f988644138715c31091a3107b456d2d0895cb04a4c685e2bcdeaf514d540", + "cmd/loadtest/main.go": "a78ff5b5ed837607b963c8f59f633cc58396e9d6af0055794bfba8879f7b11c9" + } +} \ No newline at end of file diff --git a/README.md b/README.md index 38f433a..94bf09f 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,705 @@ # FairQueue -A virtual queue and inventory allocation system for high-demand live events in Nigeria. Built to handle the moment 50,000 people try to buy 5,000 tickets at exactly the same time — without overselling, without crashes, and without bots. +FairQueue is a robust virtual queue and inventory allocation system designed for high-demand live events in Nigeria. It's engineered to gracefully handle massive traffic spikes—like when 50,000 people try to buy 5,000 tickets at the same instant—ensuring no overselling, preventing system crashes, and effectively deterring bot abuse. -## The Problem +## Overview -When a high-demand event goes on sale, three things happen simultaneously: +This project tackles the chaos that often comes with popular online ticket sales. It gives you a reliable way to manage a huge influx of users, put them in a fair queue, and then let them claim and pay for tickets without the system buckling under pressure or accidentally selling the same ticket twice. It's built for stability and correctness, even when things get crazy. -- The website crashes under the sudden spike in traffic -- Bots grab the inventory in milliseconds before real fans get a chance -- Payment failures silently lose tickets that were already claimed +## Quick Start -FairQueue solves all three. It absorbs the traffic spike into a virtual queue, admits customers at a controlled rate, and guarantees that inventory allocation is atomic — two people can never get the same ticket. +Setting up FairQueue locally is straightforward using Docker Compose: + +1. **Clone the Repository** + ```bash + git clone https://github.com/DanielPopoola/fairqueue.git + cd fairqueue + ``` +2. **Configure Environment Variables** + Copy the example environment file and fill in your Paystack API keys. Other settings work out of the box. + ```bash + cp .env.example .env + # Open .env and replace PAYSTACK__SECRET_KEY and PAYSTACK__WEBHOOK_SECRET + ``` +3. **Build and Run with Docker Compose** + ```bash + docker compose up --build + ``` + +Once the services are up, the API will be available at `http://localhost:8080`, and the Swagger UI for API exploration will be at `http://localhost:8080/swagger/index.html`. + +## Features + +FairQueue manages the entire customer journey from joining a queue to confirming a payment, focusing on performance, fairness, and data consistency. + +### 1. Virtual Queue Management + +When an event goes on sale, customers join a waiting queue. FairQueue uses a Redis Sorted Set for fast, scalable queue operations and Postgres for durable queue entry records. A background worker then admits customers from this queue at a controlled rate, issuing them unique admission tokens. + +```mermaid +sequenceDiagram + actor Customer + participant Browser as "Browser / Mobile App" + participant API as "FairQueue API" + participant QueueSvc as "QueueService" + participant QueueCoord as "QueueCoordinator" + participant PG as "PostgreSQL" + participant Redis as "Redis" + + Browser->>API: POST /events/{id}/queue + API->>QueueSvc: Join(customerID, eventID) + QueueSvc->>QueueCoord: Join(entry) + QueueCoord->>PG: INSERT into queue_entries (WAITING) + PG-->>QueueCoord: Success + QueueCoord->>Redis: ZADD waiting:{eventID} (customerID, timestamp) + alt Redis Error (Non-critical) + Redis--XQueueCoord: Warn & Continue + end + QueueCoord-->>QueueSvc: Success + QueueSvc->>QueueCoord: GetPosition() + QueueCoord->>Redis: ZRANK waiting:{eventID} + Redis-->>QueueCoord: position (or -1) + alt Redis Miss + QueueCoord->>PG: Query position + PG-->>QueueCoord: position + end + QueueCoord-->>QueueSvc: Position (1-based) + QueueSvc-->>API: Result (entry, position) + API-->>Browser: 201 Created (QueueJoinResponse) +``` -## Quick Start +### 2. Atomic Inventory Allocation (Claiming Tickets) + +Once admitted, customers use a short-lived token to claim a ticket. This process uses a two-layer concurrency shield: a Redis `SET NX` lock for an initial, cheap check and an atomic Redis Lua script to decrement inventory. The final guarantee of correctness is a unique constraint in PostgreSQL, ensuring no overselling. + +```mermaid +flowchart TD + A[Customer sends POST /events/{id}/claims] --> B{Verify Admission Token & Ownership} + B -- Invalid / Expired --> E_AUTH[Error: 400 ADMISSION_TOKEN_EXPIRED] + B -- Valid & Owned --> C{Customer already has active claim?} + C -- Yes --> E_ALREADY_CLAIMED[Error: 409 ALREADY_CLAIMED] + C -- No --> D{Acquire Redis lock for customer:event?} + D -- Lock already held --> E_LOCK_HELD[Error: 409 ALREADY_CLAIMED] + D -- Lock Acquired --> E[Call InventoryCoordinator.AttemptDecrement] + E --> F{Redis DECRBY inventory if > 0} + F -- Result -2 (Sold Out) --> G[Release Redis Lock] + G --> E_SOLD_OUT[Error: 410 EVENT_SOLD_OUT] + F -- Result -1 (Cache Miss) --> H{Fallback: Count active claims from Postgres} + H --> I{If Postgres Count <= 0} + I -- Yes --> J[Release Redis Lock] + J --> E_SOLD_OUT + I -- No --> K[Force-sync Redis inventory with Postgres] + K --> L[Retry Redis DECRBY] + L --> F_CONT[Continue to F] + F -- Result >= 0 (Success) --> M[Insert Claim into Postgres] + M -- Unique Constraint Violation --> N[Rollback Redis Decrement] + N --> P[Release Redis Lock] + P --> E_ALREADY_CLAIMED_PG[Error: 409 ALREADY_CLAIMED] + M -- Success --> Q[Mark Customer Queue Entry COMPLETED] + Q --> R[Release Redis Lock] + R --> S{Is new Redis inventory count <= 0?} + S -- Yes --> T[Mark Event SOLD_OUT in Postgres] + S -- No --> U[Return 201 Created] + T --> U +``` -```bash -git clone https://github.com/DanielPopoola/fairqueue -cd fairqueue -cp .env.example .env # fill in your Paystack keys; everything else works out of the box -docker compose up --build +### 3. Resilient Payment Processing (Outbox Pattern) + +The payment flow implements an "outbox pattern" to ensure data consistency even in the face of system failures. A payment record is first created in a `INITIALIZING` state in PostgreSQL before any external payment gateway (e.g., Paystack) is called. This guarantees that no payment is lost or unrecorded. A reconciliation worker constantly monitors for stale payments and heals them. + +```mermaid +sequenceDiagram + actor Customer + participant Browser as "Browser / Mobile App" + participant API as "FairQueue API" + participant PaymentSvc as "PaymentService" + participant PaymentStore as "Postgres PaymentStore" + participant Paystack as "Paystack Gateway" + participant ClaimStore as "Postgres ClaimStore" + participant Inventory as "InventoryCoordinator" + + Customer->>Browser: Click Pay + Browser->>API: POST /claims/{id}/payments + API->>PaymentSvc: Initialize(claimID, customerID) + + PaymentSvc->>PaymentStore: GetByClaimID(claimID) + alt Existing Payment Found + PaymentStore-->>PaymentSvc: Existing Payment + PaymentSvc-->>API: 201 (Existing Auth URL) + API-->>Browser: + return + end + + PaymentSvc->>PaymentStore: CREATE Payment (INITIALIZING) + PaymentStore-->>PaymentSvc: Payment Record (ID) + PaymentSvc->>Paystack: InitializeTransaction(customerEmail, amount, ref) + alt Transient Paystack Error (e.g., Timeout) + Paystack--XPaymentSvc: Error (transient) + PaymentSvc--XAPI: Error (transient) + API--XBrowser: 400 Bad Request + note right of Paystack: Payment remains INITIALIZING in DB, Worker will retry + return + end + alt Permanent Paystack Error (e.g., Invalid Card) + Paystack--XPaymentSvc: Error (permanent) + PaymentSvc->>PaymentStore: Mark Payment FAILED + PaymentStore-->>PaymentSvc: Success + PaymentSvc->>ClaimStore: Update Claim (RELEASED) + ClaimStore-->>PaymentSvc: Success + PaymentSvc->>Inventory: Increment(eventID) + Inventory-->>PaymentSvc: Success (best effort) + PaymentSvc--XAPI: Error (permanent) + API--XBrowser: 400 Bad Request + return + end + Paystack-->>PaymentSvc: Success (Auth URL, Reference) + PaymentSvc->>PaymentStore: Mark Payment PENDING (with Auth URL) + PaymentStore-->>PaymentSvc: Success + PaymentSvc-->>API: 201 (Payment ID, Auth URL) + API-->>Browser: + Browser->>Customer: Redirect to Auth URL for payment + activate Paystack + Paystack->>Paystack: Customer completes payment + deactivate Paystack + Paystack->>API: POST /webhooks/paystack (charge.success/failed) + API->>PaymentSvc: HandleWebhook(payload, signature) + PaymentSvc->>PaymentSvc: (Async) processWebhook + activate PaymentSvc + PaymentSvc->>PaymentStore: GetByReference(reference) + PaymentStore-->>PaymentSvc: Payment Record + alt charge.success + PaymentSvc->>PaymentStore: Update Payment (CONFIRMED) + PaymentStore-->>PaymentSvc: Success (idempotent) + PaymentSvc->>ClaimStore: Update Claim (CONFIRMED) + ClaimStore-->>PaymentSvc: Success (idempotent) + else charge.failed + PaymentSvc->>PaymentStore: Mark Payment FAILED + PaymentStore-->>PaymentSvc: Success (idempotent) + PaymentSvc->>ClaimStore: Update Claim (RELEASED) + ClaimStore-->>PaymentSvc: Success (idempotent) + PaymentSvc->>Inventory: Increment(eventID) + Inventory-->>PaymentSvc: Success (best effort) + end + deactivate PaymentSvc + API-->>Paystack: 200 OK +``` + +### 4. Background Workers & Recovery + +FairQueue includes several background workers for critical operations: + +* **Admission Worker**: Periodically moves customers from the waiting queue to the admitted queue. +* **Expiry Worker**: Releases claims that expire without payment and purges stale queue entries. +* **Reconciliation Worker**: Ensures consistency between Redis and PostgreSQL, correcting any divergences in inventory counts and payment statuses. +* **Startup Recovery**: Rebuilds Redis state from PostgreSQL on application startup, ensuring a quick and accurate recovery after a Redis wipe or service restart. + +For a deep dive into the design rationale and trade-offs behind these decisions, check out [TRADEOFFS.md](TRADEOFFS.md). + +## System Architecture / Design + +FairQueue follows a layered architecture, with clear separation of concerns between domain, storage, services, workers, and API layers. PostgreSQL is the single source of truth for all authoritative state, while Redis serves as a high-performance, reconstructible cache and queuing layer. + +```mermaid +graph TD + subgraph Clients + Browser["Browser / Mobile App"] + end + + subgraph API["API Layer (chi router)"] + Handlers["HTTP Handlers"] + Middleware["Auth Middleware
Organizer JWT · Customer JWT"] + Hub["WebSocket Hub
live position updates"] + end + + subgraph Services + EventSvc["EventService
create · activate · end"] + QueueSvc["QueueService
join · position · abandon"] + ClaimSvc["ClaimService
claim · release"] + PaymentSvc["PaymentService
initialize · webhook · reconcile"] + end + + subgraph Coordinators["Service Coordinators"] + QueueCoord["QueueCoordinator
Postgres + Redis queue ops"] + InvCoord["InventoryCoordinator
lock + decrement + rollback"] + end + + subgraph Workers["Background Workers (Scheduler)"] + AdmWorker["Admission Worker
every 5s — admit next batch"] + ExpWorker["Expiry Worker
every 30s — release stale claims"] + RecWorker["Reconciliation Worker
every 30s — heal Redis divergence"] + Recovery["Startup Recovery
once at boot — rebuild Redis from PG"] + end + + subgraph Storage + PG[("PostgreSQL
source of truth")] + RD[("Redis
performance layer only")] + end + + Paystack["Paystack Gateway
HTTP + webhook"] + + Browser -- "HTTP REST" --> Handlers + Browser -- "WebSocket ?token=" --> Hub + Handlers --> Middleware + Handlers --> EventSvc + Handlers --> QueueSvc + Handlers --> ClaimSvc + Handlers --> PaymentSvc + + QueueSvc --> QueueCoord + ClaimSvc --> QueueCoord + ClaimSvc --> InvCoord + QueueCoord --> PG + QueueCoord --> RD + InvCoord --> RD + + EventSvc --> PG + PaymentSvc --> PG + PaymentSvc --> Paystack + PaymentSvc --> InvCoord + + AdmWorker --> QueueCoord + AdmWorker --> InvCoord + AdmWorker -- "push admission token" --> Hub + ExpWorker --> PG + ExpWorker --> InvCoord + RecWorker --> PG + RecWorker --> InvCoord + Recovery --> PG + Recovery --> RD +``` + +## Technologies Used + +| Layer / Aspect | Technology | Description | +| :------------------ | :--------------- | :---------------------------------------------------------- | +| **Language** | Go | Primary programming language for performance and concurrency. | +| **Database** | PostgreSQL 16 | Relational database, serving as the single source of truth. | +| **Cache / Queue** | Redis 7 | In-memory data store for high-speed caching and virtual queuing. | +| **Payment Gateway** | Paystack | External payment processing integration. | +| **HTTP Router** | Chi | Lightweight, idiomatic HTTP router for Go. | +| **WebSockets** | coder/websocket | Library for real-time bidirectional communication. | +| **Containerization**| Docker, Compose | For local development, testing, and deployment. | +| **Metrics** | Prometheus | For collecting and exposing application metrics. | +| **API Docs** | Swaggo | Auto-generates Swagger/OpenAPI documentation. | +| **Testing** | Testcontainers | Spawning real database/cache instances for integration tests. | +| **Auth** | Argon2id, JWT | Secure password hashing and token-based authentication. | +| **Logging** | `log/slog` | Structured logging for observability. | +| **Configuration** | Koanf | Flexible configuration management. | + +## API Documentation + +FairQueue exposes a RESTful API for organizers to manage events and for customers to join queues, claim tickets, and make payments. WebSocket endpoints provide real-time updates for queue positions. + +**Base URL**: `http://localhost:8080` + +### Authentication + +* **OrganizerAuth**: JWT issued on organizer login. Pass as `Authorization: Bearer {token}` header. +* **CustomerAuth**: JWT issued on OTP verification. Pass as `Authorization: Bearer {token}` header for HTTP requests, or as `?token=` query parameter for WebSocket connections. + +### Health Check + +#### GET /health +**Description**: Returns the current health status of the API and its dependencies (Postgres, Redis). + +**Response**: +```json +{ + "status": "healthy", + "postgres": "ok", + "redis": "ok" +} +``` + +**Errors**: +- 503: Service Unavailable (if any dependency is down) + +### Auth Endpoints + +#### POST /auth/organizer/login +**Description**: Authenticates an organizer with email and password, returning a JWT. + +**Request**: +```json +{ + "email": "organizer@example.com", + "password": "supersecret" +} +``` + +**Response**: +```json +{ + "success": true, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "organizer_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef" +} +``` + +**Errors**: +- 401: UNAUTHORIZED / INVALID_CREDENTIALS (email or password incorrect) +- 400: INVALID_INPUT (invalid request body) + +#### POST /auth/customer/otp/request +**Description**: Sends a 6-digit One-Time Password (OTP) to the customer's email. Creates a customer account if one doesn't exist. (Note: OTP is currently logged to console in development, not actually emailed.) + +**Request**: +```json +{ + "email": "customer@example.com" +} +``` + +**Response**: +```json +{ + "success": true, + "message": "OTP sent to your email" +} +``` + +**Errors**: +- 400: INVALID_INPUT (invalid email format) + +#### POST /auth/customer/otp/verify +**Description**: Validates the provided OTP for a customer's email, returning a customer JWT upon success. + +**Request**: +```json +{ + "email": "customer@example.com", + "otp": "482910" +} +``` + +**Response**: +```json +{ + "success": true, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "customer_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef" +} +``` + +**Errors**: +- 401: UNAUTHORIZED / INVALID_OTP (OTP is invalid or expired) +- 400: INVALID_INPUT (invalid request body) + +### Event Endpoints + +#### POST /events +**Description**: Creates a new event in `DRAFT` status. Requires `OrganizerAuth`. + +**Authentication**: `OrganizerAuth` + +**Request**: +```json +{ + "name": "Burna Boy Live in Lagos", + "total_inventory": 5000, + "price": 25000, + "sale_start": "2024-08-01T10:00:00Z", + "sale_end": "2024-08-01T22:00:00Z" +} ``` -API is available at `http://localhost:8080`. Swagger UI is at `http://localhost:8080/swagger/index.html`. +**Response**: +```json +{ + "success": true, + "data": { + "id": "e1a2b3c4-d5e6-7890-1234-567890abcdef", + "organizer_id": "o1a2b3c4-d5e6-7890-1234-567890abcdef", + "name": "Burna Boy Live in Lagos", + "total_inventory": 5000, + "price": 25000, + "status": "DRAFT", + "sale_start": "2024-08-01T10:00:00Z", + "sale_end": "2024-08-01T22:00:00Z", + "created_at": "2024-07-28T14:30:00Z", + "updated_at": "2024-07-28T14:30:00Z" + } +} +``` + +**Errors**: +- 401: UNAUTHORIZED +- 400: INVALID_INPUT (invalid event details, e.g., zero inventory, invalid dates) + +#### GET /events/{eventId} +**Description**: Retrieves details for a specific event by its ID. Publicly accessible. + +**Response**: +```json +{ + "success": true, + "data": { + "id": "e1a2b3c4-d5e6-7890-1234-567890abcdef", + "organizer_id": "o1a2b3c4-d5e6-7890-1234-567890abcdef", + "name": "Burna Boy Live in Lagos", + "total_inventory": 5000, + "price": 25000, + "status": "DRAFT", + "sale_start": "2024-08-01T10:00:00Z", + "sale_end": "2024-08-01T22:00:00Z", + "created_at": "2024-07-28T14:30:00Z", + "updated_at": "2024-07-28T14:30:00Z" + } +} +``` + +**Errors**: +- 404: NOT_FOUND (event not found) +- 400: INVALID_INPUT (invalid eventId format) + +#### PUT /events/{eventId}/activate +**Description**: Transitions an event from `DRAFT` to `ACTIVE` status, making it available for queueing and claims. Requires `OrganizerAuth`. + +**Authentication**: `OrganizerAuth` + +**Response**: (Same as GET /events/{eventId} but with `status: "ACTIVE"`) + +**Errors**: +- 401: UNAUTHORIZED +- 403: FORBIDDEN (organizer does not own this event) +- 404: NOT_FOUND (event not found) +- 400: INVALID_TRANSITION (event is not in `DRAFT` status) + +#### PUT /events/{eventId}/end +**Description**: Transitions an event from `ACTIVE` or `SOLD_OUT` to `ENDED` status. Requires `OrganizerAuth`. + +**Authentication**: `OrganizerAuth` + +**Response**: (Same as GET /events/{eventId} but with `status: "ENDED"`) -## How It Works +**Errors**: +- 401: UNAUTHORIZED +- 403: FORBIDDEN (organizer does not own this event) +- 404: NOT_FOUND (event not found) +- 400: INVALID_TRANSITION (event is not in `ACTIVE` or `SOLD_OUT` status) -A customer's journey through the system has four stages: +### Queue Endpoints -**1. Queue** — When the sale opens, everyone hits `POST /events/{id}/queue` at once. This is a cheap Redis write (O(log N)), so it absorbs any traffic volume without touching the database. Each customer gets a queue position. +#### POST /events/{eventId}/queue +**Description**: Adds the authenticated customer to the waiting queue for an event. Requires `CustomerAuth`. -**2. Admission** — A background worker runs every 5 seconds. It pops the next batch of customers from the waiting queue, moves them to the admitted set, and pushes them a signed admission token via WebSocket. Customers who miss the push can poll `GET /events/{id}/queue/position` to retrieve their token. +**Authentication**: `CustomerAuth` -**3. Claim** — An admitted customer presents their token to `POST /events/{id}/claims`. The system atomically checks and decrements the Redis inventory counter. If that succeeds, it inserts a claim row in Postgres. A unique constraint on `(event_id, customer_id)` is the last line of defence against any race condition. +**Response**: +```json +{ + "success": true, + "queue_entry_id": "q1a2b3c4-d5e6-7890-1234-567890abcdef", + "event_id": "e1a2b3c4-d5e6-7890-1234-567890abcdef", + "position": 1547 +} +``` + +**Errors**: +- 401: UNAUTHORIZED +- 404: NOT_FOUND (event not found or not active) +- 409: ALREADY_IN_QUEUE (customer is already in the queue) + +#### GET /events/{eventId}/queue/position +**Description**: Returns the authenticated customer's current position in the queue. If the customer has been admitted, it returns position `0` and an `admission_token`. Requires `CustomerAuth`. -**4. Payment** — The customer calls `POST /claims/{id}/payments`, which writes a payment record to Postgres *before* calling Paystack. This means a crash can never produce a charge with no record. The reconciliation worker finds and heals any payments stuck in intermediate states. +**Authentication**: `CustomerAuth` -## Key Design Decisions +**Response (Waiting)**: +```json +{ + "success": true, + "position": 847, + "status": "WAITING" +} +``` + +**Response (Admitted)**: +```json +{ + "success": true, + "position": 0, + "status": "ADMITTED", + "admission_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` -**PostgreSQL is the only source of truth.** Redis holds nothing that cannot be reconstructed from Postgres. If Redis is wiped, the startup recovery function rebuilds the queue and the reconciliation worker heals the inventory count. Redis makes things fast; Postgres makes things correct. +**Errors**: +- 401: UNAUTHORIZED +- 404: NOT_FOUND (customer is not in the queue for this event) -**Two-layer concurrency shield.** A Redis `SET NX` lock stops concurrent claim attempts before they reach the database. A Postgres unique constraint on `(event_id, customer_id)` is the inviolable correctness guarantee that holds even if the lock is unavailable. Both layers must fail for an oversell to occur. +#### DELETE /events/{eventId}/queue +**Description**: Removes the authenticated customer from the waiting queue. Only `WAITING` entries can be abandoned. Requires `CustomerAuth`. -**Outbox pattern for payment safety.** A `Payment` row is always written in `INITIALIZING` state before the Paystack API is called. A crash at any point leaves a recoverable record. The reconciliation worker finds stale `INITIALIZING` records and retries the gateway call. +**Authentication**: `CustomerAuth` -**Postgres-first writes.** The Redis inventory counter is only decremented *after* the Postgres insert commits. If the server crashes between the commit and the Redis write, Redis shows more tickets than exist — the reconciliation worker heals this within 30 seconds. The alternative (Redis first) risks permanently locking out valid users. Temporary inflation is the only acceptable failure mode. +**Response**: +`204 No Content` -For the full reasoning behind every decision, see [TRADEOFFS.md](./TRADEOFFS.md). +**Errors**: +- 401: UNAUTHORIZED +- 404: NOT_FOUND (queue entry not found) +- 400: INVALID_TRANSITION (admitted customers cannot abandon the queue) -## Architecture +### Claim Endpoints -See [ARCHITECTURE.md](./ARCHITECTURE.md) for a component diagram showing how all the pieces fit together. +#### POST /events/{eventId}/claims +**Description**: Allows an admitted customer to claim a ticket using their `admission_token`. The customer then has a limited time to complete payment. Requires `CustomerAuth`. -## Project Structure +**Authentication**: `CustomerAuth` +**Request**: +```json +{ + "admission_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} ``` -cmd/api/ Entry point and dependency wiring -internal/ - domain/ State machines and domain errors — no infrastructure dependencies - service/ Business logic: claims, queue, payments, events - store/ - postgres/ PostgreSQL store implementations - redis/ Redis store implementations (inventory, queue, lock) - worker/ Background workers: admission, expiry, reconciliation, recovery - api/ HTTP handlers, middleware, WebSocket hub - gateway/paystack/ Paystack payment gateway adapter - auth/ JWT tokenizers (organizer + customer), argon2id password hashing - config/ Environment-based configuration loading and validation - infra/ - migrate/ Embedded SQL migrations - retry/ Generic retry with exponential backoff + +**Response**: +```json +{ + "success": true, + "claim_id": "c1a2b3c4-d5e6-7890-1234-567890abcdef", + "event_id": "e1a2b3c4-d5e6-7890-1234-567890abcdef", + "expires_at": "2024-07-28T14:40:00Z" +} ``` +**Errors**: +- 401: UNAUTHORIZED +- 400: INVALID_INPUT (invalid eventId or admission token) +- 409: ALREADY_CLAIMED (customer already has an active claim) +- 410: EVENT_SOLD_OUT (event is sold out) +- 400: ADMISSION_TOKEN_EXPIRED (token is expired or invalid for this event/customer) + +#### DELETE /claims/{claimId} +**Description**: Explicitly releases an active claim before its TTL expires, returning the ticket to available inventory. Requires `CustomerAuth`. + +**Authentication**: `CustomerAuth` + +**Response**: +`204 No Content` + +**Errors**: +- 401: UNAUTHORIZED +- 403: FORBIDDEN (customer does not own this claim) +- 404: NOT_FOUND (claim not found) +- 409: INVALID_TRANSITION (claim is not in a releaseable state) + +### Payment Endpoints + +#### POST /claims/{claimId}/payments +**Description**: Initializes a payment transaction for a given claim. This endpoint is idempotent and will return the existing payment details if called multiple times for the same claim. Requires `CustomerAuth`. + +**Authentication**: `CustomerAuth` + +**Response**: +```json +{ + "success": true, + "payment_id": "p1a2b3c4-d5e6-7890-1234-567890abcdef", + "authorization_url": "https://paystack.co/pay/someref123", + "reference": "fq-uuid-reference" +} +``` + +**Errors**: +- 401: UNAUTHORIZED +- 403: FORBIDDEN (customer does not own this claim) +- 404: NOT_FOUND (claim not found) +- 400: INVALID_INPUT (e.g., claim expired or not in claimable state) + +#### POST /webhooks/paystack +**Description**: Receives `charge.success` and `charge.failed` webhook events from Paystack to update payment and claim statuses. No explicit authentication header for this endpoint (HMAC signature in `x-paystack-signature` is verified internally). + +**Request**: +```json +{ + "event": "charge.success", + "data": { + "reference": "fq-uuid-reference", + "status": "success", + "gateway_response": "Approved" + } +} +``` + +**Response**: +`200 OK` + +**Errors**: +- 400: INVALID_INPUT (invalid payload or signature) + +### Real-time WebSockets + +#### GET /ws/queue/{eventId} +**Description**: Establishes a WebSocket connection for real-time queue position updates and admission notifications. Authentication is via a `CustomerAuth` JWT passed as a query parameter. + +**Authentication**: `CustomerAuth` (via `?token=` query parameter) + +**Example messages from server**: +```json +{ "type": "position", "position": 847 } +{ "type": "admitted", "admission_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } +``` + +**Errors**: +- 401: UNAUTHORIZED (invalid or missing JWT) + +### Metrics + +#### GET /metrics +**Description**: Exposes Prometheus-compatible metrics for monitoring the application's performance. + +**Response**: (Prometheus text format) + +### Environment Variables + +The following environment variables are used to configure the application: + +| Variable | Description | Example Value | +| :--------------------------- | :----------------------------------------------------- | :----------------------------------- | +| `ENV` | Application environment (development, production) | `development` | +| `SERVER__PORT` | Port the HTTP server listens on | `8080` | +| `SERVER__READ_TIMEOUT` | Server read timeout | `15s` | +| `SERVER__WRITE_TIMEOUT` | Server write timeout | `15s` | +| `SERVER__IDLE_TIMEOUT` | Server idle timeout | `60s` | +| `DATABASE__HOST` | PostgreSQL host | `localhost` | +| `DATABASE__PORT` | PostgreSQL port | `5432` | +| `DATABASE__USER` | PostgreSQL user | `fairqueue` | +| `DATABASE__PASSWORD` | PostgreSQL password | `fairqueue` | +| `DATABASE__NAME` | PostgreSQL database name | `fairqueue` | +| `DATABASE__SSL_MODE` | PostgreSQL SSL mode | `disable` | +| `DATABASE__MAX_OPEN_CONNS` | Max open database connections | `25` | +| `DATABASE__MAX_IDLE_CONNS` | Max idle database connections | `5` | +| `DATABASE__CONN_MAX_LIFETIME`| Max connection lifetime | `15m` | +| `DATABASE__CONN_MAX_IDLE_TIME`| Max connection idle time | `5m` | +| `REDIS__HOST` | Redis host | `localhost` | +| `REDIS__PORT` | Redis port | `6379` | +| `REDIS__PASSWORD` | Redis password (if any) | ` ` | +| `REDIS__DB` | Redis database index | `0` | +| `AUTH__TOKEN_SECRET` | Secret key for JWTs (at least 32 chars) | `replace-this-with-a-random-secret-at-least-32-chars` | +| `AUTH__TOKEN_TTL` | TTL for customer admission tokens | `5m` | +| `PAYSTACK__SECRET_KEY` | Paystack secret key (`sk_test_...`) | `sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `PAYSTACK__WEBHOOK_SECRET` | Paystack webhook secret (for HMAC verification) | `replace-with-your-paystack-webhook-secret` | +| `PAYSTACK__BASE_URL` | Paystack API base URL | `https://api.paystack.co` | +| `GATEWAYRETRY__MAX_ATTEMPTS` | Max retry attempts for gateway calls | `3` | +| `GATEWAYRETRY__BASE_DELAY` | Base delay for exponential backoff | `500ms` | +| `GATEWAYRETRY__MAX_DELAY` | Max delay for exponential backoff | `5s` | +| `WORKERS__ADMISSION__INTERVAL`| Admission worker run interval | `5s` | +| `WORKERS__ADMISSION__BATCH_SIZE`| Number of customers to admit per batch | `50` | +| `WORKERS__EXPIRY__INTERVAL` | Expiry worker run interval | `30s` | +| `WORKERS__EXPIRY__BATCH_SIZE`| Batch size for expiring claims/queue entries | `100` | +| `WORKERS__RECONCILIATION__INTERVAL`| Reconciliation worker run interval | `30s` | +| `WORKERS__RECONCILIATION__STALE_PAYMENT_AGE`| Age at which a payment is considered stale | `10m` | +| `WORKERS__RECONCILIATION__STALE_QUEUE_ENTRY_AGE`| Age at which a queue entry is considered stale | `2h` | +| `LOGGER__LEVEL` | Logging level (debug, info, warn, error) | `info` | + + ## Running Tests +FairQueue uses a comprehensive testing strategy including unit, integration, and end-to-end tests. + ```bash # Domain logic only — fast, no infrastructure required make test-unit @@ -87,16 +714,16 @@ make test-e2e make test ``` -Integration tests run against real infrastructure. The only mock in the codebase is the Paystack gateway — because mocking the database tells you nothing about whether the unique constraint fires. +Integration tests leverage `testcontainers-go` to spin up actual PostgreSQL and Redis instances, ensuring that components interact correctly with real infrastructure rather than mocks. The only mock in the codebase is for the external Paystack payment gateway, which eliminates unreliable external HTTP calls from the test suite. + +## License + +This project is open-source. + +## Author Info -## Stack +**Daniel Popoola** +* [LinkedIn](https://www.linkedin.com/in/daniel-popoola-942aa8216/) +* [X (Twitter)](https://x.com/iamuchihadan) -| Layer | Technology | -|---|---| -| Language | Go | -| Database | PostgreSQL 16 | -| Cache / Queue | Redis 7 | -| Payment | Paystack | -| HTTP router | Chi | -| WebSocket | coder/websocket | -| Container | Docker + Compose | \ No newline at end of file +[![Readme was generated by Dokugen](https://img.shields.io/badge/Readme%20was%20generated%20by-Dokugen-brightgreen)](https://www.npmjs.com/package/dokugen) \ No newline at end of file diff --git a/internal/service/payments.go b/internal/service/payments.go index dfba981..4067200 100644 --- a/internal/service/payments.go +++ b/internal/service/payments.go @@ -234,7 +234,6 @@ func (s *PaymentService) failureFlow(ctx context.Context, p *domain.Payment, rea return err } -// failureFlow atomically marks a payment failed and releases the related claim. func (s *PaymentService) fetchInitData(ctx context.Context, claimID, custID string) (*domain.Claim, *domain.Customer, *domain.Event, error) { claim, err := s.claims.GetByID(ctx, claimID) if err != nil { @@ -268,7 +267,6 @@ func (s *PaymentService) validateClaimForPayment(c *domain.Claim, customerID str return nil } -// validateClaimForPayment enforces claim ownership, freshness, and status checks. func (s *PaymentService) handleGatewayInitError(ctx context.Context, p *domain.Payment, err error, log *slog.Logger) error { if errors.Is(err, paystack.ErrPermanent) { if ferr := s.failureFlow(ctx, p, err.Error(), domain.PaymentStatusInitializing); ferr != nil { diff --git a/internal/service/queue.go b/internal/service/queue.go index e7d6f4f..bb79053 100644 --- a/internal/service/queue.go +++ b/internal/service/queue.go @@ -102,8 +102,6 @@ func (s *QueueService) GetAdmittedEntry(ctx context.Context, customerID, eventID } // Abandon removes a customer from the waiting queue. -// Only WAITING entries can be abandoned — ADMITTED entries -// can only move to EXPIRED via the eviction worker. func (s *QueueService) Abandon(ctx context.Context, customerID, eventID string) error { entry, err := s.queue.pgQueue.GetByCustomerAndEvent(ctx, customerID, eventID) if err != nil { diff --git a/internal/service/queue_coordinator.go b/internal/service/queue_coordinator.go index d8ee7fe..53c6836 100644 --- a/internal/service/queue_coordinator.go +++ b/internal/service/queue_coordinator.go @@ -45,8 +45,6 @@ func (c *QueueCoordinator) Join(ctx context.Context, entry *domain.QueueEntry) e // Redis second — fast position tracking if err := c.redisQueue.Join(ctx, entry.EventID, entry.CustomerID, joinedAt); err != nil { - // Non-fatal — position tracking is best effort. - // Customer is in the queue per Postgres. c.logger.Warn("failed to add customer to redis queue", "customer_id", entry.CustomerID, "event_id", entry.EventID,