-
Notifications
You must be signed in to change notification settings - Fork 100
/
subscription_service.rb
280 lines (227 loc) · 10.5 KB
/
subscription_service.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# frozen_string_literal: true
module Fees
class SubscriptionService < BaseService
def initialize(invoice:, subscription:, boundaries:)
@invoice = invoice
@subscription = subscription
@boundaries = OpenStruct.new(boundaries)
super(nil)
end
def create
return result if already_billed?
new_precise_amount_cents = compute_amount
new_amount_cents = new_precise_amount_cents.round
new_fee = initialize_fee(new_amount_cents, new_precise_amount_cents)
ActiveRecord::Base.transaction do
new_fee.save!
adjusted_fee.update!(fee: new_fee) if invoice.draft? && adjusted_fee
end
result.fee = new_fee
result
rescue ActiveRecord::RecordInvalid => e
result.record_validation_failure!(record: e.record)
end
private
attr_reader :invoice, :subscription, :boundaries
delegate :customer, :organization, to: :invoice
delegate :previous_subscription, :plan, to: :subscription
def initialize_fee(new_amount_cents, new_precise_amount_cents)
base_fee = Fee.new(
invoice:,
organization:,
subscription:,
amount_cents: new_amount_cents,
precise_amount_cents: new_precise_amount_cents.to_d,
amount_currency: plan.amount_currency,
fee_type: :subscription,
invoiceable_type: 'Subscription',
invoiceable: subscription,
units: 1,
properties: boundaries.to_h,
payment_status: :pending,
taxes_amount_cents: 0,
unit_amount_cents: new_amount_cents,
amount_details: {plan_amount_cents: plan.amount_cents}
)
base_fee.precise_unit_amount = base_fee.unit_amount.to_f
return base_fee if !invoice.draft? || !adjusted_fee
if adjusted_fee.adjusted_display_name?
base_fee.invoice_display_name = adjusted_fee.invoice_display_name
return base_fee
end
units = adjusted_fee.units
unit_precise_amount_cents = adjusted_fee.unit_precise_amount_cents
amount_cents = adjusted_fee.adjusted_units? ? (units * new_amount_cents) : (units * unit_precise_amount_cents).round
precise_amount_cents = if adjusted_fee.adjusted_units?
(units * new_precise_amount_cents)
else
(units * unit_precise_amount_cents)
end
base_fee.amount_cents = amount_cents.round
base_fee.precise_amount_cents = precise_amount_cents
base_fee.units = units
precise_unit_amount_cents = adjusted_fee.adjusted_units? ? new_amount_cents : unit_precise_amount_cents
base_fee.unit_amount_cents = precise_unit_amount_cents.round
base_fee.precise_unit_amount = precise_unit_amount_cents / invoice.total_amount.currency.subunit_to_unit
base_fee.invoice_display_name = adjusted_fee.invoice_display_name
base_fee
end
def adjusted_fee
return @adjusted_fee if defined? @adjusted_fee
@adjusted_fee = AdjustedFee
.where(invoice:, subscription:, fee_type: :subscription)
.where("properties->>'from_datetime' = ?", boundaries.from_datetime&.iso8601(3))
.where("properties->>'to_datetime' = ?", boundaries.to_datetime&.iso8601(3))
.first
end
def already_billed?
existing_fee = invoice.fees.subscription.find_by(subscription_id: subscription.id)
return false unless existing_fee
result.fee = existing_fee
true
end
def compute_amount
# NOTE: bill for the last time a subscription that was upgraded
return terminated_amount if should_compute_terminated_amount?
# NOTE: bill for the first time a subscription created after an upgrade
return upgraded_amount if should_compute_upgraded_amount?
# NOTE: bill a subscription on a full period
return full_period_amount if should_use_full_amount?
# NOTE: bill a subscription for the first time (or after downgrade)
first_subscription_amount
end
def should_compute_terminated_amount?
return false unless subscription.terminated?
return false if subscription.plan.pay_in_advance?
subscription.upgraded? || subscription.next_subscription.nil?
end
def should_compute_upgraded_amount?
return false unless subscription.previous_subscription_id?
return false if subscription.invoices.count > 1
subscription.previous_subscription.upgraded?
end
# NOTE: Subscription has already been billed once and is not terminated
# or when it is payed in advance on an anniversary base
def should_use_full_amount?
# First condition covers case when plan is pay in advance and on anniversary base.
# This case is used for the first subscription invoice since following cases will cover recurring invoices.
# However, we should not bill full amount if subscription is downgraded since in that case, first invoice
# should be prorated (this part is covered with first_subscription_amount method).
return true if plan.pay_in_advance? && subscription.anniversary? && !subscription.previous_subscription_id
return true if subscription.fees.subscription.where('created_at < ?', invoice.created_at).exists?
return true if subscription.started_in_past? && plan.pay_in_advance?
if subscription.started_in_past? &&
subscription.started_at < date_service(subscription).previous_beginning_of_period
return true
end
false
end
def first_subscription_amount
from_datetime = boundaries.from_datetime
to_datetime = boundaries.to_datetime
if plan.has_trial?
# NOTE: amount is 0 if trial cover the full period
return 0 if subscription.trial_end_date >= to_datetime
# NOTE: from_date is the trial end date if it happens during the period
if (subscription.trial_end_date > from_datetime) && (subscription.trial_end_date < to_datetime)
from_datetime = subscription.initial_started_at + plan.trial_period.days
end
end
# NOTE: Number of days of the first period since subscription creation
days_to_bill = Utils::Datetime.date_diff_with_timezone(
from_datetime,
to_datetime,
customer.applicable_timezone
)
days_to_bill * single_day_price(subscription)
end
# NOTE: When terminating a pay in arrerar subscription, we need to
# bill the number of used day of the terminated subscription.
#
# The amount to bill is computed with:
# **nb_day** = number of days between beggining of the period and the termination date
# **day_cost** = (plan amount_cents / full period duration)
# amount_to_bill = (nb_day * day_cost)
def terminated_amount
from_datetime = boundaries.from_datetime
to_datetime = boundaries.to_datetime
if plan.has_trial?
# NOTE: amount is 0 if trial cover the full period
return 0 if subscription.trial_end_datetime >= to_datetime
# NOTE: from_date is the trial end date if it happens during the period
if (subscription.trial_end_datetime > from_datetime) && (subscription.trial_end_datetime < to_datetime)
from_datetime = subscription.trial_end_datetime
end
end
# NOTE: number of days between beginning of the period and the termination date
number_of_day_to_bill = subscription.date_diff_with_timezone(from_datetime, to_datetime)
# Remove later customer timezone fix while passing optional_from_date
# single_day_price method should return correct amount even without the timezone fix since
# date service should not calculate single_day_price based on difference between dates but more as a
# difference between date-times
number_of_day_to_bill *
single_day_price(
subscription,
optional_from_date: from_datetime.in_time_zone(customer.applicable_timezone).to_date
)
end
def upgraded_amount
from_datetime = boundaries.from_datetime
to_datetime = boundaries.to_datetime
if plan.has_trial?
return 0 if subscription.trial_end_datetime >= to_datetime
# NOTE: from_date is the trial end date if it happens during the period
if (subscription.trial_end_datetime > from_datetime) && (subscription.trial_end_datetime < to_datetime)
from_datetime = subscription.trial_end_datetime
end
end
# NOTE: number of days between the upgrade and the end of the period
number_of_day_to_bill = Utils::Datetime.date_diff_with_timezone(
from_datetime,
to_datetime,
customer.applicable_timezone
)
# NOTE: Subscription is upgraded from another plan
# We only bill the days between the upgrade date and the end of the period
# A credit note will apply automatically the amount of days from previous plan that were not consumed
#
# The amount to bill is computed with:
# **nb_day** = number of days between upgrade and the end of the period
# **day_cost** = (plan amount_cents / full period duration)
# amount_to_bill = (nb_day * day_cost)
number_of_day_to_bill * single_day_price(subscription)
end
def full_period_amount
from_datetime = boundaries.from_datetime
to_datetime = boundaries.to_datetime
if plan.has_trial?
# NOTE: amount is 0 if trial cover the full period
return 0 if subscription.trial_end_datetime >= to_datetime
# NOTE: from_date is the trial end date if it happens during the period
# for this case, we should not apply the full period amount
# but the prorata between the trial end date end the invoice to_date
if (subscription.trial_end_datetime > from_datetime) && (subscription.trial_end_datetime < to_datetime)
number_of_day_to_bill = Utils::Datetime.date_diff_with_timezone(
subscription.trial_end_datetime,
to_datetime,
customer.applicable_timezone
)
return number_of_day_to_bill * single_day_price(
subscription,
optional_from_date: from_datetime.in_time_zone(customer.applicable_timezone).to_date
)
end
end
plan.amount_cents
end
def date_service(subscription)
Subscriptions::DatesService.new_instance(subscription, Time.zone.at(boundaries.timestamp))
end
# NOTE: cost of a single day in a period
def single_day_price(target_subscription, optional_from_date: nil)
date_service(target_subscription).single_day_price(
optional_from_date:
)
end
end
end