accountability 0.1.1 → 0.2.1

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.
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'