billingly 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|