billingly 0.0.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.
- data/MIT-LICENSE +20 -0
- data/README.rdoc +3 -0
- data/Rakefile +24 -0
- data/app/controllers/billingly/subscriptions_controller.rb +48 -0
- data/app/mailers/billingly_mailer.rb +20 -0
- data/app/models/billingly/customer.rb +116 -0
- data/app/models/billingly/invoice.rb +120 -0
- data/app/models/billingly/invoice_item.rb +4 -0
- data/app/models/billingly/ledger_entry.rb +16 -0
- data/app/models/billingly/one_time_charge.rb +5 -0
- data/app/models/billingly/payment.rb +19 -0
- data/app/models/billingly/plan.rb +5 -0
- data/app/models/billingly/receipt.rb +9 -0
- data/app/models/billingly/subscription.rb +82 -0
- data/app/views/billingly/subscriptions/create.html.erb +1 -0
- data/app/views/billingly/subscriptions/index.html.erb +2 -0
- data/app/views/billingly/subscriptions/new.html.erb +1 -0
- data/app/views/billingly_mailer/overdue_notification.html.erb +21 -0
- data/app/views/billingly_mailer/paid_notification.html.erb +10 -0
- data/app/views/billingly_mailer/pending_notification.html.erb +17 -0
- data/app/views/billingly_mailer/pending_notification.plain.erb +17 -0
- data/app/views/billingly_mailer/pending_notification.text.erb +17 -0
- data/config/locales/en.yml +23 -0
- data/lib/billingly.rb +4 -0
- data/lib/billingly/engine.rb +45 -0
- data/lib/billingly/rails/routes.rb +12 -0
- data/lib/billingly/version.rb +3 -0
- data/lib/generators/billingly_migration_generator.rb +20 -0
- data/lib/generators/templates/create_billingly_tables.rb +71 -0
- data/lib/tasks/billingly_tasks.rake +21 -0
- metadata +143 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2012 YOURNAME
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
data/Rakefile
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
begin
|
3
|
+
require 'bundler/setup'
|
4
|
+
rescue LoadError
|
5
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
6
|
+
end
|
7
|
+
begin
|
8
|
+
require 'rdoc/task'
|
9
|
+
rescue LoadError
|
10
|
+
require 'rdoc/rdoc'
|
11
|
+
require 'rake/rdoctask'
|
12
|
+
RDoc::Task = Rake::RDocTask
|
13
|
+
end
|
14
|
+
|
15
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
16
|
+
rdoc.rdoc_dir = 'rdoc'
|
17
|
+
rdoc.title = 'RailsSubscriptionBilling'
|
18
|
+
rdoc.options << '--line-numbers'
|
19
|
+
rdoc.rdoc_files.include('README.rdoc')
|
20
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
21
|
+
end
|
22
|
+
|
23
|
+
Bundler::GemHelper.install_tasks
|
24
|
+
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# This controller takes care of managing subscriptions.
|
2
|
+
class Billingly::SubscriptionsController < ::ApplicationController
|
3
|
+
before_filter :requires_customer, only: [:index, :reactivate]
|
4
|
+
before_filter :requires_active_customer, except: [:index, :reactivate]
|
5
|
+
|
6
|
+
# Index shows the current subscription to customers while they are active.
|
7
|
+
# It's also the page that prompts them to reactivate their account when deactivated.
|
8
|
+
# It's likely the only reachable page for deactivated customers.
|
9
|
+
def index
|
10
|
+
@subscription = current_customer.active_subscription
|
11
|
+
redirect_to(action: :new) unless @subscription
|
12
|
+
end
|
13
|
+
|
14
|
+
# Should let customers choose a plan to subscribe to, wheter they are subscribing
|
15
|
+
# for the first time or upgrading their plan.
|
16
|
+
def new
|
17
|
+
@plans = Billingly::Plan.all
|
18
|
+
end
|
19
|
+
|
20
|
+
# Subscribe the customer to a plan, or change his current plan.
|
21
|
+
def create
|
22
|
+
plan = Billingly::Plan.find(params[:plan_id])
|
23
|
+
current_customer.subscribe_to_plan(plan)
|
24
|
+
on_subscription_success
|
25
|
+
end
|
26
|
+
|
27
|
+
# Action to reactivate an account for a customer that left voluntarily and does
|
28
|
+
# not owe any money to us.
|
29
|
+
# Their account will be reactivated to their old subscription plan immediately.
|
30
|
+
# They can change plans afterwards.
|
31
|
+
def reactivate
|
32
|
+
return render nothing: true, status: 403 unless current_customer.reactivate
|
33
|
+
on_reactivation_success
|
34
|
+
end
|
35
|
+
|
36
|
+
# When a subscription is sucessful this callback is triggered.
|
37
|
+
# Host applications should override it by subclassing this subscriptionscontroller,
|
38
|
+
# and include their own behaviour, and for example grant the privileges associated
|
39
|
+
# to the subscription plan.
|
40
|
+
def on_subscription_success
|
41
|
+
redirect_to(action: :index)
|
42
|
+
end
|
43
|
+
|
44
|
+
def on_reactivation_success
|
45
|
+
on_subscription_success
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class BillinglyMailer < ActionMailer::Base
|
2
|
+
default from: 'example@example.com'
|
3
|
+
|
4
|
+
def pending_notification(invoice)
|
5
|
+
@invoice = invoice
|
6
|
+
@cash = invoice.customer.ledger[:cash]
|
7
|
+
mail(to: invoice.customer.email, subject: I18n.t('billingly.your_invoice_is_available'))
|
8
|
+
end
|
9
|
+
|
10
|
+
def overdue_notification(invoice)
|
11
|
+
@invoice = invoice
|
12
|
+
@cash = invoice.customer.ledger[:cash]
|
13
|
+
mail(to: invoice.customer.email, subject: I18n.t('billingly.your_account_was_suspended'))
|
14
|
+
end
|
15
|
+
|
16
|
+
def paid_notification(invoice)
|
17
|
+
@invoice = invoice
|
18
|
+
mail(to: invoice.customer.email, subject: I18n.t('billingly.payment_receipt'))
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# A Customer is the main entity of Billingly:
|
2
|
+
# * Customers are subscribed to plans
|
3
|
+
# * Customers are charged for one-time expenses.
|
4
|
+
# * Payments are received on a Customer's behalf and credited to their account.
|
5
|
+
# * Invoices are generated periodically calculating charges a Customer incurred in.
|
6
|
+
# * Receipts are sent to Customers when their invoices are paid.
|
7
|
+
|
8
|
+
require 'validates_email_format_of'
|
9
|
+
|
10
|
+
module Billingly
|
11
|
+
class Customer < ActiveRecord::Base
|
12
|
+
has_many :subscriptions
|
13
|
+
has_many :one_time_charges
|
14
|
+
has_many :invoices
|
15
|
+
has_many :ledger_entries
|
16
|
+
|
17
|
+
attr_accessible :email
|
18
|
+
validates_email_format_of :email
|
19
|
+
|
20
|
+
# Customers subscribe to the service and perform periodic payments to continue using it.
|
21
|
+
# We offer common plans stating how much and how often they should pay, also, if the
|
22
|
+
# payment is to be done at the beginning or end of the period (upfront or due-month)
|
23
|
+
# Every customer can potentially get a special deal, but we offer common
|
24
|
+
# deals as 'plans' from which a proper subscription is created.
|
25
|
+
def subscribe_to_plan(plan)
|
26
|
+
subscriptions.last.terminate if subscriptions.last
|
27
|
+
|
28
|
+
subscriptions.build.tap do |new|
|
29
|
+
[:payable_upfront, :description, :periodicity, :amount].each do |k|
|
30
|
+
new[k] = plan[k]
|
31
|
+
end
|
32
|
+
new.subscribed_on = Time.now
|
33
|
+
new.save!
|
34
|
+
new.generate_next_invoice
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Returns the actual subscription of the customer. while working with the
|
39
|
+
# customer API a customer should only have 1 active subscription at a time.
|
40
|
+
def active_subscription
|
41
|
+
subscriptions.last
|
42
|
+
end
|
43
|
+
|
44
|
+
# Every transaction is registered in the journal from where a general ledger can
|
45
|
+
# be retrieved.
|
46
|
+
# Due to silly rounding errors on sqlite we need to convert decimals to float and then to
|
47
|
+
# decimals again. :S
|
48
|
+
def ledger
|
49
|
+
Hash.new(0.0).tap do |all|
|
50
|
+
ledger_entries.group_by(&:account).collect do |account, entries|
|
51
|
+
values = entries.collect(&:amount).collect(&:to_f)
|
52
|
+
all[account.to_sym] = values.inject(0.0) do |sum,item|
|
53
|
+
(BigDecimal.new(sum.to_s) + BigDecimal.new(item.to_s)).to_f
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Shortcut for adding ledger_entries for a particular customer.
|
60
|
+
def add_to_ledger(amount, *accounts, extra)
|
61
|
+
accounts = [] if accounts.nil?
|
62
|
+
unless extra.is_a?(Hash)
|
63
|
+
accounts << extra
|
64
|
+
extra = {}
|
65
|
+
end
|
66
|
+
|
67
|
+
accounts.each do |account|
|
68
|
+
ledger_entries.create!(extra.merge(amount: amount, account: account.to_s))
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# This class method is run periodically deactivate all customers who have overdue invoices.
|
73
|
+
def self.deactivate_all_debtors
|
74
|
+
debtors.where(deactivated_since: nil).all.each{|debtor| debtor.deactivate }
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.debtors
|
78
|
+
joins(:invoices).readonly(false)
|
79
|
+
.where("#{Billingly::Invoice.table_name}.due_on < ?", Time.now)
|
80
|
+
.where(billingly_invoices: {deleted_on: nil, receipt_id: nil})
|
81
|
+
end
|
82
|
+
|
83
|
+
# Credits a payment for a customer, settling invoices if possible.
|
84
|
+
def credit_payment(amount)
|
85
|
+
Billingly::Payment.credit_for(self, amount)
|
86
|
+
Billingly::Invoice.charge_all(self.invoices)
|
87
|
+
reactivate
|
88
|
+
end
|
89
|
+
|
90
|
+
def deactivate
|
91
|
+
return if deactivated?
|
92
|
+
active_subscription.terminate
|
93
|
+
update_attribute(:deactivated_since, Time.now)
|
94
|
+
return self
|
95
|
+
end
|
96
|
+
|
97
|
+
# Reactivates a customer that was deactivated when missed a previous payment.
|
98
|
+
# The new subscription is parametrized the same as the old one. The old subscription
|
99
|
+
# is terminated.
|
100
|
+
def reactivate(new_plan = active_subscription)
|
101
|
+
return unless deactivated?
|
102
|
+
return if debtor?
|
103
|
+
update_attribute(:deactivated_since, nil)
|
104
|
+
subscribe_to_plan(new_plan)
|
105
|
+
return self
|
106
|
+
end
|
107
|
+
|
108
|
+
def deactivated?
|
109
|
+
not deactivated_since.nil?
|
110
|
+
end
|
111
|
+
|
112
|
+
def debtor?
|
113
|
+
not self.class.debtors.find_by_id(self.id).nil?
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Billingly
|
2
|
+
class Invoice < ActiveRecord::Base
|
3
|
+
belongs_to :subscription
|
4
|
+
belongs_to :customer
|
5
|
+
belongs_to :receipt
|
6
|
+
has_many :ledger_entries
|
7
|
+
attr_accessible :customer, :amount, :due_on, :period_start, :period_end
|
8
|
+
|
9
|
+
def paid?
|
10
|
+
not receipt.nil?
|
11
|
+
end
|
12
|
+
|
13
|
+
def deleted?
|
14
|
+
not deleted_on.nil?
|
15
|
+
end
|
16
|
+
|
17
|
+
# Settle an invoice by moving money from the cash balance into an expense,
|
18
|
+
# generating a receipt and marking the invoice as paid.
|
19
|
+
def charge
|
20
|
+
return if paid?
|
21
|
+
return unless deleted_on.nil?
|
22
|
+
return if customer.ledger[:cash] < amount
|
23
|
+
|
24
|
+
receipt = create_receipt!(customer: customer, paid_on: Time.now)
|
25
|
+
extra = {receipt: receipt, subscription: subscription}
|
26
|
+
customer.add_to_ledger(-(amount), :cash, extra)
|
27
|
+
customer.add_to_ledger(amount, :spent, extra)
|
28
|
+
|
29
|
+
save! # save receipt
|
30
|
+
return receipt
|
31
|
+
end
|
32
|
+
|
33
|
+
# When a subscription terminates, it's last invoice gets truncated so that it does
|
34
|
+
# not extend beyond the duration of the subscription.
|
35
|
+
def truncate
|
36
|
+
return if Time.now > self.period_end
|
37
|
+
old_amount = self.amount
|
38
|
+
new_amount = if self.period_start < Time.now
|
39
|
+
whole_period = self.period_end - self.period_start
|
40
|
+
used_period = Time.now - self.period_start
|
41
|
+
self.period_end = Time.now
|
42
|
+
used_period.round * old_amount / whole_period.round
|
43
|
+
else
|
44
|
+
self.deleted_on = Time.now
|
45
|
+
0
|
46
|
+
end
|
47
|
+
self.amount = BigDecimal.new(new_amount.round(2).to_s)
|
48
|
+
if paid?
|
49
|
+
reimburse = (old_amount - self.amount).round(2)
|
50
|
+
extra = {invoice: self, subscription: subscription}
|
51
|
+
customer.add_to_ledger(reimburse, :cash, extra)
|
52
|
+
customer.add_to_ledger(-(reimburse), :spent, extra)
|
53
|
+
end
|
54
|
+
save!
|
55
|
+
self.charge
|
56
|
+
return self
|
57
|
+
end
|
58
|
+
|
59
|
+
# Charges all invoices that can be charged from the existing customer cash balances
|
60
|
+
def self.charge_all(collection = self)
|
61
|
+
collection.where(deleted_on: nil, receipt_id: nil).order('period_start').each do |invoice|
|
62
|
+
invoice.charge
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Send the email notifying that this invoice is due soon and should be paid.
|
67
|
+
def self.notify_all_pending
|
68
|
+
where('due_on < ?', Billingly::Subscription::GRACE_PERIOD.from_now)
|
69
|
+
.where(deleted_on: nil, receipt_id: nil, notified_pending_on: nil)
|
70
|
+
.each do |invoice|
|
71
|
+
invoice.notify_pending
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def notify_pending
|
76
|
+
return unless notified_pending_on.nil?
|
77
|
+
return if paid?
|
78
|
+
return if deleted?
|
79
|
+
return if due_on > Billingly::Subscription::GRACE_PERIOD.from_now
|
80
|
+
BillinglyMailer.pending_notification(self).deliver!
|
81
|
+
update_attribute(:notified_pending_on, Time.now)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Send the email notifying that this invoice being overdue and the subscription
|
85
|
+
# being cancelled
|
86
|
+
def self.notify_all_overdue
|
87
|
+
where('due_on <= ?', Time.now)
|
88
|
+
.where(deleted_on: nil, receipt_id: nil, notified_overdue_on: nil)
|
89
|
+
.each do |invoice|
|
90
|
+
invoice.notify_overdue
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def notify_overdue
|
95
|
+
return unless notified_overdue_on.nil?
|
96
|
+
return if paid?
|
97
|
+
return if deleted?
|
98
|
+
return if due_on > Time.now
|
99
|
+
BillinglyMailer.overdue_notification(self).deliver!
|
100
|
+
update_attribute(:notified_overdue_on, Time.now)
|
101
|
+
end
|
102
|
+
|
103
|
+
# Notifies that the invoice has been charged successfully.
|
104
|
+
# Send the email notifying that this invoice being overdue and the subscription
|
105
|
+
# being cancelled
|
106
|
+
def self.notify_all_paid
|
107
|
+
where('receipt_id is not null')
|
108
|
+
.where(deleted_on: nil, notified_paid_on: nil).each do |invoice|
|
109
|
+
invoice.notify_paid
|
110
|
+
end
|
111
|
+
end
|
112
|
+
def notify_paid
|
113
|
+
return unless paid?
|
114
|
+
return unless notified_paid_on.nil?
|
115
|
+
return if deleted?
|
116
|
+
BillinglyMailer.paid_notification(self).deliver!
|
117
|
+
update_attribute(:notified_paid_on, Time.now)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Billingly
|
2
|
+
class LedgerEntry < ActiveRecord::Base
|
3
|
+
belongs_to :customer
|
4
|
+
belongs_to :invoice
|
5
|
+
belongs_to :payment
|
6
|
+
belongs_to :receipt
|
7
|
+
belongs_to :subscription
|
8
|
+
|
9
|
+
validates :amount, presence: true
|
10
|
+
validates :customer, presence: true
|
11
|
+
validates :account, presence: true, inclusion: %w(paid cash spent)
|
12
|
+
|
13
|
+
attr_accessible :customer, :account, :invoice, :payment, :receipt, :subscription, :amount
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# Processes payments
|
2
|
+
module Billingly
|
3
|
+
class Payment < ActiveRecord::Base
|
4
|
+
belongs_to :customer
|
5
|
+
has_many :ledger_entries
|
6
|
+
attr_accessible :amount, :customer
|
7
|
+
|
8
|
+
# Process a new payment done by a customer.
|
9
|
+
# Payments can be credited at any point and they are bound to increase
|
10
|
+
# a customer's balance. Payments are not mapped 1 to 1 with invoices,
|
11
|
+
# instead, invoices are deemed as paid whenever the customer's balance
|
12
|
+
# is enough to cover them.
|
13
|
+
def self.credit_for(customer, amount)
|
14
|
+
create!(amount: amount, customer: customer).tap do |payment|
|
15
|
+
customer.add_to_ledger(amount, :cash, :paid, payment: payment)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Billingly
|
2
|
+
class Subscription < ActiveRecord::Base
|
3
|
+
has_many :ledger_entries
|
4
|
+
# If a subscription is payable_upfront, then the customer effectively owes us
|
5
|
+
# since the day in which a given period starts.
|
6
|
+
# If a subscription is payable on 'due-month', then the customer effectively
|
7
|
+
# owes us money since the date in which a given period ended.
|
8
|
+
# When we invoice for a given period we will set the due_date a few days
|
9
|
+
# ahead of the date in which the debt was made effective, we call this
|
10
|
+
# a GRACE_PERIOD.
|
11
|
+
GRACE_PERIOD = 10.days
|
12
|
+
|
13
|
+
# Invoices will be generated before their due_date, as soon as possible,
|
14
|
+
# but not sooner than GENERATE_AHEAD days.
|
15
|
+
GENERATE_AHEAD = 3.days
|
16
|
+
|
17
|
+
# Subscriptions are to be charged periodically. Their periodicity is
|
18
|
+
# stored semantically on the database, but we want to convert it
|
19
|
+
# to actual ruby time ranges to do date arithmetic.
|
20
|
+
PERIODICITIES = {'monthly' => 1.month, 'yearly' => 1.year}
|
21
|
+
|
22
|
+
belongs_to :customer
|
23
|
+
has_many :invoices
|
24
|
+
|
25
|
+
validates :periodicity, inclusion: PERIODICITIES.keys
|
26
|
+
|
27
|
+
# The periodicity can be set using a symbol, for convenience.
|
28
|
+
# It's still a string under the hood.
|
29
|
+
def periodicity=(value)
|
30
|
+
self[:periodicity] = value.to_s if value
|
31
|
+
end
|
32
|
+
|
33
|
+
def period_size
|
34
|
+
case periodicity
|
35
|
+
when 'monthly' then 1.month
|
36
|
+
when 'yearly' then 1.year
|
37
|
+
else
|
38
|
+
raise ArgumentError.new 'Cannot get period size without periodicity'
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# The invoice generation process should run frequently, at least on a daily basis.
|
43
|
+
# It will create invoices some time before they are due, to give customers a chance
|
44
|
+
# to pay and settle them.
|
45
|
+
# This method is idempotent, if an upcoming invoice for a subscription already exists,
|
46
|
+
# it does not create yet another one.
|
47
|
+
def generate_next_invoice
|
48
|
+
return if terminated?
|
49
|
+
from = invoices.empty? ? subscribed_on : invoices.last.period_end
|
50
|
+
to = from + period_size
|
51
|
+
due_on = (payable_upfront ? from : to) + GRACE_PERIOD
|
52
|
+
return if GENERATE_AHEAD.from_now < from
|
53
|
+
|
54
|
+
invoice = invoices.create!(customer: customer, amount: amount,
|
55
|
+
due_on: due_on, period_start: from, period_end: to)
|
56
|
+
invoice.charge
|
57
|
+
return invoice
|
58
|
+
end
|
59
|
+
|
60
|
+
# Terminates this subscription, it could be either because we deactivate a debtor
|
61
|
+
# or because the customer decided to end his subscription on his own terms.
|
62
|
+
def terminate
|
63
|
+
return if terminated?
|
64
|
+
update_attribute(:unsubscribed_on, Time.now)
|
65
|
+
invoices.last.truncate
|
66
|
+
return self
|
67
|
+
end
|
68
|
+
|
69
|
+
def terminated?
|
70
|
+
not unsubscribed_on.nil?
|
71
|
+
end
|
72
|
+
|
73
|
+
# This class method is called from a cron job, it creates invoices for all the subscriptions
|
74
|
+
# that still need their invoice created.
|
75
|
+
# TODO: This goes through all the active subscriptions, make it smarter so that the batch job runs quicker.
|
76
|
+
def self.generate_next_invoices
|
77
|
+
where(unsubscribed_on: nil).each do |subscription|
|
78
|
+
subscription.generate_next_invoice
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
placeholder!
|
@@ -0,0 +1 @@
|
|
1
|
+
placeholder!
|
@@ -0,0 +1,21 @@
|
|
1
|
+
<%=t 'billingly.make_sure_to_pay' %>
|
2
|
+
|
3
|
+
<%=t 'billingly.you_can_reactivate_your_account_by_visiting' %> http://payments_page
|
4
|
+
|
5
|
+
----- <%=t 'billingly.your_overdue_invoice_is_detailed_below' %> -----
|
6
|
+
|
7
|
+
<%=t 'billingly.invoice_number' %>: <%= '%.10i' % @invoice.id %>
|
8
|
+
|
9
|
+
<%=t 'billingly.due_date' %>: <%= @invoice.due_on.to_date %>
|
10
|
+
|
11
|
+
<%= "#{t('billingly.covering_from')} #{@invoice.period_start.to_date} #{t('billingly.until')} #{@invoice.period_end.to_date}" %>
|
12
|
+
|
13
|
+
<%=t 'billingly.detail' %>
|
14
|
+
<%= '-' * 70 %>
|
15
|
+
<%= '%-55s%15s' % [@invoice.subscription.description, "$#{@invoice.amount} USD"] %>
|
16
|
+
<% if @cash > 0 %>
|
17
|
+
<%= '%-55s%15s' % [t('billingly.balance_in_your_favour'), "-$#{@cash} USD"] %>
|
18
|
+
<% end %>
|
19
|
+
|
20
|
+
<%= '%-55s%15s' % [t('billingly.you_should_pay'), "$#{@invoice.amount - @cash} USD"] %>
|
21
|
+
|
@@ -0,0 +1,10 @@
|
|
1
|
+
<%=t 'billingly.receipt_greeting' %>:
|
2
|
+
|
3
|
+
<%=t 'billingly.receipt_text' %>
|
4
|
+
|
5
|
+
<%= '-' * 70 %>
|
6
|
+
<%=t 'billingly.invoice_number' %>: <%= '%.10i' % @invoice.id %>
|
7
|
+
<%=t 'billingly.paid_on' %>: <%= @invoice.receipt.paid_on.to_date %>
|
8
|
+
<%=t 'billingly.plan' %>: <%= @invoice.subscription.description %>
|
9
|
+
<%=t 'billingly.amount' %>: $<%= @invoice.amount %> USD
|
10
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
<%=t 'billingly.invoice_number' %>: <%= '%.10i' % @invoice.id %>
|
2
|
+
|
3
|
+
<%=t 'billingly.due_date' %>: <%= @invoice.due_on.to_date %>
|
4
|
+
|
5
|
+
<%= "#{t('billingly.covering_from')} #{@invoice.period_start.to_date} #{t('billingly.until')} #{@invoice.period_end.to_date}" %>
|
6
|
+
|
7
|
+
<%=t 'billingly.detail' %>
|
8
|
+
<%= '-' * 70 %>
|
9
|
+
<%= '%-55s%15s' % [@invoice.subscription.description, "$#{@invoice.amount} USD"] %>
|
10
|
+
<% if @cash > 0 %>
|
11
|
+
<%= '%-55s%15s' % [t('billingly.balance_in_your_favour'), "-$#{@cash} USD"] %>
|
12
|
+
<% end %>
|
13
|
+
|
14
|
+
<%= '%-55s%15s' % [t('billingly.you_should_pay'), "$#{@invoice.amount - @cash} USD"] %>
|
15
|
+
|
16
|
+
<%=t 'billingly.you_can_pay_this_invoice_by_visiting' %> http://payments_page
|
17
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
<%=t 'billingly.invoice_number' %>: <%= '%.10i' % @invoice.id %>
|
2
|
+
|
3
|
+
<%=t 'billingly.due_date' %>: <%= @invoice.due_on.to_date %>
|
4
|
+
|
5
|
+
<%= "#{t('billingly.covering_from')} #{@invoice.period_start.to_date} #{t('billingly.until')} #{@invoice.period_end.to_date}" %>
|
6
|
+
|
7
|
+
<%=t 'billingly.detail' %>
|
8
|
+
<%= '-' * 70 %>
|
9
|
+
<%= '%-55s%15s' % [@invoice.subscription.description, "$#{@invoice.amount} USD"] %>
|
10
|
+
<% if @cash > 0 %>
|
11
|
+
<%= '%-55s%15s' % [t('billingly.balance_in_your_favour'), "-$#{@cash} USD"] %>
|
12
|
+
<% end %>
|
13
|
+
|
14
|
+
<%= '%-55s%15s' % [t('billingly.you_should_pay'), "$#{@invoice.amount - @cash} USD"] %>
|
15
|
+
|
16
|
+
<%=t 'billingly.you_can_pay_this_invoice_by_visiting' %> http://payments_page
|
17
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
<%=t 'billingly.invoice_number' %>: <%= '%.10i' % @invoice.id %>
|
2
|
+
|
3
|
+
<%=t 'billingly.due_date' %>: <%= @invoice.due_on.to_date %>
|
4
|
+
|
5
|
+
<%= "#{t('billingly.covering_from')} #{@invoice.period_start.to_date} #{t('billingly.until')} #{@invoice.period_end.to_date}" %>
|
6
|
+
|
7
|
+
<%=t 'billingly.detail' %>
|
8
|
+
<%= '-' * 70 %>
|
9
|
+
<%= '%-55s%15s' % [@invoice.subscription.description, "$#{@invoice.amount} USD"] %>
|
10
|
+
<% if @cash > 0 %>
|
11
|
+
<%= '%-55s%15s' % [t('billingly.balance_in_your_favour'), "-$#{@cash} USD"] %>
|
12
|
+
<% end %>
|
13
|
+
|
14
|
+
<%= '%-55s%15s' % [t('billingly.you_should_pay'), "$#{@invoice.amount - @cash} USD"] %>
|
15
|
+
|
16
|
+
<%=t 'billingly.you_can_pay_this_invoice_by_visiting' %> http://payments_page
|
17
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
en:
|
2
|
+
billingly:
|
3
|
+
your_account_was_suspended: Your account was suspended
|
4
|
+
your_invoice_is_available: Your invoice is available
|
5
|
+
payment_receipt: Payment Receipt
|
6
|
+
invoice_number: Invoice Number
|
7
|
+
covering_from: For period starting on
|
8
|
+
until: until
|
9
|
+
due_date: Due Date
|
10
|
+
detail: Detail
|
11
|
+
balance_in_your_favour: Balance in your favour
|
12
|
+
you_should_pay: You should pay
|
13
|
+
you_can_pay_this_invoice_by_visiting: You can pay this invoice by visiting
|
14
|
+
you_can_reactivate_your_account_by_visiting: You can reactivate your account by visiting
|
15
|
+
make_sure_to_pay:
|
16
|
+
Your account was suspended. Make sure to pay your overdue invoices to reactivate it.
|
17
|
+
your_overdue_invoice_is_detailed_below: Your overdue invoice is detailed below
|
18
|
+
receipt_greeting: Dear customer
|
19
|
+
receipt_text:
|
20
|
+
"This is a receipt for your subscription.\nThis is only a receipt, no payment is due.\nThank you for your business!"
|
21
|
+
paid_on: Paid on
|
22
|
+
plan: Plan
|
23
|
+
amount: Amount
|
data/lib/billingly.rb
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
module Billingly
|
2
|
+
def self.table_name_prefix
|
3
|
+
'billingly_'
|
4
|
+
end
|
5
|
+
|
6
|
+
class Engine < Rails::Engine
|
7
|
+
|
8
|
+
# Extends the ApplicationController with all the
|
9
|
+
# billingly before_filters and helper methods
|
10
|
+
initializer 'billingly.app_controller' do |app|
|
11
|
+
ActiveSupport.on_load(:action_controller) do
|
12
|
+
class_eval do
|
13
|
+
def current_customer
|
14
|
+
nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def requires_customer
|
18
|
+
on_empty_customer if current_customer.nil?
|
19
|
+
end
|
20
|
+
|
21
|
+
# This method is call on a before filter when a customer was required
|
22
|
+
# but none was found. It's reccommended that this method redirects to
|
23
|
+
# a login url or out of the site.
|
24
|
+
def on_empty_customer
|
25
|
+
redirect_to(root_path)
|
26
|
+
end
|
27
|
+
|
28
|
+
# This before filter should apply to all actions that require an active
|
29
|
+
# customer. Usually this would mean all your non-public pages.
|
30
|
+
# The billingly controllers already apply this before filter, you should
|
31
|
+
# use it in your own controllers too.
|
32
|
+
def requires_active_customer
|
33
|
+
if requires_customer.nil? && current_customer.deactivated?
|
34
|
+
redirect_to(controller: 'billingly/subscriptions', action: 'index')
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
helper_method :current_customer
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class ActionDispatch::Routing::Mapper
|
2
|
+
def add_billingly_routes(skope=nil, controller='billingly/subscriptions')
|
3
|
+
route = lambda do
|
4
|
+
resources :subscriptions, controller: controller do
|
5
|
+
collection do
|
6
|
+
post :reactivate
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
if skope then scope(skope, as: skope, &route) else route.call end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'rails/generators/migration'
|
2
|
+
require 'rails/generators'
|
3
|
+
|
4
|
+
class BillinglyMigrationGenerator < Rails::Generators::Base
|
5
|
+
include Rails::Generators::Migration
|
6
|
+
|
7
|
+
def create_billingly_migration
|
8
|
+
migration_template 'create_billingly_tables.rb', "db/migrate/create_billingly_tables.rb"
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def source_paths
|
14
|
+
[File.expand_path("../templates", __FILE__)]
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.next_migration_number(path)
|
18
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
class CreateBillinglyTables < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :billingly_customers do |t|
|
4
|
+
t.datetime 'deactivated_since'
|
5
|
+
t.string 'email', null: false
|
6
|
+
end
|
7
|
+
|
8
|
+
create_table :billingly_invoices do |t|
|
9
|
+
t.references :customer, null: false
|
10
|
+
t.references :receipt
|
11
|
+
t.references :subscription
|
12
|
+
t.decimal 'amount', precision: 11, scale: 2, default: 0.0, null: false
|
13
|
+
t.datetime 'due_on', null: false
|
14
|
+
t.datetime 'period_start', null: false
|
15
|
+
t.datetime 'period_end', null: false
|
16
|
+
t.datetime 'deleted_on'
|
17
|
+
t.datetime 'notified_pending_on'
|
18
|
+
t.datetime 'notified_overdue_on'
|
19
|
+
t.datetime 'notified_paid_on'
|
20
|
+
t.text 'comment'
|
21
|
+
t.timestamps
|
22
|
+
end
|
23
|
+
|
24
|
+
create_table :billingly_payments do |t|
|
25
|
+
t.references :customer, null: false
|
26
|
+
t.decimal 'amount', precision: 11, scale: 2, default: 0.0, null: false
|
27
|
+
end
|
28
|
+
|
29
|
+
create_table :billingly_receipts do |t|
|
30
|
+
t.references :customer, null: false
|
31
|
+
t.datetime 'paid_on'
|
32
|
+
t.timestamps
|
33
|
+
end
|
34
|
+
|
35
|
+
create_table :billingly_ledger_entries do |t|
|
36
|
+
t.references :customer, null: false
|
37
|
+
t.string :account, null: false
|
38
|
+
t.decimal 'amount', precision: 11, scale: 2, default: 0.0, null: false
|
39
|
+
t.references :subscription
|
40
|
+
t.references :invoice
|
41
|
+
t.references :payment
|
42
|
+
t.references :receipt
|
43
|
+
t.timestamps
|
44
|
+
end
|
45
|
+
|
46
|
+
create_table :billingly_subscriptions do |t|
|
47
|
+
t.references :customer, null: false
|
48
|
+
t.string 'description', null: false
|
49
|
+
t.datetime 'subscribed_on', null: false
|
50
|
+
t.string 'periodicity', null: false
|
51
|
+
t.decimal 'amount', precision: 11, scale: 2, default: 0.0, null: false
|
52
|
+
t.datetime 'unsubscribed_on'
|
53
|
+
t.boolean 'payable_upfront', null: false, default: false
|
54
|
+
t.timestamps
|
55
|
+
end
|
56
|
+
|
57
|
+
create_table :billingly_plans do |t|
|
58
|
+
t.string 'name' # Pro 50
|
59
|
+
t.string 'description' # 50GB for 9,99 a month.
|
60
|
+
t.string 'periodicity'
|
61
|
+
t.decimal 'amount', precision: 11, scale: 2, default: 0.0, null: false # 9.99
|
62
|
+
t.boolean 'payable_upfront' # true
|
63
|
+
t.timestamps
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.down
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
@@ -0,0 +1,21 @@
|
|
1
|
+
desc """
|
2
|
+
Run all periodic billing tasks like generating new invoices,
|
3
|
+
deactivating debtors, and emailing.
|
4
|
+
You can run it as often as you want.
|
5
|
+
"""
|
6
|
+
namespace :billingly do
|
7
|
+
task all: :environment do
|
8
|
+
puts 'Generating invoices'
|
9
|
+
Billingly::Subscription.generate_next_invoices
|
10
|
+
puts 'Charging invoices if possible'
|
11
|
+
Billingly::Invoice.charge_all
|
12
|
+
puts 'Deactivating debtors'
|
13
|
+
Billingly::Customer.deactivate_all_debtors
|
14
|
+
puts 'Sending payment receipts'
|
15
|
+
Billingly::Invoice.notify_all_paid
|
16
|
+
puts 'Notifying pending invoices'
|
17
|
+
Billingly::Invoice.notify_all_pending
|
18
|
+
puts 'Notifying deactivated debtors'
|
19
|
+
Billingly::Invoice.notify_all_overdue
|
20
|
+
end
|
21
|
+
end
|
metadata
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: billingly
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Nubis
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-09-11 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rails
|
16
|
+
requirement: &70238504847900 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '3.2'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70238504847900
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: validates_email_format_of
|
27
|
+
requirement: &70238504847400 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70238504847400
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: timecop
|
38
|
+
requirement: &70238504846940 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ! '>='
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *70238504846940
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: sqlite3
|
49
|
+
requirement: &70238504846520 !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
type: :development
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: *70238504846520
|
58
|
+
- !ruby/object:Gem::Dependency
|
59
|
+
name: rspec-rails
|
60
|
+
requirement: &70238504846080 !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
type: :development
|
67
|
+
prerelease: false
|
68
|
+
version_requirements: *70238504846080
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: factory_girl_rails
|
71
|
+
requirement: &70238504845640 !ruby/object:Gem::Requirement
|
72
|
+
none: false
|
73
|
+
requirements:
|
74
|
+
- - ! '>='
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: *70238504845640
|
80
|
+
description: Engine for subscriptions billing - still alpha - contact me if you want
|
81
|
+
to use it
|
82
|
+
email:
|
83
|
+
- nubis@woobiz.com.ar
|
84
|
+
executables: []
|
85
|
+
extensions: []
|
86
|
+
extra_rdoc_files: []
|
87
|
+
files:
|
88
|
+
- app/controllers/billingly/subscriptions_controller.rb
|
89
|
+
- app/mailers/billingly_mailer.rb
|
90
|
+
- app/models/billingly/customer.rb
|
91
|
+
- app/models/billingly/invoice.rb
|
92
|
+
- app/models/billingly/invoice_item.rb
|
93
|
+
- app/models/billingly/ledger_entry.rb
|
94
|
+
- app/models/billingly/one_time_charge.rb
|
95
|
+
- app/models/billingly/payment.rb
|
96
|
+
- app/models/billingly/plan.rb
|
97
|
+
- app/models/billingly/receipt.rb
|
98
|
+
- app/models/billingly/subscription.rb
|
99
|
+
- app/views/billingly/subscriptions/create.html.erb
|
100
|
+
- app/views/billingly/subscriptions/index.html.erb
|
101
|
+
- app/views/billingly/subscriptions/new.html.erb
|
102
|
+
- app/views/billingly_mailer/overdue_notification.html.erb
|
103
|
+
- app/views/billingly_mailer/paid_notification.html.erb
|
104
|
+
- app/views/billingly_mailer/pending_notification.html.erb
|
105
|
+
- app/views/billingly_mailer/pending_notification.plain.erb
|
106
|
+
- app/views/billingly_mailer/pending_notification.text.erb
|
107
|
+
- config/locales/en.yml
|
108
|
+
- lib/billingly/engine.rb
|
109
|
+
- lib/billingly/rails/routes.rb
|
110
|
+
- lib/billingly/version.rb
|
111
|
+
- lib/billingly.rb
|
112
|
+
- lib/generators/billingly_migration_generator.rb
|
113
|
+
- lib/generators/templates/create_billingly_tables.rb
|
114
|
+
- lib/tasks/billingly_tasks.rake
|
115
|
+
- MIT-LICENSE
|
116
|
+
- Rakefile
|
117
|
+
- README.rdoc
|
118
|
+
homepage: ''
|
119
|
+
licenses: []
|
120
|
+
post_install_message:
|
121
|
+
rdoc_options: []
|
122
|
+
require_paths:
|
123
|
+
- lib
|
124
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
125
|
+
none: false
|
126
|
+
requirements:
|
127
|
+
- - ! '>='
|
128
|
+
- !ruby/object:Gem::Version
|
129
|
+
version: '0'
|
130
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
131
|
+
none: false
|
132
|
+
requirements:
|
133
|
+
- - ! '>='
|
134
|
+
- !ruby/object:Gem::Version
|
135
|
+
version: '0'
|
136
|
+
requirements: []
|
137
|
+
rubyforge_project:
|
138
|
+
rubygems_version: 1.8.15
|
139
|
+
signing_key:
|
140
|
+
specification_version: 3
|
141
|
+
summary: Engine for subscriptions billing - still alpha - contact me if you want to
|
142
|
+
use it
|
143
|
+
test_files: []
|