accountability 0.1.1 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -9
  3. data/app/assets/stylesheets/accountability/application.scss +0 -0
  4. data/app/controllers/accountability/accounts_controller.rb +2 -4
  5. data/app/controllers/accountability/billing_configurations_controller.rb +80 -0
  6. data/app/controllers/accountability/order_groups_controller.rb +2 -4
  7. data/app/controllers/accountability/payments_controller.rb +25 -0
  8. data/app/controllers/accountability/products_controller.rb +7 -5
  9. data/app/controllers/accountability/statements_controller.rb +19 -0
  10. data/app/controllers/accountability_controller.rb +47 -0
  11. data/app/helpers/accountability/{application_helper.rb → accountability_helper.rb} +1 -1
  12. data/app/helpers/accountability/billing_configurations_helper.rb +47 -0
  13. data/app/helpers/accountability/products_helper.rb +21 -0
  14. data/app/models/accountability/account.rb +31 -1
  15. data/app/models/accountability/application_record.rb +24 -0
  16. data/app/models/accountability/billing_configuration.rb +41 -0
  17. data/app/models/accountability/coupon.rb +2 -0
  18. data/app/models/accountability/credit.rb +53 -29
  19. data/app/models/accountability/debit.rb +10 -0
  20. data/app/models/accountability/inventory.rb +61 -0
  21. data/app/models/accountability/offerable.rb +28 -1
  22. data/app/models/accountability/offerable/scope.rb +48 -0
  23. data/app/models/accountability/order_item.rb +3 -7
  24. data/app/models/accountability/payment.rb +22 -3
  25. data/app/models/accountability/product.rb +45 -5
  26. data/app/models/accountability/statement.rb +46 -0
  27. data/app/models/accountability/{account/transactions.rb → transactions.rb} +14 -2
  28. data/app/models/concerns/accountability/active_merchant_interface.rb +15 -0
  29. data/app/models/concerns/accountability/active_merchant_interface/stripe_interface.rb +134 -0
  30. data/app/pdfs/statement_pdf.rb +91 -0
  31. data/app/views/accountability/accounts/show.html.haml +21 -9
  32. data/app/views/accountability/products/index.html.haml +17 -34
  33. data/app/views/accountability/products/new.html.haml +73 -0
  34. data/app/views/accountability/shared/_session_info.html.haml +24 -0
  35. data/config/locales/en.yml +19 -0
  36. data/db/migrate/20190814000455_create_accountability_tables.rb +43 -1
  37. data/lib/accountability.rb +2 -1
  38. data/lib/accountability/configuration.rb +22 -2
  39. data/lib/accountability/engine.rb +6 -1
  40. data/lib/accountability/extensions/acts_as_billable.rb +11 -0
  41. data/lib/accountability/rails/routes.rb +72 -0
  42. data/lib/accountability/types.rb +9 -0
  43. data/lib/accountability/types/billing_configuration_types.rb +57 -0
  44. data/lib/accountability/version.rb +1 -1
  45. data/lib/generators/accountability/install_generator.rb +47 -0
  46. data/lib/generators/accountability/templates/migration.rb.tt +155 -0
  47. data/lib/generators/accountability/templates/price_overrides_migration.rb.tt +13 -0
  48. metadata +73 -12
  49. data/app/assets/stylesheets/accountability/application.css +0 -15
  50. data/app/controllers/accountability/application_controller.rb +0 -45
  51. data/app/views/accountability/products/new.html.erb +0 -40
  52. 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: %i[one_time weekly monthly annually]
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
- private
87
+ private
49
88
 
50
- def raise_offerable_not_found
51
- raise 'Offerable not found'
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 Account::Transactions
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
- %h1 Account Overview
1
+ .container
2
+ - if Accountability::Configuration.dev_tools_enabled?
3
+ = render partial: 'accountability/shared/session_info'
2
4
 
3
- %br
5
+ - if admin_session?
6
+ = link_to 'Back', accountability_accounts_path, class: 'btn btn-outline-primary float-right'
4
7
 
5
- %h2
6
- %b Billable ID:
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
- = link_to 'Back', accountability_accounts_path
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
- %h1 Products
1
+ .container
2
+ - if Accountability::Configuration.dev_tools_enabled?
3
+ = render partial: 'accountability/shared/session_info'
2
4
 
3
- = link_to 'Shopping Cart', accountability_order_group_path(current_order_group)
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
- %p
8
- %b User is admin:
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
- -# Temporary - we shouldn't assume they're on Devise
12
- %p
13
- %b Email:
14
- = current_user&.email
11
+ %h1.mb-5 Products
15
12
 
16
- %p
17
- %b Account ID (session):
18
- = session[:current_account_id]
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
- %p
21
- %b Account (db):
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'