accountability 0.1.1 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +86 -9
- data/app/assets/stylesheets/accountability/application.scss +0 -0
- data/app/controllers/accountability/accounts_controller.rb +2 -4
- data/app/controllers/accountability/billing_configurations_controller.rb +80 -0
- data/app/controllers/accountability/order_groups_controller.rb +2 -4
- data/app/controllers/accountability/payments_controller.rb +25 -0
- data/app/controllers/accountability/products_controller.rb +7 -5
- data/app/controllers/accountability/statements_controller.rb +19 -0
- data/app/controllers/accountability_controller.rb +47 -0
- data/app/helpers/accountability/{application_helper.rb → accountability_helper.rb} +1 -1
- data/app/helpers/accountability/billing_configurations_helper.rb +47 -0
- data/app/helpers/accountability/products_helper.rb +21 -0
- data/app/models/accountability/account.rb +31 -1
- data/app/models/accountability/application_record.rb +24 -0
- data/app/models/accountability/billing_configuration.rb +41 -0
- data/app/models/accountability/coupon.rb +2 -0
- data/app/models/accountability/credit.rb +53 -29
- data/app/models/accountability/debit.rb +10 -0
- data/app/models/accountability/inventory.rb +61 -0
- data/app/models/accountability/offerable.rb +28 -1
- data/app/models/accountability/offerable/scope.rb +48 -0
- data/app/models/accountability/order_item.rb +3 -7
- data/app/models/accountability/payment.rb +22 -3
- data/app/models/accountability/product.rb +45 -5
- data/app/models/accountability/statement.rb +46 -0
- data/app/models/accountability/{account/transactions.rb → transactions.rb} +14 -2
- data/app/models/concerns/accountability/active_merchant_interface.rb +15 -0
- data/app/models/concerns/accountability/active_merchant_interface/stripe_interface.rb +134 -0
- data/app/pdfs/statement_pdf.rb +91 -0
- data/app/views/accountability/accounts/show.html.haml +21 -9
- data/app/views/accountability/products/index.html.haml +17 -34
- data/app/views/accountability/products/new.html.haml +73 -0
- data/app/views/accountability/shared/_session_info.html.haml +24 -0
- data/config/locales/en.yml +19 -0
- data/db/migrate/20190814000455_create_accountability_tables.rb +43 -1
- data/lib/accountability.rb +2 -1
- data/lib/accountability/configuration.rb +22 -2
- data/lib/accountability/engine.rb +6 -1
- data/lib/accountability/extensions/acts_as_billable.rb +11 -0
- data/lib/accountability/rails/routes.rb +72 -0
- data/lib/accountability/types.rb +9 -0
- data/lib/accountability/types/billing_configuration_types.rb +57 -0
- data/lib/accountability/version.rb +1 -1
- data/lib/generators/accountability/install_generator.rb +47 -0
- data/lib/generators/accountability/templates/migration.rb.tt +155 -0
- data/lib/generators/accountability/templates/price_overrides_migration.rb.tt +13 -0
- metadata +73 -12
- data/app/assets/stylesheets/accountability/application.css +0 -15
- data/app/controllers/accountability/application_controller.rb +0 -45
- data/app/views/accountability/products/new.html.erb +0 -40
- data/lib/accountability/cartographer.rb +0 -28
@@ -5,15 +5,39 @@
|
|
5
5
|
# Expired - No longer available, but still usable by prior adopters
|
6
6
|
# Terminated - No longer available - even by prior adopters
|
7
7
|
|
8
|
+
# rubocop:disable Rails/HasAndBelongsToMany
|
9
|
+
|
8
10
|
module Accountability
|
9
11
|
class Product < ApplicationRecord
|
12
|
+
RECURRING_SCHEDULES = %i[weekly monthly annually].freeze
|
13
|
+
|
10
14
|
has_and_belongs_to_many :coupons
|
11
15
|
has_many :order_items, dependent: :restrict_with_error
|
12
16
|
has_many :credits, through: :order_items, inverse_of: :product
|
13
17
|
|
14
18
|
serialize :source_scope, Hash
|
15
19
|
|
16
|
-
enum schedule:
|
20
|
+
enum schedule: [:one_time, *RECURRING_SCHEDULES], _prefix: :accrues
|
21
|
+
|
22
|
+
def active?
|
23
|
+
return false unless activation_date.present?
|
24
|
+
|
25
|
+
activation_date.past?
|
26
|
+
end
|
27
|
+
|
28
|
+
def billing_cycle_length
|
29
|
+
case schedule
|
30
|
+
when 'weekly' then 1.week
|
31
|
+
when 'monthly' then 1.month
|
32
|
+
when 'annually' then 1.year
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Returns an Inventory object containing each record within the product's scope.
|
37
|
+
# This method will eventually phase out the `inventory` and `available_inventory` methods.
|
38
|
+
def inventory_items
|
39
|
+
Inventory.new(self)
|
40
|
+
end
|
17
41
|
|
18
42
|
def inventory
|
19
43
|
return [] if source_class.nil?
|
@@ -21,6 +45,22 @@ module Accountability
|
|
21
45
|
source_class.where(**source_scope)
|
22
46
|
end
|
23
47
|
|
48
|
+
def available_inventory
|
49
|
+
return [] if source_class.nil?
|
50
|
+
|
51
|
+
source_class.where(**source_scope).public_send(offerable_template.whitelist)
|
52
|
+
end
|
53
|
+
|
54
|
+
# TODO: Update offerable_template.scopes to return an array of Scope objects and delegate to that instead
|
55
|
+
def scopes
|
56
|
+
return @scopes if @scopes.present?
|
57
|
+
|
58
|
+
@scopes = offerable_template.scopes.map do |attribute, params|
|
59
|
+
params.merge! source_class: source_class, attribute: attribute
|
60
|
+
Offerable::Scope.new(**params)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
24
64
|
def offerable_template
|
25
65
|
return if offerable_category.nil?
|
26
66
|
return @offerable if @offerable.present?
|
@@ -43,11 +83,11 @@ module Accountability
|
|
43
83
|
end
|
44
84
|
|
45
85
|
delegate :callbacks, to: :offerable_template
|
46
|
-
end
|
47
86
|
|
48
|
-
|
87
|
+
private
|
49
88
|
|
50
|
-
|
51
|
-
|
89
|
+
def raise_offerable_not_found
|
90
|
+
raise 'Offerable not found'
|
91
|
+
end
|
52
92
|
end
|
53
93
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# New Credit records are automatically assigned to the latest Statement on their associated Account.
|
2
|
+
# If the latest statement is past its :end_date, the credit will build a new one.
|
3
|
+
# The new :end_date is determined by the account's :statement_schedule enum.
|
4
|
+
|
5
|
+
module Accountability
|
6
|
+
class Statement < ApplicationRecord
|
7
|
+
self.implicit_order_column = 'end_date'
|
8
|
+
|
9
|
+
before_validation :set_end_date
|
10
|
+
|
11
|
+
belongs_to :account
|
12
|
+
has_many :credits, dependent: :destroy
|
13
|
+
|
14
|
+
validates :end_date, presence: true
|
15
|
+
|
16
|
+
delegate :past?, :future?, to: :end_date
|
17
|
+
|
18
|
+
def paid?
|
19
|
+
return false if account.last_balanced_at.nil?
|
20
|
+
|
21
|
+
end_date.before? account.last_balanced_at
|
22
|
+
end
|
23
|
+
|
24
|
+
def total_accrued
|
25
|
+
credits.sum(:amount)
|
26
|
+
end
|
27
|
+
|
28
|
+
def transactions
|
29
|
+
associated_credits = credits.includes(:product, deductions: :discount).references(:order_item)
|
30
|
+
|
31
|
+
Transactions.new(credits: associated_credits)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def set_end_date
|
37
|
+
return if account.nil?
|
38
|
+
return if end_date.present?
|
39
|
+
|
40
|
+
self.end_date = case account.statement_schedule
|
41
|
+
when 'end_of_month' then Time.current.end_of_month
|
42
|
+
when 'bi_weekly' then 2.weeks.from_now
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -1,12 +1,12 @@
|
|
1
1
|
require 'forwardable'
|
2
2
|
|
3
3
|
module Accountability
|
4
|
-
class
|
4
|
+
class Transactions
|
5
5
|
extend Forwardable
|
6
6
|
|
7
7
|
attr_accessor :transactions
|
8
8
|
|
9
|
-
def_delegators :transactions, :each, :sort_by
|
9
|
+
def_delegators :transactions, :each, :sort_by, :any?, :none?
|
10
10
|
|
11
11
|
def initialize(debits: [], credits: [])
|
12
12
|
debit_transactions = debits.map do |debit|
|
@@ -38,6 +38,18 @@ module Accountability
|
|
38
38
|
def credit?
|
39
39
|
type == :credit
|
40
40
|
end
|
41
|
+
|
42
|
+
def base_amount
|
43
|
+
debit? ? base_amount : record.base_price
|
44
|
+
end
|
45
|
+
|
46
|
+
def deductions
|
47
|
+
debit? ? [] : record.deductions
|
48
|
+
end
|
49
|
+
|
50
|
+
def taxes
|
51
|
+
debit? ? 0 : record.taxes
|
52
|
+
end
|
41
53
|
end
|
42
54
|
end
|
43
55
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Accountability
|
2
|
+
module ActiveMerchantInterface
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
provider = Accountability::Configuration.payment_gateway[:provider]
|
7
|
+
case provider
|
8
|
+
when :stripe
|
9
|
+
include StripeInterface
|
10
|
+
else
|
11
|
+
raise NotImplementedError, "No ActiveMerchantInterface defined for #{provider}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# TODO: Implement #unstore_active_merchant_data
|
2
|
+
|
3
|
+
module Accountability
|
4
|
+
module ActiveMerchantInterface::StripeInterface
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
attribute :stripe_api_errors, default: []
|
9
|
+
validate :validate_no_stripe_errors
|
10
|
+
|
11
|
+
# It is important to clear these errors after validation
|
12
|
+
# so that store_active_merchant_data will run again.
|
13
|
+
after_validation :clear_stripe_api_errors, on: :create
|
14
|
+
after_save_commit :save_customer_info_to_stripe
|
15
|
+
end
|
16
|
+
|
17
|
+
def charge(amount)
|
18
|
+
gateway = initialize_payment_gateway
|
19
|
+
amount_in_cents = (amount * 100).round
|
20
|
+
card_id = active_merchant_data['authorization']
|
21
|
+
|
22
|
+
response = gateway.purchase(amount_in_cents, card_id)
|
23
|
+
|
24
|
+
return true if response.success?
|
25
|
+
|
26
|
+
error_code = response.error_code || I18n.t('accountability.gateway.errors.unknown_gateway_error')
|
27
|
+
stripe_api_errors.append(error_code)
|
28
|
+
false
|
29
|
+
end
|
30
|
+
|
31
|
+
def store_active_merchant_data(**options)
|
32
|
+
response = store_card_in_gateway
|
33
|
+
|
34
|
+
unless response.success?
|
35
|
+
error_code = response.error_code || I18n.t('accountability.gateway.errors.unknown_gateway_error')
|
36
|
+
stripe_api_errors.append(error_code)
|
37
|
+
return
|
38
|
+
end
|
39
|
+
|
40
|
+
active_merchant_data = extract_active_merchant_data(response)
|
41
|
+
validate_chargeable(**active_merchant_data) if options.fetch(:verify_card)
|
42
|
+
|
43
|
+
self.active_merchant_data = active_merchant_data
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def store_card_in_gateway(gateway = initialize_payment_gateway)
|
49
|
+
gateway.store(token, description: configuration_name, email: contact_email, set_default: true)
|
50
|
+
end
|
51
|
+
|
52
|
+
def validate_chargeable(gateway = initialize_payment_gateway, **active_merchant_data)
|
53
|
+
authorization = active_merchant_data[:authorization]
|
54
|
+
|
55
|
+
response = gateway.verify(authorization, verification_params)
|
56
|
+
return if response.success?
|
57
|
+
|
58
|
+
error_code = response.error_code || I18n.t('accountability.gateway.errors.unknown_gateway_error')
|
59
|
+
stripe_api_errors.append(error_code)
|
60
|
+
end
|
61
|
+
|
62
|
+
def extract_active_merchant_data(response)
|
63
|
+
customer_id = response.params['id']
|
64
|
+
card_id = response.params['sources']['data'].first['id']
|
65
|
+
authorization = response.authorization
|
66
|
+
|
67
|
+
data = { authorization: authorization, customer_id: customer_id, card_id: card_id }
|
68
|
+
data.symbolize_keys
|
69
|
+
end
|
70
|
+
|
71
|
+
def initialize_payment_gateway
|
72
|
+
secret_key = Accountability::Configuration.payment_gateway.dig(:authentication, :secret_key)
|
73
|
+
ActiveMerchant::Billing::StripeGateway.new(login: secret_key)
|
74
|
+
end
|
75
|
+
|
76
|
+
def add_customer_info(customer_id, gateway = initialize_payment_gateway)
|
77
|
+
response = gateway.update_customer(customer_id, customer_params)
|
78
|
+
return unless response.error_code
|
79
|
+
|
80
|
+
Rails.logger.warn %(Warning: add_customer_info failed for #{self.class}: #{id}; card_id: #{card_id};
|
81
|
+
customer_id: #{customer_id}; Stripe Error: #{add_customer_info_response.error_code}).squish
|
82
|
+
end
|
83
|
+
|
84
|
+
def add_card_info(customer_id, card_id, gateway = initialize_payment_gateway)
|
85
|
+
response = gateway.update(customer_id, card_id, card_params)
|
86
|
+
return unless response.error_code
|
87
|
+
|
88
|
+
Rails.logger.warn %(Warning: add_card_info failed for #{self.class}: #{id}; card_id: #{card_id};
|
89
|
+
customer_id: #{customer_id}; Stripe Error: #{add_customer_info_response.error_code}).squish
|
90
|
+
end
|
91
|
+
|
92
|
+
def save_customer_info_to_stripe(gateway = initialize_payment_gateway)
|
93
|
+
customer_id, card_id = active_merchant_data.with_indifferent_access.values_at(:customer_id, :card_id)
|
94
|
+
return unless customer_id && card_id
|
95
|
+
|
96
|
+
add_customer_info(customer_id, gateway)
|
97
|
+
add_card_info(customer_id, card_id, gateway)
|
98
|
+
end
|
99
|
+
|
100
|
+
def verification_params
|
101
|
+
{ description: 'Accountability Verification Charge', statement_description: 'Card Verification' }
|
102
|
+
end
|
103
|
+
|
104
|
+
def card_params
|
105
|
+
{ metadata: { billing_configuration_id: id, account_id: account.id },
|
106
|
+
name: contact_name,
|
107
|
+
address_line1: billing_address.address_1,
|
108
|
+
address_line2: billing_address.address_2,
|
109
|
+
address_city: billing_address.city,
|
110
|
+
address_zip: billing_address.zip,
|
111
|
+
address_state: billing_address.state,
|
112
|
+
address_country: billing_address.country }
|
113
|
+
end
|
114
|
+
|
115
|
+
def customer_params
|
116
|
+
{ metadata: { billing_configuration_id: id, account_id: account.id },
|
117
|
+
name: contact_name, email: contact_email,
|
118
|
+
address: { line1: billing_address.address_1,
|
119
|
+
line2: billing_address.address_2,
|
120
|
+
city: billing_address.city,
|
121
|
+
postal_code: billing_address.zip,
|
122
|
+
state: billing_address.state,
|
123
|
+
country: billing_address.country } }
|
124
|
+
end
|
125
|
+
|
126
|
+
def validate_no_stripe_errors
|
127
|
+
stripe_api_errors.each { |error| errors.add(:base, error) }
|
128
|
+
end
|
129
|
+
|
130
|
+
def clear_stripe_api_errors
|
131
|
+
stripe_api_errors.clear
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'prawn'
|
2
|
+
require 'prawn/table'
|
3
|
+
require 'date'
|
4
|
+
|
5
|
+
class StatementPdf
|
6
|
+
include ActiveSupport::NumberHelper
|
7
|
+
include Prawn::View
|
8
|
+
|
9
|
+
attr_accessor :statement
|
10
|
+
|
11
|
+
def initialize(statement, debug_mode: false)
|
12
|
+
@statement = statement
|
13
|
+
@debug_mode = debug_mode
|
14
|
+
|
15
|
+
draw_document
|
16
|
+
end
|
17
|
+
|
18
|
+
def draw_document
|
19
|
+
font 'Helvetica'
|
20
|
+
|
21
|
+
stroke_axis if debug_mode?
|
22
|
+
draw_header
|
23
|
+
move_down 40
|
24
|
+
draw_transactions_section
|
25
|
+
number_pages '<page>', align: :center, at: [0, -6]
|
26
|
+
end
|
27
|
+
|
28
|
+
def draw_header
|
29
|
+
# Draw upper-left text
|
30
|
+
bounding_box [0, 720], width: 250, height: 70 do
|
31
|
+
move_down 10
|
32
|
+
text 'STATEMENT', size: 18
|
33
|
+
move_down 5
|
34
|
+
text "Ending: #{statement.end_date.strftime('%B %-d, %Y')}", align: :left
|
35
|
+
|
36
|
+
transparent(0.5) { stroke_bounds } if debug_mode?
|
37
|
+
end
|
38
|
+
|
39
|
+
# Draw upper-right logo
|
40
|
+
bounding_box [290, 720], width: 250, height: 70 do
|
41
|
+
logo_path = Accountability::Configuration.logo_path
|
42
|
+
image logo_path, fit: [250, 70], position: :right
|
43
|
+
transparent(0.5) { stroke_bounds } if debug_mode?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def draw_transactions_section
|
48
|
+
text 'Details', style: :bold_italic
|
49
|
+
stroke_horizontal_rule
|
50
|
+
move_down 15
|
51
|
+
|
52
|
+
# Draw transactions table
|
53
|
+
transactions = Accountability::Transactions.new(credits: statement.credits.includes(:product, :deductions))
|
54
|
+
transaction_data = transactions.sort_by(&:date).reverse.map do |transaction|
|
55
|
+
[transaction.date.strftime('%b %-d, %Y'), make_transaction_subtable(transaction)]
|
56
|
+
end
|
57
|
+
|
58
|
+
table transaction_data, column_widths: [100, 440], row_colors: %w[d2e3ed ffffff]
|
59
|
+
|
60
|
+
# Draw statement total in separate table
|
61
|
+
total_data = [[nil, 'Total ', decorate_currency(statement.total_accrued)]]
|
62
|
+
table total_data, cell_style: { borders: [], font_style: :bold }, column_widths: [100, 340, 100] do
|
63
|
+
row(-1).columns(1).style align: :right
|
64
|
+
row(-1).columns(2).borders = %i[bottom left right]
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def make_transaction_subtable(transaction)
|
69
|
+
rows = [[transaction.description, decorate_currency(transaction.base_amount)]]
|
70
|
+
|
71
|
+
# Define deduction rows if applicable
|
72
|
+
transaction.deductions.each do |deduction|
|
73
|
+
rows.append([deduction.coupon_name, decorate_currency(-deduction.amount)])
|
74
|
+
end
|
75
|
+
|
76
|
+
# Define tax row if applicable
|
77
|
+
rows.append(['Tax', decorate_currency(transaction.taxes)]) if transaction.taxes.nonzero?
|
78
|
+
|
79
|
+
make_table rows, column_widths: [340, 100]
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def debug_mode?
|
85
|
+
@debug_mode
|
86
|
+
end
|
87
|
+
|
88
|
+
def decorate_currency(value)
|
89
|
+
number_to_currency value, negative_format: '(%u%n)'
|
90
|
+
end
|
91
|
+
end
|
@@ -1,12 +1,24 @@
|
|
1
|
-
|
1
|
+
.container
|
2
|
+
- if Accountability::Configuration.dev_tools_enabled?
|
3
|
+
= render partial: 'accountability/shared/session_info'
|
2
4
|
|
3
|
-
|
5
|
+
- if admin_session?
|
6
|
+
= link_to 'Back', accountability_accounts_path, class: 'btn btn-outline-primary float-right'
|
4
7
|
|
5
|
-
%
|
6
|
-
%
|
7
|
-
= @account.billable.id
|
8
|
-
%p
|
9
|
-
%b Balance:
|
10
|
-
= @account.balance
|
8
|
+
%h1 Billing Info
|
9
|
+
%h4.mb-5= @account.billable_record_name
|
11
10
|
|
12
|
-
|
11
|
+
.row
|
12
|
+
.col-md-6
|
13
|
+
.card
|
14
|
+
.card-header
|
15
|
+
Account Balance
|
16
|
+
.card-body
|
17
|
+
.text-primary= @account.balance
|
18
|
+
|
19
|
+
.col-md-6
|
20
|
+
.card
|
21
|
+
.card-header
|
22
|
+
Payment History
|
23
|
+
.card-body
|
24
|
+
.text-secondary Coming Soon
|
@@ -1,39 +1,22 @@
|
|
1
|
-
|
1
|
+
.container
|
2
|
+
- if Accountability::Configuration.dev_tools_enabled?
|
3
|
+
= render partial: 'accountability/shared/session_info'
|
2
4
|
|
3
|
-
|
4
|
-
|
5
|
-
= link_to 'New Product', new_accountability_product_path
|
5
|
+
- if current_order_group.order_items.any?
|
6
|
+
= link_to 'Shopping Cart', accountability_order_group_path(current_order_group), class: 'btn btn-outline-primary float-right ml-2'
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
= admin_session? ? 'yep' : 'nope'
|
8
|
+
- if current_account.present?
|
9
|
+
= link_to 'Billing', accountability_account_path(current_account), class: 'btn btn-outline-primary float-right ml-2'
|
10
10
|
|
11
|
-
|
12
|
-
%p
|
13
|
-
%b Email:
|
14
|
-
= current_user&.email
|
11
|
+
%h1.mb-5 Products
|
15
12
|
|
16
|
-
|
17
|
-
|
18
|
-
|
13
|
+
- @products.each do |product|
|
14
|
+
.card.mb-4
|
15
|
+
.card-header
|
16
|
+
= product.name
|
17
|
+
.card-body
|
18
|
+
= product.description
|
19
|
+
= button_to 'Add to cart', add_item_accountability_order_group_path(current_order_group, product_id: product.id), class: 'btn btn-outline-success float-right'
|
19
20
|
|
20
|
-
|
21
|
-
|
22
|
-
= current_account
|
23
|
-
|
24
|
-
%p
|
25
|
-
%b OrderGroup ID (db):
|
26
|
-
= current_order_group&.id
|
27
|
-
|
28
|
-
%br
|
29
|
-
%br
|
30
|
-
%br
|
31
|
-
%br
|
32
|
-
%br
|
33
|
-
%hr
|
34
|
-
|
35
|
-
- @products.each do |product|
|
36
|
-
%h2= product.name
|
37
|
-
%p= product.description
|
38
|
-
= button_to 'Add to cart', add_item_accountability_order_group_path(current_order_group, product_id: product.id)
|
39
|
-
%hr
|
21
|
+
- if admin_session?
|
22
|
+
= link_to 'Add New Product', new_accountability_product_path, class: 'btn btn-outline-primary float-right mt-5'
|