Skip to content

fix(contributions): atomically decrement reward tier claimed_count on… - #626

Merged
dotunv merged 2 commits into
Savitura:mainfrom
innocentharuna:fix/issue-397-tier-remaining-decrement
Jul 28, 2026
Merged

fix(contributions): atomically decrement reward tier claimed_count on…#626
dotunv merged 2 commits into
Savitura:mainfrom
innocentharuna:fix/issue-397-tier-remaining-decrement

Conversation

@innocentharuna

Copy link
Copy Markdown
Contributor

Description

Closes #397
Reward tier claimed_count is now atomically decremented when a tier-backed contribution is made, preventing overselling of limited-edition reward tiers.

The Problem
The claimed_count (and hence computed remaining) on reward_tiers was only incremented asynchronously in the ledger monitor when Horizon detected the on-chain payment. The contribution route itself never decremented the count, so:

  • Sold-out tiers continued showing availability in the UI
  • Multiple backers could claim the last slot of a limited tier
  • Creators could end up owing more physical rewards than they could fulfil

The Fix
A new reserveTierSlot() function in rewardTierService.js atomically increments claimed_count inside the contribution route's database transaction — before the Stellar transaction is even submitted. If the tier is already at capacity, the contribution is rejected with HTTP 409 Sold out and the entire transaction is rolled back.

Changes

backend/src/services/rewardTierService.js

  • reserveTierSlot(client, { tierId, campaignId }) — Atomically increments claimed_count via UPDATE ... WHERE (tier_limit IS NULL OR claimed_count < tier_limit) RETURNING. Returns the tier row on success, or null if sold out.
  • assignTierToContribution(client, { ..., tierId }) — Extended to accept an optional tierId. When provided, only creates the contribution_rewards join row (idempotent via ON CONFLICT DO NOTHING) without double-incrementing claimed_count, since the route already reserved the slot.

backend/src/routes/contributions.js

  • Custodial POST / route now extracts optional tier_id from req.body
  • Validates the tier belongs to the campaign (pre-flight SELECT)
  • Calls reserveTierSlot() inside the existing transaction, before submitting to Stellar
  • Returns HTTP 409 with { error: "This reward tier is sold out", tier_id } if the tier is at capacity
  • Passes tierId through to submitCustodialContribution so it flows into metadata

backend/src/services/contributionService.js

  • Accepts new tierId parameter and includes it in stellar_transactions.metadata as tier_id

backend/src/services/ledgerMonitor.js

  • Reads tier_id from stellar_transactions.metadata and passes it to assignTierToContribution, ensuring pre-reserved tiers are honoured without double-counting claimed_count

backend/src/middleware/validation.js

  • Added optional tier_id validation (UUID format) to contributionValidation

How It Works End-to-End

User contributes (with tier_id)
  │
  ├─ 1. Route validates tier exists & belongs to campaign (pre-flight)
  │
  ├─ 2. BEGIN transaction
  │     ├─ Advisory lock (campaign + user)
  │     ├─ Check per-user cap
  │     ├─ reserveTierSlot() → UPDATE reward_tiers SET claimed_count++ 
  │     │                        WHERE id = tier_id AND claimed_count < tier_limit
  │     │                        RETURNING id
  │     │     └─ If 0 rows → ROLLBACK, respond 409 Sold out 🚫
  │     ├─ Submit Stellar transaction
  │     ├─ Insert stellar_transactions record (metadata.tier_id set)
  │     └─ COMMIT ✅
  │
  └─ 3. (async) Ledger monitor indexes on-chain payment
        ├─ Insert contributions row
        ├─ assignTierToContribution(tierId) → creates contribution_rewards
        │   (idempotent via ON CONFLICT DO NOTHING, no double-increment)
        └─ Update campaign raised_amount

Acceptance Criteria

Criteria Status
Tier claimed_count decrements atomically with each contribution UPDATE ... SET claimed_count = claimed_count + 1 inside route transaction
Contribution rejected with 409 when tier is sold out reserveTierSlot() returns null → res.status(409)
Concurrent contributions for the last slot handled correctly (only one succeeds) ✅ Atomic UPDATE ... WHERE claimed_count < tier_limit with PostgreSQL row-level locking inside transaction
Tier sold-out state reflected in UI immediately claimed_count is updated atomically in the route, so the next listTiersWithAvailability() query reflects the correct remaining

Testing

  • Unit test reserveTierSlot() — success path
  • Unit test reserveTierSlot() — sold-out path returns null
  • Unit test assignTierToContribution() with explicit tierId — no double-increment
  • Integration: contribute with valid tier_id, verify claimed_count incremented
  • Integration: contribute with sold-out tier_id, verify 409 response
  • Integration: concurrent contributions for last slot, verify exactly one 202 and one 409

… contribution

- Add reserveTierSlot() to rewardTierService.js: atomically increments claimed_count inside the contribution route transaction, returns null if the tier is sold out (HTTP 409).
- Add optional tierId param to assignTierToContribution(): when set, only creates the contribution_rewards join row without bumping claimed_count again.
- Custodial POST / route accepts optional tier_id and reserves the slot atomically via reserveTierSlot(). Rejects with 409 if sold out.
- Stored tier_id in stellar_transactions.metadata for ledger monitor.
- Ledger monitor passes reserved tier_id from metadata to assignTierToContribution.
- Validation middleware accepts optional tier_id field.
@drips-wave

drips-wave Bot commented Jul 28, 2026

Copy link
Copy Markdown

@innocentharuna Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@dotunv
dotunv merged commit dbcbd2d into Savitura:main Jul 28, 2026
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.

bug: Reward tier remaining count not decremented when a tier-backed contribution is made

2 participants