core_merchant 0.8.0 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bd7b45750d9491ddf78c12b41e73a1d79d8bc6fa5cdff69df22f536b1765e42
4
- data.tar.gz: 056e3021e60c7c5edb4a301dae4a29b6184ad3889b55c81e226dccfcfcb00020
3
+ metadata.gz: 698e2da9b3b98b8e6c6275063af7d439c1c2a79384927e952e50419691331ce9
4
+ data.tar.gz: fde06b7ff60d2ac49bc36b5c89200c073a1f498eca046075c7bd0cfb626702de
5
5
  SHA512:
6
- metadata.gz: f916fe4fb53d28a9fbb61c8d7306825cb96d82ceeb559a6aeea1373c83e5401c785c0400dd3b41f0d5f9a4c321f0cc91c85eddead5808d546c7fb09d6b671aa7
7
- data.tar.gz: 218935fdbb73a113262735e5b4a0290d7942121b167932ad00a5ead4c1a75ef69d8f18dfffca5fea14aafbbb8bf79f6cb940042f15f0989a59ce1e6c14a58208
6
+ metadata.gz: 2cc9e74b4053e9a91435977f1b6d30222c96a69403654062a9c36dcfec6a18e2e4eebecfdc9c889cc3afbac1ac1a34e4f451cd78e6abbe64bf4ca7e795570543
7
+ data.tar.gz: 6a8cfc973fe959bfbb5cf29c0b68e3e7f644e1cee9f5e21fdc415a4d510b26ddf904e85d05c29188a176fec0225c6e763938713dcdb4d8521cf12d0aab4797c5
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- core_merchant (0.8.0)
4
+ core_merchant (0.9.0)
5
5
  activesupport (~> 7.0)
6
6
  rails (~> 7.0)
7
7
 
data/README.md CHANGED
@@ -10,15 +10,44 @@ CoreMerchant is a library for customer, product, and subscription management in
10
10
  - [X] Add initializer generator
11
11
  - [X] Add SubscriptionPlan model
12
12
  - [X] Add Subscription model
13
- - [ ] Implement subscription manager and callbacks
14
- - [ ] Add sidekiq jobs for subscription management
13
+ - [X] Implement subscription manager and callbacks
14
+ - [X] Implement SubscriptionEvent model for logging
15
15
  - [ ] Add Invoice model
16
16
  - [ ] Add billing and invoicing service
17
17
 
18
- ## Installation
18
+ ## Table of contents
19
+ - [CoreMerchant](#coremerchant)
20
+ - [To-dos until 1.0.0 release](#to-dos-until-100-release)
21
+ - [Table of contents](#table-of-contents)
22
+ - [Installation](#installation)
23
+ - [Usage](#usage)
24
+ - [Initialization](#initialization)
25
+ - [Configuration](#configuration)
26
+ - [1. Customer class](#1-customer-class)
27
+ - [2. Subscription listener class](#2-subscription-listener-class)
28
+ - [Subscription management](#subscription-management)
29
+ - [Creating a subscription plan](#creating-a-subscription-plan)
30
+ - [Creating a subscription](#creating-a-subscription)
31
+ - [Cancelling a subscription](#cancelling-a-subscription)
32
+ - [Handling subscription events](#handling-subscription-events)
33
+ - [Subscription History](#subscription-history)
34
+ - [Public API](#public-api)
35
+ - [SubscriptionManager](#subscriptionmanager)
36
+ - [SubscriptionPlan](#subscriptionplan)
37
+ - [Subscription](#subscription)
38
+ - [SubscriptionEvent](#subscriptionevent)
39
+ - [Subclasses](#subclasses)
40
+ - [SubscriptionRenewalEvent](#subscriptionrenewalevent)
41
+ - [SubscriptionStatusChangeEvent](#subscriptionstatuschangeevent)
42
+ - [SubscriptionPlanChangeEvent](#subscriptionplanchangeevent)
43
+ - [SubscriptionCancellationEvent](#subscriptioncancellationevent)
44
+ - [Contributing](#contributing)
45
+
46
+
47
+ # Installation
19
48
  Add this line to your application's Gemfile:
20
49
  ```
21
- gem 'core_merchant', '~> 0.1.0'
50
+ gem 'core_merchant', '~> 0.10.0'
22
51
  ```
23
52
  and run `bundle install`.
24
53
 
@@ -27,30 +56,32 @@ Alternatively, you can install the gem manually:
27
56
  $ gem install core_merchant
28
57
  ```
29
58
 
30
- ## Usage
31
- ### Initialization
59
+ # Usage
60
+ ## Initialization
32
61
  Run the generator to create the initializer file and the migrations:
33
62
  ```
34
- $ rails generate core_merchant:install --customer_class=User
63
+ $ rails generate core_merchant:install
35
64
  ```
36
- `--customer_class` option is required and should be the name of the model that represents the customer in your application. For example, if you already have a `User` model that represents the users of your application, you can use it as the customer class in CoreMerchant.
37
65
 
38
66
  This will create the following files:
39
67
  - `config/initializers/core_merchant.rb` - Configuration file
68
+ - `config/locales/core_merchant.en.yml` - English translations for core merchant models
40
69
  - `db/migrate/xxxxxx_create_core_merchant_subscription_plans.rb` - Migration for subscription plans
41
-
70
+ - `db/migrate/xxxxxx_create_core_merchant_subscriptions.rb` - Migration for subscriptions
42
71
  You can then run the migrations:
43
72
  ```
44
73
  $ rails db:migrate
45
74
  ```
46
75
 
47
- ### Configuration
76
+ ## Configuration
48
77
  The initializer file `config/initializers/core_merchant.rb` contains the following configuration options:
78
+
79
+ ### 1. Customer class
49
80
  ```ruby
50
81
  config.customer_class = 'User'
51
82
  ```
52
83
 
53
- You need to include the `CoreMerchant::Customer` module in the customer class:
84
+ This is the class that includes the `CoreMerchant::Customer` module. It should be the model class that represents your customers. For example, if you have a `User` model that represents your customers, you can include the `CoreMerchant::Customer` module in the `User` class:
54
85
  ```ruby
55
86
  # app/models/user.rb
56
87
  class User < ApplicationRecord
@@ -60,9 +91,149 @@ class User < ApplicationRecord
60
91
  end
61
92
  ```
62
93
 
63
- ## Models
94
+ ### 2. Subscription listener class
95
+ ```ruby
96
+ config.subscription_listener_class = 'MySubscriptionListener'
97
+ ```
98
+ This is the class that will receive subscription events. It should include the `CoreMerchant::SubscriptionListener` module and implement event handlers for the subscription events. Some examples:
99
+ ```ruby
100
+ class MySubscriptionListener
101
+ include CoreMerchant::SubscriptionListener
102
+
103
+ def on_test_event_received
104
+ puts 'Test event received, hooray!'
105
+ end
106
+ end
107
+ ```
108
+
109
+ More about subscription events in the [Handling subscription events](#handling-subscription-events) section.
110
+
111
+ ## Subscription management
112
+
113
+ ### Creating a subscription plan
114
+ You can create a subscription plan using the `SubscriptionPlan` model:
115
+ ```ruby
116
+ CoreMerchant::SubscriptionPlan.create(name_key: 'basic', price_cents: 10_00, duration: '1m')
117
+ ```
118
+
119
+ ### Creating a subscription
120
+ You can create a subscription for a customer using the `Subscription` model:
121
+ ```ruby
122
+ customer = User.find(1)
123
+ plan = CoreMerchant::SubscriptionPlan.find_by(name_key: 'basic')
124
+ subscription = CoreMerchant::Subscription.create(customer: customer, plan: plan)
125
+ ```
126
+
127
+ Note that the subscription will not be active until you start it:
128
+ ```ruby
129
+ subscription.start
130
+ ```
131
+
132
+ ### Cancelling a subscription
133
+ You can cancel a subscription by calling the `cancel` method. You can also specify a reason for the cancellation and whether the cancellation should take effect immediately or at the end of the current billing period:
134
+ ```ruby
135
+ subscription.cancel(reason: 'Customer request', at_period_end: false)
136
+ ```
137
+
138
+ ### Handling subscription events
139
+ You can handle subscription events by implementing event handlers in the subscription listener class. For example, you can send an email to the customer when a subscription is created:
140
+ ```ruby
141
+ class MySubscriptionListener
142
+ include CoreMerchant::SubscriptionListener
143
+
144
+
145
+ def on_subscription_created(subscription)
146
+ FakeEmailService.send_email(subscription.customer.email, "We're happy to have you on board!")
147
+
148
+ # You can also start the subscription automatically
149
+ subscription.start
150
+ end
151
+
152
+ def on_subscription_started(subscription)
153
+ FakeEmailService.send_email(subscription.customer.email, "Your subscription has started!")
154
+ end
155
+
156
+ def on_subscription_due_for_renewal(subscription)
157
+ success = FakePaymentService.charge(subscription.customer, subscription.plan.price_cents)
158
+
159
+ if success
160
+ CoreMerchant.subscription_manager.payment_successful_for_renewal(subscription)
161
+ else
162
+ FakeEmailService.send_email(subscription.customer.email, "Payment failed, please update your payment method.")
163
+ CoreMerchant.subscription_manager.payment_failed_for_renewal(subscription)
164
+ end
165
+ end
166
+
167
+ def on_subscription_renewed(subscription)
168
+ FakeEmailService.send_email(subscription.customer.email, "Your subscription has been renewed until #{subscription.current_period_end_date}")
169
+ end
170
+ end
171
+ ```
172
+
173
+ Available subscription events:
174
+ - `on_subscription_created(subscription)`
175
+ - `on_subscription_destroyed(subscription)`
176
+ - `on_subscription_started(subscription)`
177
+ - `on_subscription_canceled(subscription, reason:, at_period_end:)`
178
+ - `on_subscription_due_for_renewal(subscription)`
179
+ - `on_subscription_renewed(subscription)`
180
+ - `on_subscription_renewal_payment_processing(subscription)`
181
+ - `on_subscription_grace_period_started(subscription, days_remaining:)`
182
+
183
+ ### Subscription History
184
+ CoreMerchant now keeps a detailed history of subscription events, including creations, renewals, cancellations, status changes, and plan changes. This provides an audit trail and can be useful for debugging, customer support, and analytics.
185
+
186
+ To access a subscription's history:
187
+ ```ruby
188
+ subscription = CoreMerchant::Subscription.find(42)
189
+
190
+ # Get all events
191
+ subscription.subscription_events
192
+
193
+ # Get specific event types
194
+ latest_renewal = subscription.renewal_events.last
195
+ puts "Last renewed at: #{latest_renewal.created_at}"
196
+ puts "Renewal price: #{latest_renewal.price_cents} cents, renewed until: #{latest_renewal.renewed_until}"
197
+
198
+ latest_status_change = subscription.status_change_events.last
199
+ puts "Status changed from #{latest_status_change.from} to #{latest_status_change.to}"
200
+
201
+ latest_plan_change = subscription.plan_change_events.last
202
+ puts "Plan changed from #{latest_plan_change.from_plan.name} to #{latest_plan_change.to_plan.name}"
203
+ ```
204
+
205
+ ## Public API
206
+ ### SubscriptionManager
207
+ Access from `CoreMerchant.subscription_manager`. The `SubscriptionManager` class is responsible for managing subscriptions. It is responsible for notifying listeners when subscription events occur and checking for and handling renewals.
208
+
209
+ **Attributes**:
210
+ - `listeners` - An array of listeners that will be notified when subscription events occur.
211
+
212
+ **Methods**:
213
+ - `check_subscriptions` - Checks all subscriptions for renewals
214
+ - `add_listener(listener)` - Adds a listener to the list of listeners
215
+ - `no_payment_needed_for_renewal(subscription)` - Handles the case where no payment is needed for a renewal.
216
+ Call when a subscription is renewed without payment.
217
+ - `processing_payment_for_renewal(subscription)` - Handles the case where payment is being processed for a renewal.
218
+ Call when payment is being processed for a renewal.
219
+ - `payment_successful_for_renewal(subscription)` - Handles the case where payment was successful for a renewal.
220
+ Call when payment was successful for a renewal.
221
+ - `payment_failed_for_renewal(subscription)` - Handles the case where payment failed for a renewal.
222
+ Call when payment failed for a renewal.
223
+
224
+ **Usage**:
225
+ ```ruby
226
+ manager = CoreMerchant.subscription_manager
227
+ manager.check_subscriptions
228
+ # ... somewhere else in the code ...
229
+ manager.payment_successful_for_renewal(subscription1)
230
+ manager.payment_failed_for_renewal(subscription2)
231
+ ```
232
+
64
233
  ### SubscriptionPlan
65
- The `SubscriptionPlan` model represents a subscription plan in your application. It has the following attributes:
234
+ The `SubscriptionPlan` model represents a subscription plan in your application. Subscription plans are used to define the pricing and features of a subscription. All prices are in cents.
235
+
236
+ **Attributes**:
66
237
  - `name_key`: A unique key for the subscription plan.
67
238
  This key is used to identify the plan in the application,
68
239
  as well as the translation key for the plan name through `core_merchant.subscription_plans`.
@@ -73,6 +244,109 @@ The `SubscriptionPlan` model represents a subscription plan in your application.
73
244
  - `introductory_price_cents`: The introductory price of the subscription plan in cents.
74
245
  - `introductory_duration`: The duration of the introductory price of the subscription plan.
75
246
 
247
+ **Usage**:
248
+ ``` ruby
249
+ plan = CoreMerchant::SubscriptionPlan.new(name_key: "basic_monthly", price_cents: 7_99)
250
+ plan.save
251
+ ```
252
+ ### Subscription
253
+ Represents a subscription in CoreMerchant.
254
+ This class manages the lifecycle of a customer's subscription to a specific plan.
255
+
256
+ **Subscriptions can transition through various statuses**:
257
+ - `pending`: Subscription created but not yet started
258
+ - `trial`: In a trial period
259
+ - `active`: Currently active and paid
260
+ - `past_due`: Payment failed but in grace period
261
+ - `pending_cancellation`: Will be canceled at period end
262
+ - `processing_renewal`: Renewal in progress
263
+ - `processing_payment`: Payment processing
264
+ - `canceled`: Canceled by user or due to payment failure
265
+ - `expired`: Subscription period ended
266
+ - `paused`: Temporarily halted, not yet implemented
267
+ - `pending_change`: Plan change scheduled for next renewal, not yet implemented
268
+
269
+ **Key features**:
270
+ - Supports immediate and end-of-period cancellations
271
+ - Allows plan changes, effective immediately or at next renewal
272
+ - Handles subscription pausing and resuming
273
+ - Manages trial periods
274
+ - Supports variable pricing for renewals
275
+
276
+ **Attributes**:
277
+ - `customer`: Polymorphic association to the customer
278
+ - `subscription_plan`: The current plan for this subscription
279
+ - `status`: Current status of the subscription (see enum definition)
280
+ - `start_date`: When the subscription started
281
+ - `end_date`: When the subscription ended (or will end)
282
+ - `trial_end_date`: End date of the trial period (if applicable)
283
+ - `canceled_at`: When the subscription was canceled
284
+ - `current_period_start`: Start of the current billing period
285
+ - `current_period_end`: End of the current billing period
286
+ - `pause_start_date`: When the subscription was paused
287
+ - `pause_end_date`: When the paused subscription will resume
288
+ - `current_period_price_cents`: Price for the current period
289
+ - `next_renewal_price_cents`: Price for the next renewal (if different from plan)
290
+ - `cancellation_reason`: Reason for cancellation (if applicable)
291
+
292
+ **Methods**:
293
+ - `start` - Starts the subscription
294
+ - `cancel(reason:, at_period_end:)` - Cancels the subscription, optionally at the end of the current period
295
+
296
+ **Usage**:
297
+ ```ruby
298
+ subscription = CoreMerchant::Subscription.create(customer: user, subscription_plan: plan, status: :active)
299
+ subscription.start
300
+ subscription.cancel(reason: "Too expensive", at_period_end: true)
301
+ ```
302
+
303
+ ### SubscriptionEvent
304
+ The `SubscriptionEvent` model represents a historical log of events related to a subscription. It provides an audit trail of all significant actions and state changes for a subscription.
305
+
306
+ This class has subclasses for specific event types, such as `SubscriptionRenewalEvent`, `SubscriptionStatusChangeEvent`, and `SubscriptionPlanChangeEvent`. Each subclass has additional fields specific to the event type.
307
+
308
+ **Attributes**:
309
+ - `subscription`: Association to the related Subscription
310
+ - `event_type`: Type of the event (e.g., 'created', 'renewed', 'canceled', 'status_changed', 'plan_changed')
311
+ - `metadata`: JSON field for storing additional event-specific data
312
+
313
+ **Usage**:
314
+ ```ruby
315
+ # Automatically logged when a subscription is created
316
+ subscription = CoreMerchant::Subscription.create(customer: user, subscription_plan: plan)
317
+
318
+ # Logging a custom event
319
+ subscription.log_event('custom_event', key: 'value')
320
+
321
+ # Retrieve the last renewal event
322
+ latest_renewal = subscription.renewal_events.last
323
+ puts "Last renewed at: #{latest_renewal.created_at}"
324
+ puts "Renewal price: #{latest_renewal.price_cents} cents, renewed until: #{latest_renewal.renewed_until}"
325
+
326
+ # Retrieve the last event of any type
327
+ latest_event = subscription.subscription_events.last
328
+ puts "Last event type: #{latest_event.event_type}, metadata: #{latest_event.metadata}"
329
+ ```
330
+
331
+ #### Subclasses
332
+ ##### SubscriptionRenewalEvent
333
+ - `price_cents`: The price of the renewal in cents
334
+ - `renewed_until`: The end date of the renewal
335
+ - `renewed_at`: The date and time of the renewal
336
+
337
+ ##### SubscriptionStatusChangeEvent
338
+ - `from`: The previous status of the subscription
339
+ - `to`: The new status of the subscription
340
+
341
+ ##### SubscriptionPlanChangeEvent
342
+ - `from_plan`: The previous plan of the subscription
343
+ - `to_plan`: The new plan of the subscription
344
+
345
+ ##### SubscriptionCancellationEvent
346
+ - `reason`: The reason for the cancellation
347
+ - `at_period_end`: Whether the cancellation is scheduled for the end of the current period
348
+ - `canceled_at`: The date and time of the cancellation
349
+
76
350
  > [!NOTE]
77
351
  > Other models and features are being developed and will be added in future releases.
78
352
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoreMerchant
4
+ module Concerns
5
+ # This concern adds a SubscriptionEvent association including creation and retrieval
6
+ module SubscriptionEventAssociation
7
+ extend ActiveSupport::Concern
8
+
9
+ EVENT_TYPES = %i[renewal status_change plan_change cancellation].freeze
10
+
11
+ included do
12
+ has_many :events, class_name: "SubscriptionEvent", dependent: :destroy
13
+
14
+ EVENT_TYPES.each do |event_type|
15
+ scope_name = "#{event_type}_events".to_sym
16
+ class_name = "Subscription#{event_type.to_s.camelize}Event"
17
+
18
+ has_many scope_name, -> { where(event_type: event_type) }, class_name: class_name
19
+
20
+ define_method "create_#{event_type}_event" do |**metadata|
21
+ events.create!(event_type: event_type, metadata: metadata)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoreMerchant
4
+ module Concerns
5
+ # Includes logic for notifying listeners of subscription events.
6
+ module SubscriptionManagerNotifications
7
+ extend ActiveSupport::Concern
8
+
9
+ included do # rubocop:disable Metrics/BlockLength
10
+ def notify(subscription, event, **options) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
11
+ case event
12
+ when :created
13
+ notify_subscription_created(subscription)
14
+ when :destroyed
15
+ notify_subscription_destroyed(subscription)
16
+ when :started
17
+ notify_subscription_started(subscription)
18
+ when :canceled
19
+ notify_subscription_canceled(subscription, options[:reason], options[:immediate])
20
+ when :due_for_renewal
21
+ notify_subscription_due_for_renewal(subscription)
22
+ when :renewed
23
+ notify_subscription_renewed(subscription)
24
+ when :renewal_payment_processing
25
+ notify_subscription_renewal_payment_processing(subscription)
26
+ when :grace_period_started
27
+ notify_subscription_grace_period_started(subscription, options[:days_remaining])
28
+ when :expired
29
+ notify_subscription_expired(subscription)
30
+ end
31
+ end
32
+
33
+ def notify_test_event
34
+ send_notification_to_listeners(nil, :on_test_event_received)
35
+ end
36
+
37
+ private
38
+
39
+ def notify_subscription_created(subscription)
40
+ send_notification_to_listeners(subscription, :on_subscription_created)
41
+ end
42
+
43
+ def notify_subscription_destroyed(subscription)
44
+ send_notification_to_listeners(subscription, :on_subscription_destroyed)
45
+ end
46
+
47
+ def notify_subscription_started(subscription)
48
+ send_notification_to_listeners(subscription, :on_subscription_started)
49
+ end
50
+
51
+ def notify_subscription_canceled(subscription, reason, immediate)
52
+ send_notification_to_listeners(subscription, :on_subscription_canceled, reason: reason, immediate: immediate)
53
+ end
54
+
55
+ def notify_subscription_due_for_renewal(subscription)
56
+ send_notification_to_listeners(subscription, :on_subscription_due_for_renewal)
57
+ end
58
+
59
+ def notify_subscription_renewed(subscription)
60
+ send_notification_to_listeners(subscription, :on_subscription_renewed)
61
+ end
62
+
63
+ def notify_subscription_renewal_payment_processing(subscription)
64
+ send_notification_to_listeners(subscription, :on_subscription_renewal_payment_processing)
65
+ end
66
+
67
+ def notify_subscription_expired(subscription)
68
+ send_notification_to_listeners(subscription, :on_subscription_expired)
69
+ end
70
+
71
+ def notify_subscription_grace_period_started(subscription, days_remaining)
72
+ send_notification_to_listeners(
73
+ subscription, :on_subscription_grace_period_started,
74
+ days_remaining: days_remaining
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "core_merchant/concerns/subscription_state_machine"
4
4
  require "core_merchant/concerns/subscription_notifications"
5
+ require "core_merchant/concerns/subscription_event_association"
6
+ require "core_merchant/subscription_event"
5
7
 
6
8
  module CoreMerchant
7
9
  # Represents a subscription in CoreMerchant.
@@ -46,15 +48,13 @@ module CoreMerchant
46
48
  # **Usage**:
47
49
  # ```ruby
48
50
  # subscription = CoreMerchant::Subscription.create(customer: user, subscription_plan: plan, status: :active)
51
+ # subscription.start
49
52
  # subscription.cancel(reason: "Too expensive", at_period_end: true)
50
- # subscription.change_plan(new_plan, at_period_end: false)
51
- # subscription.pause(until_date: 1.month.from_now)
52
- # subscription.resume
53
- # subscription.renew(price_cents: 1999)
54
53
  # ```
55
- class Subscription < ActiveRecord::Base
54
+ class Subscription < ActiveRecord::Base # rubocop:disable Metrics/ClassLength
56
55
  include CoreMerchant::Concerns::SubscriptionStateMachine
57
56
  include CoreMerchant::Concerns::SubscriptionNotifications
57
+ include CoreMerchant::Concerns::SubscriptionEventAssociation
58
58
 
59
59
  self.table_name = "core_merchant_subscriptions"
60
60
 
@@ -80,6 +80,12 @@ module CoreMerchant
80
80
  validate :end_date_after_start_date, if: :end_date
81
81
  validate :canceled_at_with_reason, if: :canceled_at
82
82
 
83
+ scope :due_for_renewal,
84
+ lambda {
85
+ where(status: %i[active trial past_due processing_renewal processing_payment])
86
+ .where("current_period_end <= ?", Time.current)
87
+ }
88
+
83
89
  # Starts the subscription.
84
90
  # Sets the current period start and end dates based on the plan's duration.
85
91
  def start
@@ -89,8 +95,8 @@ module CoreMerchant
89
95
  transaction do
90
96
  transition_to_active!
91
97
  update!(
92
- current_period_start: new_period_start,
93
- current_period_end: new_period_end
98
+ current_period_start: new_period_start.to_date,
99
+ current_period_end: new_period_end.to_date
94
100
  )
95
101
  end
96
102
 
@@ -119,6 +125,18 @@ module CoreMerchant
119
125
  notify_subscription_manager(:canceled, reason: reason, immediate: !at_period_end)
120
126
  end
121
127
 
128
+ # Starts a new period for the subscription.
129
+ # This is called by SubscriptionManager when a subscription renewal is successful.
130
+ def start_new_period
131
+ new_period_start = current_period_end
132
+ new_period_end = new_period_start + subscription_plan.duration_in_date
133
+
134
+ update!(
135
+ current_period_start: new_period_start.to_date,
136
+ current_period_end: new_period_end.to_date
137
+ )
138
+ end
139
+
122
140
  # Returns the days remaining in the current period.
123
141
  # Use to show the user how many days are left before the next renewal or
124
142
  # to refund pro-rated amounts for early cancellations.
@@ -151,8 +169,23 @@ module CoreMerchant
151
169
  end
152
170
 
153
171
  def due_for_renewal?
154
- (active? || trial? || past_due? || processing_renewal? || processing_payment?) &&
155
- current_period_end <= Time.current
172
+ renewable? && current_period_end <= Time.current
173
+ end
174
+
175
+ def expired_or_canceled?
176
+ expired? || canceled?
177
+ end
178
+
179
+ def processing?
180
+ processing_renewal? || processing_payment?
181
+ end
182
+
183
+ def ongoing?
184
+ active? || trial? || past_due? || processing_renewal? || processing_payment? || pending_cancellation?
185
+ end
186
+
187
+ def renewable?
188
+ active? || trial? || past_due? || processing?
156
189
  end
157
190
 
158
191
  private
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoreMerchant
4
+ # The `SubscriptionEvent` model represents a historical log of events related to a subscription.
5
+ # It provides an audit trail of all significant actions and state changes for a subscription.
6
+
7
+ # This class has subclasses for specific event types, such as
8
+ # `SubscriptionRenewalEvent`, `SubscriptionStatusChangeEvent`, and `SubscriptionPlanChangeEvent`.
9
+ # Each subclass has additional fields specific to the event type.
10
+
11
+ # **Attributes**:
12
+ # - `subscription`: Association to the related Subscription
13
+ # - `event_type`: Type of the event (e.g., 'created', 'renewed', 'canceled', 'status_changed', 'plan_changed')
14
+ # - `metadata`: JSON field for storing additional event-specific data
15
+
16
+ # **Usage**:
17
+ # ```ruby
18
+ # # Automatically logged when a subscription is created
19
+ # subscription = CoreMerchant::Subscription.create(customer: user, subscription_plan: plan)
20
+
21
+ # # Logging a custom event
22
+ # subscription.log_event('custom_event', key: 'value')
23
+
24
+ # # Retrieve the last renewal event
25
+ # latest_renewal = subscription.renewal_events.last
26
+ # puts "Last renewed at: #{latest_renewal.created_at}"
27
+ # puts "Renewal price: #{latest_renewal.price_cents} cents, renewed until: #{latest_renewal.renewed_until}"
28
+
29
+ # # Retrieve the last event of any type
30
+ # latest_event = subscription.subscription_events.last
31
+ # puts "Last event type: #{latest_event.event_type}, metadata: #{latest_event.metadata}"
32
+ # ```
33
+ class SubscriptionEvent < ActiveRecord::Base
34
+ self.table_name = "core_merchant_subscription_events"
35
+
36
+ belongs_to :subscription, class_name: "CoreMerchant::Subscription"
37
+
38
+ validates :event_type, presence: true
39
+
40
+ def self.event_type
41
+ name.demodulize.underscore
42
+ end
43
+
44
+ def metadata
45
+ value = self[:metadata]
46
+ value.is_a?(Hash) ? value : JSON.parse(value || "{}")
47
+ end
48
+
49
+ def metadata=(value)
50
+ self[:metadata] = value.is_a?(Hash) ? value.to_json : value
51
+ end
52
+ end
53
+
54
+ # Represents a renewal event for a subscription.
55
+ class SubscriptionRenewalEvent < SubscriptionEvent
56
+ def price_cents
57
+ metadata["price_cents"]
58
+ end
59
+
60
+ def price_cents=(value)
61
+ self.metadata = metadata.merge(price_cents: value)
62
+ end
63
+
64
+ def renewed_from
65
+ metadata["renewed_from"].to_date
66
+ end
67
+
68
+ def renewed_from=(value)
69
+ self.metadata = metadata.merge(renewed_from: value)
70
+ end
71
+
72
+ def renewed_until
73
+ metadata["renewed_until"].to_date
74
+ end
75
+
76
+ def renewed_until=(value)
77
+ self.metadata = metadata.merge(renewed_until: value)
78
+ end
79
+ end
80
+
81
+ # Represents a status change event for a subscription.
82
+ class SubscriptionStatusChangeEvent < SubscriptionEvent
83
+ def from
84
+ metadata["from_status"]
85
+ end
86
+
87
+ def from=(value)
88
+ self.metadata = metadata.merge(from_status: value)
89
+ end
90
+
91
+ def to
92
+ metadata["to_status"]
93
+ end
94
+
95
+ def to=(value)
96
+ self.metadata = metadata.merge(to_status: value)
97
+ end
98
+ end
99
+
100
+ # Represents a plan change event for a subscription.
101
+ class SubscriptionPlanChangeEvent < SubscriptionEvent
102
+ def from_plan
103
+ id = metadata["from_plan_id"]
104
+ CoreMerchant::SubscriptionPlan.find(id) if id
105
+ end
106
+
107
+ def from_plan=(value)
108
+ self.metadata = metadata.merge(from_plan_id: value.id)
109
+ end
110
+
111
+ def to_plan
112
+ id = metadata["to_plan_id"]
113
+ CoreMerchant::SubscriptionPlan.find(id) if id
114
+ end
115
+
116
+ def to_plan=(value)
117
+ self.metadata = metadata.merge(to_plan_id: value.id)
118
+ end
119
+ end
120
+
121
+ # Represents a cancellation event for a subscription.
122
+ class SubscriptionCancellationEvent < SubscriptionEvent
123
+ def canceled_at
124
+ created_at
125
+ end
126
+
127
+ def at_period_end?
128
+ metadata["at_period_end"]
129
+ end
130
+
131
+ def at_period_end=(value)
132
+ self.metadata = metadata.merge(at_period_end: value)
133
+ end
134
+
135
+ def reason
136
+ metadata["reason"]
137
+ end
138
+
139
+ def reason=(value)
140
+ self.metadata = metadata.merge(reason: value)
141
+ end
142
+ end
143
+ end
@@ -1,14 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "core_merchant/concerns/subscription_manager_renewals"
3
+ require "core_merchant/concerns/subscription_manager_notifications"
4
4
 
5
5
  module CoreMerchant
6
6
  # Manages subscriptions in CoreMerchant.
7
- # This class is responsible for notifying listeners when subscription events occur.
8
- # Attributes:
7
+ # This class is responsible for notifying listeners when subscription events
8
+ # occur and checking for and handling renewals.
9
+ # **Attributes**:
9
10
  # - `listeners` - An array of listeners that will be notified when subscription events occur.
11
+ #
12
+ # **Methods**:
13
+ # - `check_subscriptions` - Checks all subscriptions for renewals
14
+ # - `add_listener(listener)` - Adds a listener to the list of listeners
15
+ # - `no_payment_needed_for_renewal(subscription)` - Handles the case where no payment is needed for a renewal.
16
+ # Call when a subscription is renewed without payment.
17
+ # - `processing_payment_for_renewal(subscription)` - Handles the case where payment is being processed for a renewal.
18
+ # Call when payment is being processed for a renewal.
19
+ # - `payment_successful_for_renewal(subscription)` - Handles the case where payment was successful for a renewal.
20
+ # Call when payment was successful for a renewal.
21
+ # - `payment_failed_for_renewal(subscription)` - Handles the case where payment failed for a renewal.
22
+ # Call when payment failed for a renewal.
23
+ #
24
+ # **Usage**:
25
+ # ```ruby
26
+ # manager = CoreMerchant.subscription_manager
27
+ # manager.check_subscriptions
28
+ #
29
+ # # ... somewhere else in the code ...
30
+ # manager.payment_successful_for_renewal(subscription1)
31
+ # manager.payment_failed_for_renewal(subscription2)
32
+ # ```
33
+ #
10
34
  class SubscriptionManager
11
- include Concerns::SubscriptionManagerRenewals
35
+ include Concerns::SubscriptionManagerNotifications
12
36
 
13
37
  attr_reader :listeners
14
38
 
@@ -18,82 +42,69 @@ module CoreMerchant
18
42
 
19
43
  def check_subscriptions
20
44
  check_renewals
45
+ check_cancellations
21
46
  end
22
47
 
23
48
  def add_listener(listener)
24
49
  @listeners << listener
25
50
  end
26
51
 
27
- def notify(subscription, event, **options) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
28
- case event
29
- when :created
30
- notify_subscription_created(subscription)
31
- when :destroyed
32
- notify_subscription_destroyed(subscription)
33
- when :started
34
- notify_subscription_started(subscription)
35
- when :canceled
36
- notify_subscription_canceled(subscription, options[:reason], options[:immediate])
37
- when :due_for_renewal
38
- notify_subscription_due_for_renewal(subscription)
39
- when :renewed
40
- notify_subscription_renewed(subscription)
41
- when :renewal_payment_processing
42
- notify_subscription_renewal_payment_processing(subscription)
43
- when :grace_period_started
44
- notify_subscription_grace_period_started(subscription, options[:days_remaining])
45
- when :expired
46
- notify_subscription_expired(subscription)
52
+ def check_renewals
53
+ Subscription.find_each do |subscription|
54
+ process_for_renewal(subscription) if subscription.due_for_renewal?
47
55
  end
48
56
  end
49
57
 
50
- def notify_test_event
51
- send_notification_to_listeners(nil, :on_test_event_received)
52
- end
53
-
54
- private
58
+ def process_for_renewal(subscription)
59
+ return unless subscription.transition_to_processing_renewal
55
60
 
56
- def notify_subscription_created(subscription)
57
- send_notification_to_listeners(subscription, :on_subscription_created)
61
+ notify(subscription, :due_for_renewal)
58
62
  end
59
63
 
60
- def notify_subscription_destroyed(subscription)
61
- send_notification_to_listeners(subscription, :on_subscription_destroyed)
64
+ def no_payment_needed_for_renewal(subscription)
65
+ renew_subscription(subscription)
62
66
  end
63
67
 
64
- def notify_subscription_started(subscription)
65
- send_notification_to_listeners(subscription, :on_subscription_started)
66
- end
68
+ def processing_payment_for_renewal(subscription)
69
+ return unless subscription.transition_to_processing_payment
67
70
 
68
- def notify_subscription_canceled(subscription, reason, immediate)
69
- send_notification_to_listeners(subscription, :on_subscription_canceled, reason: reason, immediate: immediate)
71
+ notify(subscription, :renewal_payment_processing)
70
72
  end
71
73
 
72
- def notify_subscription_due_for_renewal(subscription)
73
- send_notification_to_listeners(subscription, :on_subscription_due_for_renewal)
74
+ def payment_successful_for_renewal(subscription)
75
+ renew_subscription(subscription)
74
76
  end
75
77
 
76
- def notify_subscription_renewed(subscription)
77
- send_notification_to_listeners(subscription, :on_subscription_renewed)
78
+ def payment_failed_for_renewal(subscription)
79
+ is_in_grace_period = subscription.in_grace_period?
80
+ if is_in_grace_period
81
+ subscription.transition_to_past_due
82
+ notify(subscription, :grace_period_started, days_remaining: subscription.days_remaining_in_grace_period)
83
+ else
84
+ subscription.transition_to_expired
85
+ notify(subscription, :expired)
86
+ end
78
87
  end
79
88
 
80
- def notify_subscription_renewal_payment_processing(subscription)
81
- send_notification_to_listeners(subscription, :on_subscription_renewal_payment_processing)
89
+ def check_cancellations
90
+ Subscription.find_each do |subscription|
91
+ process_for_cancellation(subscription) if subscription.pending_cancellation?
92
+ end
82
93
  end
83
94
 
84
- def notify_subscription_expired(subscription)
85
- send_notification_to_listeners(subscription, :on_subscription_expired)
86
- end
95
+ def process_for_cancellation(subscription)
96
+ return unless subscription.transition_to_expired
87
97
 
88
- def notify_subscription_grace_period_started(subscription, days_remaining)
89
- send_notification_to_listeners(
90
- subscription, :on_subscription_grace_period_started,
91
- days_remaining: days_remaining
92
- )
98
+ notify(subscription, :expired)
93
99
  end
94
100
 
95
- def notify_subscription_grace_period_exceeded(subscription)
96
- send_notification_to_listeners(subscription, :on_subscription_grace_period_exceeded)
101
+ private
102
+
103
+ def renew_subscription(subscription)
104
+ return unless subscription.transition_to_active
105
+
106
+ subscription.start_new_period
107
+ notify(subscription, :renewed)
97
108
  end
98
109
 
99
110
  def send_notification_to_listeners(subscription, method_name, **args)
@@ -16,7 +16,7 @@ module CoreMerchant
16
16
  # - `introductory_price_cents`: The introductory price of the subscription plan in cents.
17
17
  # - `introductory_duration`: The duration of the introductory price of the subscription plan.
18
18
  #
19
- # Example:
19
+ # Usage:
20
20
  # ```
21
21
  # plan = CoreMerchant::SubscriptionPlan.new(name_key: "basic_monthly", price_cents: 7_99)
22
22
  # plan.save
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CoreMerchant
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
@@ -28,6 +28,9 @@ module CoreMerchant
28
28
 
29
29
  migration_template "migrate/create_core_merchant_subscriptions.erb",
30
30
  "db/migrate/create_core_merchant_subscriptions.rb"
31
+
32
+ migration_template "migrate/create_core_merchant_subscription_events.erb",
33
+ "db/migrate/create_core_merchant_subscription_events.rb"
31
34
  end
32
35
 
33
36
  def show_post_install
@@ -36,7 +39,7 @@ module CoreMerchant
36
39
  Next steps:
37
40
  1. Set the customer class in the initializer file (config/initializers/core_merchant.rb) to the class you want to use for customers.
38
41
  2. Create a subscription listener class (should include CoreMerchant::SubscriptionListener) in your app and set this class in the initializer file (config/initializers/core_merchant.rb) to the class you want to use for subscription listeners.
39
- 3. Run `rails db:migrate` to create the subscription and subscription plan tables.
42
+ 3. Run `rails db:migrate` to create the subscription, subscription plan, and subscription event tables.
40
43
  MESSAGE
41
44
  say next_steps, :yellow
42
45
  end
@@ -47,7 +50,7 @@ module CoreMerchant
47
50
 
48
51
  def self.description
49
52
  <<~DESC
50
- Installs CoreMerchant into your application. This generator will create an initializer file, migration files for the subscription and subscription plan tables, and a locale file."
53
+ Installs CoreMerchant into your application. This generator will create an initializer file, migration files for the subscription, subscription plan, and subscription event tables, and a locale file.
51
54
  DESC
52
55
  end
53
56
  end
@@ -0,0 +1,14 @@
1
+ # Created by: rails generate core_merchant:install
2
+ class CreateCoreMerchantSubscriptionEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
3
+ def change
4
+ create_table :core_merchant_subscription_events do |t|
5
+ t.references :subscription, null: false, foreign_key: { to_table: :core_merchant_subscriptions }
6
+ t.string :event_type, null: false
7
+ t.jsonb :metadata, default: {}, null: false
8
+
9
+ t.timestamps
10
+
11
+ t.index :event_type
12
+ end
13
+ end
14
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: core_merchant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seyithan Teymur
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-07-23 00:00:00.000000000 Z
11
+ date: 2024-07-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -144,11 +144,13 @@ files:
144
144
  - Rakefile
145
145
  - core_merchant.gemspec
146
146
  - lib/core_merchant.rb
147
- - lib/core_merchant/concerns/subscription_manager_renewals.rb
147
+ - lib/core_merchant/concerns/subscription_event_association.rb
148
+ - lib/core_merchant/concerns/subscription_manager_notifications.rb
148
149
  - lib/core_merchant/concerns/subscription_notifications.rb
149
150
  - lib/core_merchant/concerns/subscription_state_machine.rb
150
151
  - lib/core_merchant/customer_behavior.rb
151
152
  - lib/core_merchant/subscription.rb
153
+ - lib/core_merchant/subscription_event.rb
152
154
  - lib/core_merchant/subscription_listener.rb
153
155
  - lib/core_merchant/subscription_manager.rb
154
156
  - lib/core_merchant/subscription_plan.rb
@@ -156,6 +158,7 @@ files:
156
158
  - lib/generators/core_merchant/install_generator.rb
157
159
  - lib/generators/core_merchant/templates/core_merchant.en.yml
158
160
  - lib/generators/core_merchant/templates/core_merchant.rb
161
+ - lib/generators/core_merchant/templates/migrate/create_core_merchant_subscription_events.erb
159
162
  - lib/generators/core_merchant/templates/migrate/create_core_merchant_subscription_plans.erb
160
163
  - lib/generators/core_merchant/templates/migrate/create_core_merchant_subscriptions.erb
161
164
  - sig/core_merchant.rbs
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CoreMerchant
4
- module Concerns
5
- # Includes logic for renewal processing, grace period handling, and expiration checking.
6
- module SubscriptionManagerRenewals
7
- extend ActiveSupport::Concern
8
-
9
- included do # rubocop:disable Metrics/BlockLength
10
- def check_renewals
11
- Subscription.find_each do |subscription|
12
- process_for_renewal(subscription) if subscription.due_for_renewal?
13
- end
14
- end
15
-
16
- def process_for_renewal(subscription)
17
- return unless subscription.transition_to_processing_renewal
18
-
19
- notify(subscription, :due_for_renewal)
20
- end
21
-
22
- def no_payment_needed_for_renewal(subscription)
23
- return unless subscription.transition_to_active
24
-
25
- notify(subscription, :renewed)
26
- end
27
-
28
- def processing_payment_for_renewal(subscription)
29
- return unless subscription.transition_to_processing_payment
30
-
31
- notify(subscription, :renewal_payment_processing)
32
- end
33
-
34
- def payment_successful_for_renewal(subscription)
35
- return unless subscription.transition_to_active
36
-
37
- notify(subscription, :renewed)
38
- end
39
-
40
- def payment_failed_for_renewal(subscription)
41
- is_in_grace_period = subscription.in_grace_period?
42
- if is_in_grace_period
43
- subscription.transition_to_past_due
44
- notify(subscription, :grace_period_started, days_remaining: subscription.days_remaining_in_grace_period)
45
- else
46
- subscription.transition_to_expired
47
- notify(subscription, :expired)
48
- end
49
- end
50
- end
51
- end
52
- end
53
- end