ruby 51 lines · 10 steps

Safe coupon redemption in Rails

A Coupon model that validates, locks, and atomically records each redemption while enforcing expiry and usage limits.

Explained by highlit
1class Coupon < ApplicationRecord
2 has_many :redemptions, dependent: :destroy
3 
4 validates :code, presence: true, uniqueness: { case_sensitive: false }
5 validates :usage_limit, numericality: { greater_than: 0 }, allow_nil: true
6 validate :not_expired, on: :redeem
7 validate :under_usage_limit, on: :redeem
8 
9 before_validation :normalize_code
10 
11 scope :active, -> {
12 where("expires_at IS NULL OR expires_at > ?", Time.current)
13 .where(disabled_at: nil)
14 }
15 
16 scope :redeemable, -> {
17 active.where(<<~SQL.squish, false)
18 usage_limit IS NULL OR redemption_count < usage_limit OR ?
19 SQL
20 }
21 
22 def redeem!(order)
23 with_lock do
24 unless valid?(:redeem)
25 raise ActiveRecord::RecordInvalid, self
26 end
27 
28 redemption = redemptions.create!(order: order)
29 increment!(:redemption_count)
30 redemption
31 end
32 end
33 
34 private
35 
36 def normalize_code
37 self.code = code.to_s.strip.upcase.presence
38 end
39 
40 def not_expired
41 return if expires_at.blank? || expires_at.future?
42 
43 errors.add(:base, "This coupon expired on #{expires_at.to_date.to_fs(:long)}")
44 end
45 
46 def under_usage_limit
47 return if usage_limit.blank? || redemption_count < usage_limit
48 
49 errors.add(:base, "This coupon has reached its usage limit")
50 end
51end
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Contextual validations with `on:` let one model enforce different rules for different actions.
  2. 2Wrapping reads and writes in `with_lock` serializes concurrent redemptions so limits can't be overshot.
  3. 3Scopes can push complex eligibility logic into SQL, keeping queries efficient and reusable.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Safe coupon redemption in Rails — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code