loyalty_ref 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ LoyaltyRef.configure do |config|
4
+ # Define which model represents your app's user/customer
5
+ config.reference_class_name = 'User' # or 'Customer', 'Account', etc.
6
+
7
+ # Custom earning rule - earn points based on events
8
+ config.earning_rule = ->(user, event) do
9
+ # Example: earn 10 points per ₹100 spent
10
+ # event.amount / 10
11
+
12
+ # Default: earn 1 point per event
13
+ 1
14
+ end
15
+
16
+ # Custom redeem rule - how many points to deduct
17
+ config.redeem_rule = ->(user, offer) do
18
+ # Example: offer.cost_in_points
19
+ offer&.cost_in_points || 100
20
+ end
21
+
22
+ # Define tier thresholds
23
+ config.tier_thresholds = {
24
+ "Silver" => 500,
25
+ "Gold" => 1000,
26
+ "Platinum" => 2500
27
+ }
28
+
29
+ # Reward modifier based on tier
30
+ config.reward_modifier = ->(user) do
31
+ case user.tier
32
+ when "Silver" then 1.0
33
+ when "Gold" then 1.2
34
+ when "Platinum" then 1.5
35
+ else 1.0
36
+ end
37
+ end
38
+
39
+ # Referral reward logic
40
+ config.referral_reward = ->(referrer, referee) do
41
+ LoyaltyRef.earn_points(referrer, 100)
42
+ LoyaltyRef.earn_points(referee, 50)
43
+ end
44
+
45
+ # Points expiration (in days)
46
+ config.points_expiry_days = 90
47
+
48
+ # Referral code length
49
+ config.referral_code_length = 8
50
+
51
+ # Admin dashboard route path
52
+ config.admin_route_path = "/loyalty"
53
+
54
+ # Device tracking and analytics
55
+ config.enable_device_tracking = true # Enable device detection
56
+ config.collect_geo_location = false # Enable geo-location (requires user consent)
57
+
58
+ # Optional: Callback when user's tier changes
59
+ config.on_tier_changed = ->(user, old_tier, new_tier) do
60
+ # Example: Send notification email
61
+ # UserMailer.tier_changed(user, old_tier, new_tier).deliver_later
62
+ end
63
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User < ApplicationRecord
4
+ # Add these columns to your users table:
5
+ # add_column :users, :referral_code, :string
6
+ # add_column :users, :tier, :string
7
+ # add_index :users, :referral_code, unique: true
8
+
9
+ has_many :loyalty_ref_transactions, class_name: 'LoyaltyRef::Transaction', as: :user
10
+ has_many :referrals, class_name: 'User', foreign_key: 'referrer_id'
11
+ belongs_to :referrer, class_name: 'User', optional: true
12
+
13
+ before_create :generate_referral_code
14
+
15
+ def points_balance
16
+ LoyaltyRef.balance(self)
17
+ end
18
+
19
+ def current_tier
20
+ LoyaltyRef.tier(self)
21
+ end
22
+
23
+ def referral_link
24
+ "#{Rails.application.routes.url_helpers.root_url}?ref=#{referral_code}"
25
+ end
26
+
27
+ def total_referrals
28
+ referrals.count
29
+ end
30
+
31
+ def successful_referrals
32
+ referrals.joins(:loyalty_ref_transactions).distinct.count
33
+ end
34
+
35
+ private
36
+
37
+ def generate_referral_code
38
+ return if referral_code.present?
39
+
40
+ loop do
41
+ self.referral_code = SecureRandom.alphanumeric(LoyaltyRef.configuration.referral_code_length).upcase
42
+ break unless User.exists?(referral_code: referral_code)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LoyaltyRef
4
+ class Configuration
5
+ attr_accessor :reference_class_name,
6
+ :earning_rule,
7
+ :redeem_rule,
8
+ :tier_thresholds,
9
+ :reward_modifier,
10
+ :referral_reward,
11
+ :on_tier_changed,
12
+ :points_expiry_days,
13
+ :referral_code_length,
14
+ :admin_route_path,
15
+ :enable_device_tracking,
16
+ :collect_geo_location
17
+
18
+ def initialize
19
+ @reference_class_name = "User"
20
+ @tier_thresholds = {
21
+ "Silver" => 500,
22
+ "Gold" => 1000,
23
+ "Platinum" => 2500
24
+ }
25
+ @points_expiry_days = 90
26
+ @referral_code_length = 8
27
+ @admin_route_path = "/loyalty"
28
+ @enable_device_tracking = true
29
+ @collect_geo_location = false # Default to false for privacy
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module LoyaltyRef
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace LoyaltyRef
8
+
9
+ config.generators do |g|
10
+ g.test_framework :rspec, fixture: false
11
+ g.fixture_replacement :factory_bot, dir: "spec/factories"
12
+ g.assets false
13
+ g.helper false
14
+ end
15
+
16
+ initializer "loyalty_ref.assets" do |app|
17
+ app.config.assets.precompile += %w[loyalty_ref_manifest.js]
18
+ end
19
+
20
+ initializer "loyalty_ref.routes" do |app|
21
+ app.routes.prepend do
22
+ mount LoyaltyRef::Engine => LoyaltyRef.configuration.admin_route_path
23
+ end
24
+ end
25
+
26
+ initializer "loyalty_ref.migrations" do |app|
27
+ unless app.root.to_s.match root.to_s
28
+ config.paths["db/migrate"].expanded.each do |expanded_path|
29
+ app.config.paths["db/migrate"] << expanded_path
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LoyaltyRef
4
+ class ReferralLog < ActiveRecord::Base
5
+ self.table_name = "loyalty_ref_referral_logs"
6
+
7
+ belongs_to :referee, polymorphic: true, optional: true
8
+
9
+ validates :referral_code, presence: true
10
+ validates :clicked_at, presence: true
11
+
12
+ scope :clicked, -> { where.not(clicked_at: nil) }
13
+ scope :signed_up, -> { where.not(signed_up_at: nil) }
14
+ scope :converted, -> { where.not(signed_up_at: nil).where.not(referee_id: nil) }
15
+
16
+ def converted?
17
+ signed_up_at.present? && referee_id.present?
18
+ end
19
+
20
+ def conversion_rate
21
+ return 0 if clicked_count.zero?
22
+ (converted_count.to_f / clicked_count * 100).round(2)
23
+ end
24
+
25
+ def self.clicked_count
26
+ clicked.count
27
+ end
28
+
29
+ def self.converted_count
30
+ converted.count
31
+ end
32
+
33
+ def self.conversion_rate
34
+ return 0 if clicked_count.zero?
35
+ (converted_count.to_f / clicked_count * 100).round(2)
36
+ end
37
+
38
+ # Device detection methods - use stored data or fallback to user_agent parsing
39
+ def device_type
40
+ return self[:device_type] if self[:device_type].present?
41
+ return detect_device_type_from_user_agent if user_agent.present?
42
+ nil
43
+ end
44
+
45
+ def browser
46
+ return self[:browser] if self[:browser].present?
47
+ return detect_browser_from_user_agent if user_agent.present?
48
+ nil
49
+ end
50
+
51
+ # Geo-location methods (if using a geo service)
52
+ def country
53
+ return geo_data["country"] if geo_data&.dig("country").present?
54
+ return nil unless ip_address.present?
55
+ # You can integrate with services like MaxMind GeoIP2 or similar
56
+ # For now, return nil - users can implement their own geo service
57
+ nil
58
+ end
59
+
60
+ def city
61
+ return geo_data["city"] if geo_data&.dig("city").present?
62
+ return nil unless ip_address.present?
63
+ # Implement with your preferred geo service
64
+ nil
65
+ end
66
+
67
+ def latitude
68
+ geo_data&.dig("latitude")
69
+ end
70
+
71
+ def longitude
72
+ geo_data&.dig("longitude")
73
+ end
74
+
75
+ private
76
+
77
+ def detect_device_type_from_user_agent
78
+ return nil unless user_agent.present?
79
+
80
+ user_agent = user_agent.downcase
81
+
82
+ if user_agent.include?("mobile") || user_agent.include?("android") || user_agent.include?("iphone")
83
+ "mobile"
84
+ elsif user_agent.include?("tablet") || user_agent.include?("ipad")
85
+ "tablet"
86
+ else
87
+ "desktop"
88
+ end
89
+ end
90
+
91
+ def detect_browser_from_user_agent
92
+ return nil unless user_agent.present?
93
+
94
+ user_agent = user_agent.downcase
95
+
96
+ if user_agent.include?("chrome")
97
+ "Chrome"
98
+ elsif user_agent.include?("firefox")
99
+ "Firefox"
100
+ elsif user_agent.include?("safari")
101
+ "Safari"
102
+ elsif user_agent.include?("edge")
103
+ "Edge"
104
+ else
105
+ "Other"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LoyaltyRef
4
+ class Transaction < ActiveRecord::Base
5
+ self.table_name = "loyalty_ref_transactions"
6
+
7
+ belongs_to :user, polymorphic: true
8
+
9
+ validates :points, presence: true, numericality: { other_than: 0 }
10
+ validates :transaction_type, presence: true, inclusion: { in: %w[earn redeem adjust] }
11
+
12
+ scope :earned, -> { where(transaction_type: "earn") }
13
+ scope :redeemed, -> { where(transaction_type: "redeem") }
14
+ scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
15
+ scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
16
+
17
+ def expired?
18
+ expires_at.present? && expires_at <= Time.current
19
+ end
20
+
21
+ def active?
22
+ !expired?
23
+ end
24
+
25
+ def earned?
26
+ transaction_type == "earn"
27
+ end
28
+
29
+ def redeemed?
30
+ transaction_type == "redeem"
31
+ end
32
+
33
+ def adjusted?
34
+ transaction_type == "adjust"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LoyaltyRef
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_record"
5
+ require "action_view"
6
+ require "action_pack"
7
+
8
+ require_relative "loyalty_ref/version"
9
+ require_relative "loyalty_ref/engine"
10
+ require_relative "loyalty_ref/configuration"
11
+ require_relative "loyalty_ref/models/transaction"
12
+ require_relative "loyalty_ref/models/referral_log"
13
+
14
+ module LoyaltyRef
15
+ class << self
16
+ def configure
17
+ yield configuration
18
+ end
19
+
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ def earn_points(user, amount, event: nil)
25
+ return false unless user && amount.positive?
26
+
27
+ # Apply reward modifier based on tier
28
+ modifier = configuration.reward_modifier&.call(user) || 1.0
29
+ final_amount = (amount * modifier).round
30
+
31
+ # Calculate expiration date
32
+ expiry_days = configuration.points_expiry_days
33
+ expires_at = expiry_days ? Time.current + expiry_days.days : nil
34
+
35
+ # Create transaction
36
+ transaction = Transaction.create!(
37
+ user: user,
38
+ points: final_amount,
39
+ transaction_type: "earn",
40
+ event_data: event&.as_json,
41
+ expires_at: expires_at,
42
+ description: "Earned #{final_amount} points"
43
+ )
44
+
45
+ # Update user tier if needed
46
+ update_user_tier(user)
47
+
48
+ transaction
49
+ rescue => e
50
+ Rails.logger.error "LoyaltyRef: Failed to earn points: #{e.message}"
51
+ false
52
+ end
53
+
54
+ def redeem_points(user, points, offer: nil)
55
+ return false unless user && points.positive?
56
+
57
+ current_balance = balance(user)
58
+ return false if current_balance < points
59
+
60
+ # Calculate cost using custom rule if provided
61
+ cost = if configuration.redeem_rule
62
+ configuration.redeem_rule.call(user, offer)
63
+ else
64
+ points
65
+ end
66
+
67
+ # Create transaction
68
+ transaction = Transaction.create!(
69
+ user: user,
70
+ points: -cost,
71
+ transaction_type: "redeem",
72
+ offer_data: offer&.as_json,
73
+ description: "Redeemed #{cost} points"
74
+ )
75
+
76
+ transaction
77
+ rescue => e
78
+ Rails.logger.error "LoyaltyRef: Failed to redeem points: #{e.message}"
79
+ false
80
+ end
81
+
82
+ def balance(user)
83
+ return 0 unless user
84
+
85
+ Transaction.where(user: user)
86
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
87
+ .sum(:points)
88
+ end
89
+
90
+ def tier(user)
91
+ return nil unless user && configuration.tier_thresholds.present?
92
+
93
+ balance = self.balance(user)
94
+ thresholds = configuration.tier_thresholds
95
+
96
+ # Find the highest tier the user qualifies for
97
+ thresholds.sort_by { |_, points| points }.reverse.each do |tier_name, required_points|
98
+ return tier_name if balance >= required_points
99
+ end
100
+
101
+ nil
102
+ end
103
+
104
+ def track_referral(ref_code:, user_agent: nil, ip_address: nil, referrer: nil, device_data: nil)
105
+ return false unless ref_code.present?
106
+
107
+ # Extract device and geo data from device_data parameter
108
+ device_type = device_data&.dig("device_type")
109
+ browser = device_data&.dig("browser")
110
+ geo_data = device_data&.dig("geo_data")
111
+ device_info = device_data&.except("device_type", "browser", "geo_data", "collected_at")
112
+
113
+ ReferralLog.create!(
114
+ referral_code: ref_code,
115
+ user_agent: user_agent,
116
+ ip_address: ip_address,
117
+ referrer: referrer,
118
+ clicked_at: Time.current,
119
+ device_type: device_type,
120
+ browser: browser,
121
+ geo_data: geo_data,
122
+ device_data: device_info
123
+ )
124
+ rescue => e
125
+ Rails.logger.error "LoyaltyRef: Failed to track referral: #{e.message}"
126
+ false
127
+ end
128
+
129
+ def process_referral_signup(referee, ref_code)
130
+ return false unless referee && ref_code.present?
131
+
132
+ # Find the referral log
133
+ referral_log = ReferralLog.where(referral_code: ref_code)
134
+ .where(referee_id: nil)
135
+ .order(clicked_at: :desc)
136
+ .first
137
+
138
+ return false unless referral_log
139
+
140
+ # Find the referrer
141
+ referrer = find_user_by_referral_code(ref_code)
142
+ return false unless referrer
143
+
144
+ # Update referral log
145
+ referral_log.update!(
146
+ referee: referee,
147
+ signed_up_at: Time.current
148
+ )
149
+
150
+ # Update referee's referrer
151
+ referee.update!(referrer: referrer)
152
+
153
+ # Process referral rewards
154
+ if configuration.referral_reward
155
+ configuration.referral_reward.call(referrer, referee)
156
+ end
157
+
158
+ true
159
+ rescue => e
160
+ Rails.logger.error "LoyaltyRef: Failed to process referral signup: #{e.message}"
161
+ false
162
+ end
163
+
164
+ private
165
+
166
+ def update_user_tier(user)
167
+ old_tier = user.tier
168
+ new_tier = tier(user)
169
+
170
+ return if old_tier == new_tier
171
+
172
+ # Update user's tier in database
173
+ user.update!(tier: new_tier)
174
+
175
+ # Call tier change callback if configured
176
+ if configuration.on_tier_changed
177
+ configuration.on_tier_changed.call(user, old_tier, new_tier)
178
+ end
179
+ end
180
+
181
+ def find_user_by_referral_code(code)
182
+ return nil unless configuration.reference_class_name
183
+
184
+ user_class = configuration.reference_class_name.constantize
185
+ user_class.find_by(referral_code: code)
186
+ rescue NameError
187
+ Rails.logger.error "LoyaltyRef: User class '#{configuration.reference_class_name}' not found"
188
+ nil
189
+ end
190
+ end
191
+ end