core_merchant 0.7.0 → 0.9.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/Gemfile.lock +1 -1
- data/README.md +204 -6
- data/lib/core_merchant/concerns/subscription_manager_notifications.rb +80 -0
- data/lib/core_merchant/concerns/subscription_notifications.rb +23 -0
- data/lib/core_merchant/concerns/subscription_state_machine.rb +15 -12
- data/lib/core_merchant/customer_behavior.rb +2 -4
- data/lib/core_merchant/subscription.rb +112 -24
- data/lib/core_merchant/subscription_listener.rb +41 -1
- data/lib/core_merchant/subscription_manager.rb +96 -2
- data/lib/core_merchant/subscription_plan.rb +1 -1
- data/lib/core_merchant/version.rb +1 -1
- data/lib/core_merchant.rb +1 -7
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ff6bdd381d58ecdb8153a14ceff12482d22b55b70a738de1e46df9480f3ab472
|
4
|
+
data.tar.gz: a87c17013865cdbe94f70daf969624f494135955568c0662e86cfe41463a6d88
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 044f0541c54678616eff01372b20b54e4a2bfd3f3d087ba276dc749ce06bc550f6bb047ba90bad084d1edb04f1600774cf5dd681fb56b4c91ef0c8530dbcfa38
|
7
|
+
data.tar.gz: f471e4c215f2d9f36944621e9bb067f9fcde0a6f67d72ee9b6e1cc6a3a2aeb507dd93cdc110bdaf9058be19c7cb700062932609ce9f10437bb5c813c75f0ed82
|
data/.rubocop.yml
CHANGED
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -10,11 +10,33 @@ 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
|
-
- [
|
13
|
+
- [X] Implement subscription manager and callbacks
|
14
14
|
- [ ] Add sidekiq jobs for subscription management
|
15
15
|
- [ ] Add Invoice model
|
16
16
|
- [ ] Add billing and invoicing service
|
17
17
|
|
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
|
+
- [Models](#models)
|
34
|
+
- [SubscriptionManager](#subscriptionmanager)
|
35
|
+
- [SubscriptionPlan](#subscriptionplan)
|
36
|
+
- [Subscription](#subscription)
|
37
|
+
- [Contributing](#contributing)
|
38
|
+
|
39
|
+
|
18
40
|
## Installation
|
19
41
|
Add this line to your application's Gemfile:
|
20
42
|
```
|
@@ -31,14 +53,14 @@ $ gem install core_merchant
|
|
31
53
|
### Initialization
|
32
54
|
Run the generator to create the initializer file and the migrations:
|
33
55
|
```
|
34
|
-
$ rails generate core_merchant:install
|
56
|
+
$ rails generate core_merchant:install
|
35
57
|
```
|
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
58
|
|
38
59
|
This will create the following files:
|
39
60
|
- `config/initializers/core_merchant.rb` - Configuration file
|
61
|
+
- `config/locales/core_merchant.en.yml` - English translations for core merchant models
|
40
62
|
- `db/migrate/xxxxxx_create_core_merchant_subscription_plans.rb` - Migration for subscription plans
|
41
|
-
|
63
|
+
- `db/migrate/xxxxxx_create_core_merchant_subscriptions.rb` - Migration for subscriptions
|
42
64
|
You can then run the migrations:
|
43
65
|
```
|
44
66
|
$ rails db:migrate
|
@@ -46,11 +68,13 @@ $ rails db:migrate
|
|
46
68
|
|
47
69
|
### Configuration
|
48
70
|
The initializer file `config/initializers/core_merchant.rb` contains the following configuration options:
|
71
|
+
|
72
|
+
#### 1. Customer class
|
49
73
|
```ruby
|
50
74
|
config.customer_class = 'User'
|
51
75
|
```
|
52
76
|
|
53
|
-
|
77
|
+
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
78
|
```ruby
|
55
79
|
# app/models/user.rb
|
56
80
|
class User < ApplicationRecord
|
@@ -60,9 +84,127 @@ class User < ApplicationRecord
|
|
60
84
|
end
|
61
85
|
```
|
62
86
|
|
87
|
+
#### 2. Subscription listener class
|
88
|
+
```ruby
|
89
|
+
config.subscription_listener_class = 'MySubscriptionListener'
|
90
|
+
```
|
91
|
+
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:
|
92
|
+
```ruby
|
93
|
+
class MySubscriptionListener
|
94
|
+
include CoreMerchant::SubscriptionListener
|
95
|
+
|
96
|
+
def on_test_event_received
|
97
|
+
puts 'Test event received, hooray!'
|
98
|
+
end
|
99
|
+
end
|
100
|
+
```
|
101
|
+
|
102
|
+
More about subscription events in the [Handling subscription events](#handling-subscription-events) section.
|
103
|
+
|
104
|
+
### Subscription management
|
105
|
+
|
106
|
+
#### Creating a subscription plan
|
107
|
+
You can create a subscription plan using the `SubscriptionPlan` model:
|
108
|
+
```ruby
|
109
|
+
CoreMerchant::SubscriptionPlan.create(name_key: 'basic', price_cents: 10_00, duration: '1m')
|
110
|
+
```
|
111
|
+
|
112
|
+
#### Creating a subscription
|
113
|
+
You can create a subscription for a customer using the `Subscription` model:
|
114
|
+
```ruby
|
115
|
+
customer = User.find(1)
|
116
|
+
plan = CoreMerchant::SubscriptionPlan.find_by(name_key: 'basic')
|
117
|
+
subscription = CoreMerchant::Subscription.create(customer: customer, plan: plan)
|
118
|
+
```
|
119
|
+
|
120
|
+
Note that the subscription will not be active until you start it:
|
121
|
+
```ruby
|
122
|
+
subscription.start
|
123
|
+
```
|
124
|
+
|
125
|
+
#### Cancelling a subscription
|
126
|
+
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:
|
127
|
+
```ruby
|
128
|
+
subscription.cancel(reason: 'Customer request', at_period_end: false)
|
129
|
+
```
|
130
|
+
|
131
|
+
#### Handling subscription events
|
132
|
+
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:
|
133
|
+
```ruby
|
134
|
+
class MySubscriptionListener
|
135
|
+
include CoreMerchant::SubscriptionListener
|
136
|
+
|
137
|
+
|
138
|
+
def on_subscription_created(subscription)
|
139
|
+
FakeEmailService.send_email(subscription.customer.email, "We're happy to have you on board!")
|
140
|
+
|
141
|
+
# You can also start the subscription automatically
|
142
|
+
subscription.start
|
143
|
+
end
|
144
|
+
|
145
|
+
def on_subscription_started(subscription)
|
146
|
+
FakeEmailService.send_email(subscription.customer.email, "Your subscription has started!")
|
147
|
+
end
|
148
|
+
|
149
|
+
def on_subscription_due_for_renewal(subscription)
|
150
|
+
success = FakePaymentService.charge(subscription.customer, subscription.plan.price_cents)
|
151
|
+
|
152
|
+
if success
|
153
|
+
CoreMerchant.subscription_manager.payment_successful_for_renewal(subscription)
|
154
|
+
else
|
155
|
+
FakeEmailService.send_email(subscription.customer.email, "Payment failed, please update your payment method.")
|
156
|
+
CoreMerchant.subscription_manager.payment_failed_for_renewal(subscription)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def on_subscription_renewed(subscription)
|
161
|
+
FakeEmailService.send_email(subscription.customer.email, "Your subscription has been renewed until #{subscription.current_period_end_date}")
|
162
|
+
end
|
163
|
+
end
|
164
|
+
```
|
165
|
+
|
166
|
+
Available subscription events:
|
167
|
+
- `on_subscription_created(subscription)`
|
168
|
+
- `on_subscription_destroyed(subscription)`
|
169
|
+
- `on_subscription_started(subscription)`
|
170
|
+
- `on_subscription_canceled(subscription, reason:, at_period_end:)`
|
171
|
+
- `on_subscription_due_for_renewal(subscription)`
|
172
|
+
- `on_subscription_renewed(subscription)`
|
173
|
+
- `on_subscription_renewal_payment_processing(subscription)`
|
174
|
+
- `on_subscription_grace_period_started(subscription, days_remaining:)`
|
175
|
+
|
63
176
|
## Models
|
177
|
+
### SubscriptionManager
|
178
|
+
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.
|
179
|
+
|
180
|
+
**Attributes**:
|
181
|
+
- `listeners` - An array of listeners that will be notified when subscription events occur.
|
182
|
+
|
183
|
+
**Methods**:
|
184
|
+
- `check_subscriptions` - Checks all subscriptions for renewals
|
185
|
+
- `add_listener(listener)` - Adds a listener to the list of listeners
|
186
|
+
- `no_payment_needed_for_renewal(subscription)` - Handles the case where no payment is needed for a renewal.
|
187
|
+
Call when a subscription is renewed without payment.
|
188
|
+
- `processing_payment_for_renewal(subscription)` - Handles the case where payment is being processed for a renewal.
|
189
|
+
Call when payment is being processed for a renewal.
|
190
|
+
- `payment_successful_for_renewal(subscription)` - Handles the case where payment was successful for a renewal.
|
191
|
+
Call when payment was successful for a renewal.
|
192
|
+
- `payment_failed_for_renewal(subscription)` - Handles the case where payment failed for a renewal.
|
193
|
+
Call when payment failed for a renewal.
|
194
|
+
|
195
|
+
**Usage**:
|
196
|
+
```ruby
|
197
|
+
manager = CoreMerchant.subscription_manager
|
198
|
+
manager.check_subscriptions
|
199
|
+
# ... somewhere else in the code ...
|
200
|
+
manager.payment_successful_for_renewal(subscription1)
|
201
|
+
manager.payment_failed_for_renewal(subscription2)
|
202
|
+
```
|
203
|
+
|
64
204
|
### SubscriptionPlan
|
65
|
-
The `SubscriptionPlan` model represents a subscription plan in your application.
|
205
|
+
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.
|
206
|
+
|
207
|
+
**Attributes**:
|
66
208
|
- `name_key`: A unique key for the subscription plan.
|
67
209
|
This key is used to identify the plan in the application,
|
68
210
|
as well as the translation key for the plan name through `core_merchant.subscription_plans`.
|
@@ -73,6 +215,62 @@ The `SubscriptionPlan` model represents a subscription plan in your application.
|
|
73
215
|
- `introductory_price_cents`: The introductory price of the subscription plan in cents.
|
74
216
|
- `introductory_duration`: The duration of the introductory price of the subscription plan.
|
75
217
|
|
218
|
+
**Usage**:
|
219
|
+
``` ruby
|
220
|
+
plan = CoreMerchant::SubscriptionPlan.new(name_key: "basic_monthly", price_cents: 7_99)
|
221
|
+
plan.save
|
222
|
+
```
|
223
|
+
### Subscription
|
224
|
+
Represents a subscription in CoreMerchant.
|
225
|
+
This class manages the lifecycle of a customer's subscription to a specific plan.
|
226
|
+
|
227
|
+
**Subscriptions can transition through various statuses**:
|
228
|
+
- `pending`: Subscription created but not yet started
|
229
|
+
- `trial`: In a trial period
|
230
|
+
- `active`: Currently active and paid
|
231
|
+
- `past_due`: Payment failed but in grace period
|
232
|
+
- `pending_cancellation`: Will be canceled at period end
|
233
|
+
- `processing_renewal`: Renewal in progress
|
234
|
+
- `processing_payment`: Payment processing
|
235
|
+
- `canceled`: Canceled by user or due to payment failure
|
236
|
+
- `expired`: Subscription period ended
|
237
|
+
- `paused`: Temporarily halted, not yet implemented
|
238
|
+
- `pending_change`: Plan change scheduled for next renewal, not yet implemented
|
239
|
+
|
240
|
+
**Key features**:
|
241
|
+
- Supports immediate and end-of-period cancellations
|
242
|
+
- Allows plan changes, effective immediately or at next renewal
|
243
|
+
- Handles subscription pausing and resuming
|
244
|
+
- Manages trial periods
|
245
|
+
- Supports variable pricing for renewals
|
246
|
+
|
247
|
+
**Attributes**:
|
248
|
+
- `customer`: Polymorphic association to the customer
|
249
|
+
- `subscription_plan`: The current plan for this subscription
|
250
|
+
- `status`: Current status of the subscription (see enum definition)
|
251
|
+
- `start_date`: When the subscription started
|
252
|
+
- `end_date`: When the subscription ended (or will end)
|
253
|
+
- `trial_end_date`: End date of the trial period (if applicable)
|
254
|
+
- `canceled_at`: When the subscription was canceled
|
255
|
+
- `current_period_start`: Start of the current billing period
|
256
|
+
- `current_period_end`: End of the current billing period
|
257
|
+
- `pause_start_date`: When the subscription was paused
|
258
|
+
- `pause_end_date`: When the paused subscription will resume
|
259
|
+
- `current_period_price_cents`: Price for the current period
|
260
|
+
- `next_renewal_price_cents`: Price for the next renewal (if different from plan)
|
261
|
+
- `cancellation_reason`: Reason for cancellation (if applicable)
|
262
|
+
|
263
|
+
**Methods**:
|
264
|
+
- `start` - Starts the subscription
|
265
|
+
- `cancel(reason:, at_period_end:)` - Cancels the subscription, optionally at the end of the current period
|
266
|
+
|
267
|
+
**Usage**:
|
268
|
+
```ruby
|
269
|
+
subscription = CoreMerchant::Subscription.create(customer: user, subscription_plan: plan, status: :active)
|
270
|
+
subscription.start
|
271
|
+
subscription.cancel(reason: "Too expensive", at_period_end: true)
|
272
|
+
```
|
273
|
+
|
76
274
|
> [!NOTE]
|
77
275
|
> Other models and features are being developed and will be added in future releases.
|
78
276
|
|
@@ -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
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
|
5
|
+
module CoreMerchant
|
6
|
+
module Concerns
|
7
|
+
# Includes logic for notifying the SubscriptionManager when a subscription is created or destroyed,
|
8
|
+
# as well as providing a hook for custom notification logic.
|
9
|
+
module SubscriptionNotifications
|
10
|
+
extend ActiveSupport::Concern
|
11
|
+
|
12
|
+
included do
|
13
|
+
# Notify SubscriptionManager on creation and destruction.
|
14
|
+
after_create { notify_subscription_manager(:created) }
|
15
|
+
after_destroy { notify_subscription_manager(:destroyed) }
|
16
|
+
|
17
|
+
def notify_subscription_manager(event, **options)
|
18
|
+
CoreMerchant.subscription_manager.notify(self, event, **options)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -9,26 +9,29 @@ module CoreMerchant
|
|
9
9
|
# Adds state machine logic to a subscription.
|
10
10
|
# This module defines the possible states and transitions for a subscription.
|
11
11
|
# Possible transitions:
|
12
|
-
# - `pending` -> `active`, `trial`
|
13
|
-
# - `trial` -> `active`, `pending_cancellation`, `
|
14
|
-
# - `active` -> `pending_cancellation`, `canceled`, `expired`
|
12
|
+
# - `pending` -> `active`, `trial`, `processing_renewal`
|
13
|
+
# - `trial` -> `processing_renewal`, `active`, `canceled`, `pending_cancellation`, `expired`
|
14
|
+
# - `active` -> `pending_cancellation`, `canceled`, `expired`, `processing_renewal`
|
15
|
+
# - `past_due` -> `processing_renewal`
|
15
16
|
# - `pending_cancellation` -> `canceled`, `expired`
|
16
|
-
# - `
|
17
|
-
# - `
|
17
|
+
# - `processing_renewal` -> `processing_payment`, `active`, `expired`, `past_due`
|
18
|
+
# - `processing_payment` -> `active`, `expired`, `canceled`, `past_due`
|
19
|
+
# - `canceled` -> `pending`, `processing_renewal`
|
20
|
+
# - `expired` -> `pending`, `processing_renewal`
|
18
21
|
module SubscriptionStateMachine
|
19
22
|
extend ActiveSupport::Concern
|
20
23
|
|
21
24
|
# List of possible transitions in the form of { to_state: [from_states] }
|
22
25
|
POSSIBLE_TRANSITIONS = {
|
23
26
|
pending: %i[canceled expired],
|
24
|
-
trial:
|
25
|
-
active: %i[pending trial
|
27
|
+
trial: [:pending],
|
28
|
+
active: %i[pending trial processing_renewal processing_payment],
|
29
|
+
past_due: %i[active processing_renewal processing_payment],
|
26
30
|
pending_cancellation: %i[active trial],
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
pending_change: []
|
31
|
+
processing_renewal: %i[pending trial active past_due canceled expired],
|
32
|
+
processing_payment: [:processing_renewal],
|
33
|
+
canceled: %i[trial active pending_cancellation processing_payment],
|
34
|
+
expired: %i[trial active pending_cancellation processing_renewal processing_payment]
|
32
35
|
}.freeze
|
33
36
|
|
34
37
|
included do
|
@@ -6,6 +6,8 @@ module CoreMerchant
|
|
6
6
|
extend ActiveSupport::Concern
|
7
7
|
|
8
8
|
included do
|
9
|
+
has_many :subscriptions, class_name: "CoreMerchant::Subscription", as: :customer, dependent: :destroy
|
10
|
+
|
9
11
|
def core_merchant_customer_id
|
10
12
|
id
|
11
13
|
end
|
@@ -17,10 +19,6 @@ module CoreMerchant
|
|
17
19
|
def core_merchant_customer_name
|
18
20
|
name if respond_to?(:name)
|
19
21
|
end
|
20
|
-
|
21
|
-
def subscriptions
|
22
|
-
CoreMerchant::Subscription.where(customer_id: core_merchant_customer_id)
|
23
|
-
end
|
24
22
|
end
|
25
23
|
end
|
26
24
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "core_merchant/concerns/subscription_state_machine"
|
4
|
+
require "core_merchant/concerns/subscription_notifications"
|
4
5
|
|
5
6
|
module CoreMerchant
|
6
7
|
# Represents a subscription in CoreMerchant.
|
@@ -10,8 +11,10 @@ module CoreMerchant
|
|
10
11
|
# - `pending`: Subscription created but not yet started
|
11
12
|
# - `trial`: In a trial period
|
12
13
|
# - `active`: Currently active and paid
|
13
|
-
# - `past_due`: Payment failed but in grace period
|
14
|
+
# - `past_due`: Payment failed but in grace period
|
14
15
|
# - `pending_cancellation`: Will be canceled at period end
|
16
|
+
# - `processing_renewal`: Renewal in progress
|
17
|
+
# - `processing_payment`: Payment processing
|
15
18
|
# - `canceled`: Canceled by user or due to payment failure
|
16
19
|
# - `expired`: Subscription period ended
|
17
20
|
# - `paused`: Temporarily halted, not yet implemented
|
@@ -43,14 +46,12 @@ module CoreMerchant
|
|
43
46
|
# **Usage**:
|
44
47
|
# ```ruby
|
45
48
|
# subscription = CoreMerchant::Subscription.create(customer: user, subscription_plan: plan, status: :active)
|
49
|
+
# subscription.start
|
46
50
|
# subscription.cancel(reason: "Too expensive", at_period_end: true)
|
47
|
-
# subscription.change_plan(new_plan, at_period_end: false)
|
48
|
-
# subscription.pause(until_date: 1.month.from_now)
|
49
|
-
# subscription.resume
|
50
|
-
# subscription.renew(price_cents: 1999)
|
51
51
|
# ```
|
52
|
-
class Subscription < ActiveRecord::Base
|
52
|
+
class Subscription < ActiveRecord::Base # rubocop:disable Metrics/ClassLength
|
53
53
|
include CoreMerchant::Concerns::SubscriptionStateMachine
|
54
|
+
include CoreMerchant::Concerns::SubscriptionNotifications
|
54
55
|
|
55
56
|
self.table_name = "core_merchant_subscriptions"
|
56
57
|
|
@@ -61,12 +62,14 @@ module CoreMerchant
|
|
61
62
|
pending: 0,
|
62
63
|
trial: 1,
|
63
64
|
active: 2,
|
64
|
-
past_due:
|
65
|
-
pending_cancellation:
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
65
|
+
past_due: 10,
|
66
|
+
pending_cancellation: 11,
|
67
|
+
processing_renewal: 20,
|
68
|
+
processing_payment: 21,
|
69
|
+
canceled: 30,
|
70
|
+
expired: 31,
|
71
|
+
paused: 40, # Logic not yet implemented
|
72
|
+
pending_change: 50 # Logic not yet implemented
|
70
73
|
}
|
71
74
|
|
72
75
|
validates :customer, :subscription_plan, :status, :start_date, presence: true
|
@@ -74,29 +77,114 @@ module CoreMerchant
|
|
74
77
|
validate :end_date_after_start_date, if: :end_date
|
75
78
|
validate :canceled_at_with_reason, if: :canceled_at
|
76
79
|
|
80
|
+
scope :due_for_renewal,
|
81
|
+
lambda {
|
82
|
+
where(status: %i[active trial past_due processing_renewal processing_payment])
|
83
|
+
.where("current_period_end <= ?", Time.current)
|
84
|
+
}
|
85
|
+
|
86
|
+
# Starts the subscription.
|
87
|
+
# Sets the current period start and end dates based on the plan's duration.
|
77
88
|
def start
|
78
89
|
new_period_start = start_date
|
79
90
|
new_period_end = new_period_start + subscription_plan.duration_in_date
|
80
91
|
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
92
|
+
transaction do
|
93
|
+
transition_to_active!
|
94
|
+
update!(
|
95
|
+
current_period_start: new_period_start.to_date,
|
96
|
+
current_period_end: new_period_end.to_date
|
97
|
+
)
|
98
|
+
end
|
99
|
+
|
100
|
+
notify_subscription_manager(:started)
|
86
101
|
end
|
87
102
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
103
|
+
# Cancels the subscription.
|
104
|
+
# Parameters:
|
105
|
+
# - `reason`: Reason for cancellation
|
106
|
+
# - `at_period_end`: If true, the subscription will be canceled at the end of the current period.
|
107
|
+
# Otherwise, the subscription will be canceled immediately.
|
108
|
+
# Default is `true`.
|
109
|
+
def cancel(reason:, at_period_end: true)
|
110
|
+
transaction do
|
111
|
+
if at_period_end
|
112
|
+
transition_to_pending_cancellation!
|
113
|
+
else
|
114
|
+
transition_to_canceled!
|
115
|
+
end
|
116
|
+
update!(
|
117
|
+
canceled_at: at_period_end ? current_period_end : Time.current,
|
118
|
+
cancellation_reason: reason
|
119
|
+
)
|
93
120
|
end
|
121
|
+
|
122
|
+
notify_subscription_manager(:canceled, reason: reason, immediate: !at_period_end)
|
123
|
+
end
|
124
|
+
|
125
|
+
# Starts a new period for the subscription.
|
126
|
+
# This is called by SubscriptionManager when a subscription renewal is successful.
|
127
|
+
def start_new_period
|
128
|
+
new_period_start = current_period_end
|
129
|
+
new_period_end = new_period_start + subscription_plan.duration_in_date
|
130
|
+
|
94
131
|
update!(
|
95
|
-
|
96
|
-
|
132
|
+
current_period_start: new_period_start.to_date,
|
133
|
+
current_period_end: new_period_end.to_date
|
97
134
|
)
|
98
135
|
end
|
99
136
|
|
137
|
+
# Returns the days remaining in the current period.
|
138
|
+
# Use to show the user how many days are left before the next renewal or
|
139
|
+
# to refund pro-rated amounts for early cancellations.
|
140
|
+
def days_remaining_in_current_period
|
141
|
+
(current_period_end.to_date - Time.current.to_date).to_i
|
142
|
+
end
|
143
|
+
|
144
|
+
# Returns the number of days as a grace period for past-due subscriptions.
|
145
|
+
# By default, this is 3 days.
|
146
|
+
def grace_period
|
147
|
+
3.days
|
148
|
+
end
|
149
|
+
|
150
|
+
def grace_period_end_date
|
151
|
+
current_period_end + grace_period
|
152
|
+
end
|
153
|
+
|
154
|
+
def in_grace_period?
|
155
|
+
due_for_renewal? && Time.current <= grace_period_end_date
|
156
|
+
end
|
157
|
+
|
158
|
+
def days_remaining_in_grace_period
|
159
|
+
return 0 unless due_for_renewal?
|
160
|
+
|
161
|
+
(grace_period_end_date.to_date - Time.current.to_date).to_i
|
162
|
+
end
|
163
|
+
|
164
|
+
def grace_period_exceeded?
|
165
|
+
due_for_renewal? && Time.current > grace_period_end_date
|
166
|
+
end
|
167
|
+
|
168
|
+
def due_for_renewal?
|
169
|
+
renewable? && current_period_end <= Time.current
|
170
|
+
end
|
171
|
+
|
172
|
+
def expired_or_canceled?
|
173
|
+
expired? || canceled?
|
174
|
+
end
|
175
|
+
|
176
|
+
def processing?
|
177
|
+
processing_renewal? || processing_payment?
|
178
|
+
end
|
179
|
+
|
180
|
+
def ongoing?
|
181
|
+
active? || trial? || past_due? || processing_renewal? || processing_payment? || pending_cancellation?
|
182
|
+
end
|
183
|
+
|
184
|
+
def renewable?
|
185
|
+
active? || trial? || past_due? || processing?
|
186
|
+
end
|
187
|
+
|
100
188
|
private
|
101
189
|
|
102
190
|
def end_date_after_start_date
|
@@ -5,10 +5,50 @@ module CoreMerchant
|
|
5
5
|
module SubscriptionListener
|
6
6
|
extend ActiveSupport::Concern
|
7
7
|
|
8
|
-
included do
|
8
|
+
included do # rubocop:disable Metrics/BlockLength
|
9
9
|
def on_test_event_received
|
10
10
|
puts "Test event received by CoreMerchant::SubscriptionListener. Override this method in your application."
|
11
11
|
end
|
12
|
+
|
13
|
+
# rubocop:disable Metrics/LineLength
|
14
|
+
|
15
|
+
def on_subscription_created(subscription)
|
16
|
+
puts "Subscription (#{subscription.id}) created. Override #{__method__} in your application to handle this event."
|
17
|
+
end
|
18
|
+
|
19
|
+
def on_subscription_destroyed(subscription)
|
20
|
+
puts "Subscription (#{subscription.id}) destroyed. Override #{__method__} in your application to handle this event."
|
21
|
+
end
|
22
|
+
|
23
|
+
def on_subscription_started(subscription)
|
24
|
+
puts "Subscription (#{subscription.id}) started. Override #{__method__} in your application to handle this event."
|
25
|
+
end
|
26
|
+
|
27
|
+
def on_subscription_canceled(subscription, reason:, immediate:)
|
28
|
+
puts "Subscription (#{subscription.id}) canceled with reason '#{reason}' and immediate=#{immediate}. Override #{__method__} in your application to handle this event."
|
29
|
+
end
|
30
|
+
|
31
|
+
def on_subscription_due_for_renewal(subscription)
|
32
|
+
puts "Subscription (#{subscription.id}) is due for renewal. Override #{__method__} in your application to handle this event."
|
33
|
+
end
|
34
|
+
|
35
|
+
def on_subscription_grace_period_(subscription, days_remaining)
|
36
|
+
puts "Subscription (#{subscription.id}) has entered a grace period with #{days_remaining} days remaining. Override #{__method__} in your application to handle this event."
|
37
|
+
end
|
38
|
+
|
39
|
+
def on_subscription_renewed(subscription)
|
40
|
+
puts "Subscription (#{subscription.id}) renewed. Override #{__method__} in your application to handle this event."
|
41
|
+
end
|
42
|
+
|
43
|
+
def on_subscription_renewal_payment_processing(subscription)
|
44
|
+
puts "Subscription (#{subscription.id}) renewal payment processing. Override #{__method__} in your application to handle this event."
|
45
|
+
end
|
46
|
+
|
47
|
+
def on_subscription_expired(subscription)
|
48
|
+
puts "Subscription (#{subscription.id}) expired. Override #{__method__} in your application to handle this event."
|
49
|
+
end
|
50
|
+
|
51
|
+
# rubocop:enable Metrics/LineLength
|
12
52
|
end
|
13
53
|
end
|
14
54
|
end
|
@@ -1,21 +1,115 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "core_merchant/concerns/subscription_manager_notifications"
|
4
|
+
|
3
5
|
module CoreMerchant
|
4
6
|
# Manages subscriptions in CoreMerchant.
|
7
|
+
# This class is responsible for notifying listeners when subscription events
|
8
|
+
# occur and checking for and handling renewals.
|
9
|
+
# **Attributes**:
|
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
|
+
#
|
5
34
|
class SubscriptionManager
|
35
|
+
include Concerns::SubscriptionManagerNotifications
|
36
|
+
|
6
37
|
attr_reader :listeners
|
7
38
|
|
8
39
|
def initialize
|
9
40
|
@listeners = []
|
10
41
|
end
|
11
42
|
|
43
|
+
def check_subscriptions
|
44
|
+
check_renewals
|
45
|
+
check_cancellations
|
46
|
+
end
|
47
|
+
|
12
48
|
def add_listener(listener)
|
13
49
|
@listeners << listener
|
14
50
|
end
|
15
51
|
|
16
|
-
def
|
52
|
+
def check_renewals
|
53
|
+
Subscription.find_each do |subscription|
|
54
|
+
process_for_renewal(subscription) if subscription.due_for_renewal?
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def process_for_renewal(subscription)
|
59
|
+
return unless subscription.transition_to_processing_renewal
|
60
|
+
|
61
|
+
notify(subscription, :due_for_renewal)
|
62
|
+
end
|
63
|
+
|
64
|
+
def no_payment_needed_for_renewal(subscription)
|
65
|
+
renew_subscription(subscription)
|
66
|
+
end
|
67
|
+
|
68
|
+
def processing_payment_for_renewal(subscription)
|
69
|
+
return unless subscription.transition_to_processing_payment
|
70
|
+
|
71
|
+
notify(subscription, :renewal_payment_processing)
|
72
|
+
end
|
73
|
+
|
74
|
+
def payment_successful_for_renewal(subscription)
|
75
|
+
renew_subscription(subscription)
|
76
|
+
end
|
77
|
+
|
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
|
87
|
+
end
|
88
|
+
|
89
|
+
def check_cancellations
|
90
|
+
Subscription.find_each do |subscription|
|
91
|
+
process_for_cancellation(subscription) if subscription.pending_cancellation?
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def process_for_cancellation(subscription)
|
96
|
+
return unless subscription.transition_to_expired
|
97
|
+
|
98
|
+
notify(subscription, :expired)
|
99
|
+
end
|
100
|
+
|
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)
|
108
|
+
end
|
109
|
+
|
110
|
+
def send_notification_to_listeners(subscription, method_name, **args)
|
17
111
|
@listeners.each do |listener|
|
18
|
-
listener.
|
112
|
+
listener.send(method_name, subscription, **args) if listener.respond_to?(method_name)
|
19
113
|
end
|
20
114
|
end
|
21
115
|
end
|
@@ -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
|
-
#
|
19
|
+
# Usage:
|
20
20
|
# ```
|
21
21
|
# plan = CoreMerchant::SubscriptionPlan.new(name_key: "basic_monthly", price_cents: 7_99)
|
22
22
|
# plan.save
|
data/lib/core_merchant.rb
CHANGED
@@ -47,15 +47,9 @@ module CoreMerchant
|
|
47
47
|
end
|
48
48
|
|
49
49
|
# Default customer class in CoreMerchant. Use this class if you don't have a model for customers in your application.
|
50
|
-
class Customer
|
50
|
+
class Customer < ActiveRecord::Base
|
51
51
|
include CustomerBehavior
|
52
52
|
|
53
53
|
attr_accessor :id, :email, :name
|
54
|
-
|
55
|
-
def initialize(id:, email:, name: nil)
|
56
|
-
@id = id
|
57
|
-
@email = email
|
58
|
-
@name = name
|
59
|
-
end
|
60
54
|
end
|
61
55
|
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.
|
4
|
+
version: 0.9.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-
|
11
|
+
date: 2024-07-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -144,6 +144,8 @@ files:
|
|
144
144
|
- Rakefile
|
145
145
|
- core_merchant.gemspec
|
146
146
|
- lib/core_merchant.rb
|
147
|
+
- lib/core_merchant/concerns/subscription_manager_notifications.rb
|
148
|
+
- lib/core_merchant/concerns/subscription_notifications.rb
|
147
149
|
- lib/core_merchant/concerns/subscription_state_machine.rb
|
148
150
|
- lib/core_merchant/customer_behavior.rb
|
149
151
|
- lib/core_merchant/subscription.rb
|