Skip to content

fix(rewards): replace in-memory stores with Prisma persistence - #15

Merged
merlik787-droi merged 1 commit into
Kqirox:mainfrom
DeEvelyn:fix/issue-5-persist-reward-service
Aug 19, 2026
Merged

fix(rewards): replace in-memory stores with Prisma persistence#15
merlik787-droi merged 1 commit into
Kqirox:mainfrom
DeEvelyn:fix/issue-5-persist-reward-service

Conversation

@DeEvelyn

Copy link
Copy Markdown

Summary

Closes #5

Replaces all four module-level in-memory stores in RewardService with durable Prisma reads and writes. RewardService is now completely stateless — constructing a new instance in any process sees the same persisted data. The most important design decision is the outbox pattern: a pending Transaction row is persisted to Postgres before calling the Stellar network, so a crash between payment submission and row commit leaves a recoverable pending record rather than lost state.

Why

The codebase contained four module-level mutable stores:

const claimedRewards = new Map<string, Set<string>>()
const transactions: Transaction[] = []
const referralCodes = new Map<string, string>()
const pendingWithdrawals = new Map<string, WithdrawalRequest>()

Every process restart discarded all reward history. Two replicas serving the same user could both pass the assertNotAlreadyClaimed check (a Map lookup in each process) and both trigger Stellar payouts for the same module. The module-completion path already wrote durable prisma.transaction rows, but the rewards HTTP surface read only from the in-memory store — these were two completely separate ledgers that never converged.

What was built

File What it contains
src/services/reward.service.ts Fully stateless Prisma-backed service; outbox pattern for claimReward and processWithdrawal; _resetState() removed
src/controllers/reward.controller.ts await added to getBalance, getTransactionHistory, hasSufficientBalance (now async)
prisma/schema.prisma Transaction gains moduleId, stellarTxHash, completedAt; new RewardClaim model with @@unique([userId, moduleId])
prisma/migrations/20260819000002_persist_reward_service/migration.sql ALTER TABLE transactions ADD COLUMN + CREATE TABLE reward_claims with unique constraint
tests/unit/reward.service.test.ts Fully rewritten; 32 tests covering outbox ordering, P2002 double-claim, Stellar failure, balance derivation, pagination
tests/unit/reward.controller.test.ts mockReturnValuemockResolvedValue for async methods; async flush added for asyncHandler timing
docs/ROADMAP.md In-memory-store item marked complete

Integration changes outside module

  • src/controllers/reward.controller.ts — three synchronous service calls changed to await; unavoidable because the service API is now async.
  • prisma/schema.prisma — schema extended; requires pnpm db:migrate on deploy.

Acceptance criteria coverage

  • A reward claimed via claimReward is visible in getTransactionHistory and getBalance after a fresh RewardService instance is constructed in the same process (tests/unit/reward.service.test.tsgetBalance and getTransactionHistory suites read from mocked Prisma rows written by claimReward)
  • Two concurrent claimReward calls for the same (userId, moduleId) produce exactly one payout and one recorded transaction — enforced at the database level by @@unique([userId, moduleId]) on RewardClaim; P2002 path tested in "claimReward – double-claim prevention" suite
  • Transaction state survives a process restart — verified by architecture: all reads go to Prisma, no module-level state; any new RewardService instance reads the same rows
  • processWithdrawal records the transaction as pending before submitting the Stellar payment, and flips it to completed or failed with the stellarTxHash (tests/unit/reward.service.test.ts — "creates a pending Transaction before calling Stellar" and "flips status to completed with stellarTxHash" tests)
  • Unit tests no longer depend on module-level singleton state; they use a fully mocked Prisma client (tests/unit/reward.service.test.ts uses vi.mock('../../src/config/database', ...); vi.clearAllMocks() in beforeEach is sufficient isolation)
  • _resetState() test hook is removed (src/services/reward.service.ts — method deleted entirely)
  • docs/ROADMAP.md marks the in-memory-store item as complete

Deliberately deferred

  • Background settlement job: the outbox pattern leaves a pending row recoverable by a reconciliation job, but the scheduler/worker loop is out of scope for this issue.
  • Referral code storage: registerReferralCode was removed because the project already has ReferralCode / Referral Prisma models managed by referral.controller.ts. Wiring rewards to that table is a follow-on.
  • Stellar payout for referrers: the skipped referral bonus path is unchanged; it requires a walletAddress lookup mechanism that doesn't yet exist.

Test plan

  • pnpm test:ci279/279 passing (32 new tests in reward.service.test.ts)
  • pnpm build (tsc) — no type errors
  • pnpm lint — no new errors or warnings
  • DATABASE_URL=... npx prisma generate — Prisma client generated with new RewardClaim model and extended Transaction

Env vars / Notes

No new env vars. Migration must be applied before deploy:

pnpm db:migrate

The migration adds columns with IF NOT EXISTS and creates a new table — safe to apply to a live database. Existing transaction rows retain their data; the new columns default to NULL.

Replaces the four module-level in-memory stores (claimedRewards,
transactions, referralCodes, pendingWithdrawals) in RewardService
with durable Prisma reads and writes. RewardService is now stateless —
constructing a new instance in any process sees the same data.

Schema changes (migration 20260819000002_persist_reward_service):
- Transaction: add moduleId (String?), stellarTxHash (String?),
  completedAt (DateTime?) to hold the full reward ledger durably
- RewardClaim: new model with @@unique([userId, moduleId]) providing
  a database-level double-claim guard across replicas

Service changes:
- claimReward: atomic RewardClaim.create (P2002 → already-claimed
  error), pending Transaction row persisted BEFORE Stellar call
  (outbox pattern), status flipped to completed/failed after payout
- processWithdrawal: same outbox pattern — pending row first, then
  Stellar, then completed/failed with stellarTxHash
- getBalance, getTransactionHistory: derived from Prisma rows
- hasAlreadyClaimed: reads RewardClaim table (async)
- _resetState() test hook: removed entirely
- registerReferralCode: removed (referral codes are tracked via
  the Referral/ReferralCode models managed by referral.controller)

Controller changes:
- reward.controller.ts: await the now-async getBalance,
  getTransactionHistory, hasSufficientBalance service methods

Test changes:
- reward.service.test.ts: fully rewritten to mock Prisma client;
  covers outbox ordering, P2002 double-claim, Stellar failure path,
  balance derivation, pagination, and withdrawal patterns
- reward.controller.test.ts: mockReturnValue → mockResolvedValue for
  async service methods; async flush added where asyncHandler's
  floating promise needs draining

Documentation:
- docs/ROADMAP.md: marks in-memory-store replacement item complete

Closes Kqirox#5

@merlik787-droi merlik787-droi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@merlik787-droi
merlik787-droi merged commit f9630c3 into Kqirox:main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RewardService keeps rewards in process memory: balances reset on restart and drift across replicas

2 participants