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
@@ -4,6 +4,7 @@ module Accountability
|
|
4
4
|
class Account < ApplicationRecord
|
5
5
|
belongs_to :billable, polymorphic: true
|
6
6
|
|
7
|
+
has_many :statements, dependent: :destroy
|
7
8
|
has_many :order_groups, dependent: :destroy
|
8
9
|
has_many :order_items, through: :order_groups, inverse_of: :account
|
9
10
|
has_many :purchased_order_groups, -> { complete }, class_name: 'OrderGroup', inverse_of: :account
|
@@ -11,6 +12,22 @@ module Accountability
|
|
11
12
|
has_many :payments, dependent: :nullify
|
12
13
|
has_many :credits, dependent: :destroy
|
13
14
|
has_many :debits, dependent: :destroy
|
15
|
+
has_many :billing_configurations, dependent: :destroy
|
16
|
+
|
17
|
+
enum statement_schedule: %i[end_of_month bi_weekly]
|
18
|
+
|
19
|
+
def build_billing_configuration_with_active_merchant_data(billing_configuration_params, **options)
|
20
|
+
billing_configuration = billing_configurations.build(billing_configuration_params)
|
21
|
+
# We don't want to run this too often as it does create a charge against the card when verify_card is true.
|
22
|
+
# so we'll only run this when the config is valid.
|
23
|
+
billing_configuration.store_active_merchant_data(**options) if billing_configuration.valid?
|
24
|
+
billing_configuration
|
25
|
+
end
|
26
|
+
|
27
|
+
def billable_record_name
|
28
|
+
name_method = Configuration.billable_name_column
|
29
|
+
billable.public_send(name_method)
|
30
|
+
end
|
14
31
|
|
15
32
|
def balance
|
16
33
|
accrued_credits = credits.sum(:amount)
|
@@ -19,10 +36,23 @@ module Accountability
|
|
19
36
|
accrued_debits - accrued_credits
|
20
37
|
end
|
21
38
|
|
39
|
+
def balanced?
|
40
|
+
balance >= 0.00
|
41
|
+
end
|
42
|
+
|
22
43
|
def transactions
|
23
|
-
associated_credits = credits.includes(:product, :
|
44
|
+
associated_credits = credits.includes(:product, deductions: :discount).references(:order_item)
|
24
45
|
|
25
46
|
Transactions.new(debits: debits, credits: associated_credits)
|
26
47
|
end
|
48
|
+
|
49
|
+
def current_statement
|
50
|
+
latest_statement = statements.last
|
51
|
+
|
52
|
+
return if latest_statement.nil?
|
53
|
+
return if latest_statement.past?
|
54
|
+
|
55
|
+
latest_statement
|
56
|
+
end
|
27
57
|
end
|
28
58
|
end
|
@@ -1,3 +1,27 @@
|
|
1
1
|
class Accountability::ApplicationRecord < ActiveRecord::Base
|
2
2
|
self.abstract_class = true
|
3
|
+
|
4
|
+
before_validation :validate_validatable_attributes
|
5
|
+
cattr_accessor :validatable_attribute_names, default: []
|
6
|
+
|
7
|
+
def self.validates_attributes(*attribute_names)
|
8
|
+
self.validatable_attribute_names = attribute_names
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def validate_validatable_attributes
|
14
|
+
validatable_attribute_names.each do |attribute_name|
|
15
|
+
attribute = public_send attribute_name
|
16
|
+
|
17
|
+
next if attribute.blank?
|
18
|
+
|
19
|
+
attribute.validate
|
20
|
+
|
21
|
+
attribute.errors.each do |sub_attribute_name, error_message|
|
22
|
+
target = "#{attribute_name}.#{sub_attribute_name}".to_sym
|
23
|
+
errors.add target, error_message
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
3
27
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Accountability
|
2
|
+
class BillingConfiguration < ApplicationRecord
|
3
|
+
include Accountability::ActiveMerchantInterface
|
4
|
+
after_initialize :set_provider, if: :new_record?
|
5
|
+
|
6
|
+
belongs_to :account
|
7
|
+
has_many :payments, dependent: :restrict_with_error
|
8
|
+
|
9
|
+
attribute :billing_address, :billing_address, default: {}
|
10
|
+
attribute :active_merchant_data, :json, default: {}
|
11
|
+
|
12
|
+
enum provider: %i[unselected stripe]
|
13
|
+
|
14
|
+
scope :primary, -> { where primary: true }
|
15
|
+
|
16
|
+
validates :configuration_name, presence: true
|
17
|
+
validates :contact_first_name, :contact_last_name,
|
18
|
+
format: { with: Regex::LETTERS_AND_NUMBERS, message: :invalid }, presence: true
|
19
|
+
validates :contact_email, format: { with: Regex::EMAIL_ADDRESS, message: :invalid }
|
20
|
+
validates_attributes :billing_address
|
21
|
+
|
22
|
+
def contact_name
|
23
|
+
"#{contact_first_name} #{contact_last_name}"
|
24
|
+
end
|
25
|
+
|
26
|
+
def primary!
|
27
|
+
return if primary?
|
28
|
+
|
29
|
+
transaction do
|
30
|
+
account.billing_configurations.primary.update_all(primary: false) # rubocop:disable Rails/SkipsModelValidations
|
31
|
+
update!(primary: true)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def set_provider
|
38
|
+
self.provider = Configuration.payment_gateway[:provider]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -1,46 +1,70 @@
|
|
1
1
|
# A Credit represents a single charge to an Account
|
2
2
|
# To preserve data integrity, credits should never be modified
|
3
3
|
|
4
|
-
|
5
|
-
|
4
|
+
module Accountability
|
5
|
+
class Credit < ApplicationRecord
|
6
|
+
before_validation :set_amount, :set_statement
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
8
|
+
belongs_to :account
|
9
|
+
belongs_to :statement
|
10
|
+
belongs_to :order_item
|
11
|
+
has_many :deductions, dependent: :destroy
|
12
|
+
has_one :product, through: :order_item, inverse_of: :credits
|
11
13
|
|
12
|
-
|
14
|
+
validates :amount, :taxes, presence: true
|
15
|
+
validate :validate_amount_unchanged
|
13
16
|
|
14
|
-
|
17
|
+
delegate :name, to: :product, prefix: true
|
15
18
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
19
|
+
def base_price
|
20
|
+
if new_record?
|
21
|
+
order_item.default_price
|
22
|
+
else
|
23
|
+
amount + total_deductions + taxes
|
24
|
+
end
|
21
25
|
end
|
22
|
-
end
|
23
26
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
27
|
+
def total_deductions
|
28
|
+
if persisted?
|
29
|
+
deductions.sum(:amount)
|
30
|
+
else
|
31
|
+
deductions.map(&:amount).sum
|
32
|
+
end
|
29
33
|
end
|
30
|
-
end
|
31
34
|
|
32
|
-
|
35
|
+
private
|
33
36
|
|
34
|
-
|
35
|
-
|
37
|
+
def set_amount
|
38
|
+
return if persisted?
|
36
39
|
|
37
|
-
|
38
|
-
|
40
|
+
pre_tax_total = base_price - total_deductions
|
41
|
+
|
42
|
+
self.taxes = if product.tax_exempt?
|
43
|
+
0.00
|
44
|
+
else
|
45
|
+
pre_tax_total * Configuration.tax_rate / 100
|
46
|
+
end
|
47
|
+
|
48
|
+
self.amount = pre_tax_total - taxes
|
49
|
+
end
|
39
50
|
|
40
|
-
|
41
|
-
|
42
|
-
return unless amount_changed?
|
51
|
+
def set_statement
|
52
|
+
return if statement.present?
|
43
53
|
|
44
|
-
|
54
|
+
current_statement = account.current_statement
|
55
|
+
|
56
|
+
if current_statement.present?
|
57
|
+
self.statement = current_statement
|
58
|
+
else
|
59
|
+
build_statement account: account
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def validate_amount_unchanged
|
64
|
+
return if new_record?
|
65
|
+
return unless amount_changed?
|
66
|
+
|
67
|
+
errors.add(:amount, 'cannot be changed')
|
68
|
+
end
|
45
69
|
end
|
46
70
|
end
|
@@ -1,5 +1,9 @@
|
|
1
|
+
# We reset the account's :last_balanced_at after a debit is created.
|
2
|
+
# It is very important to make sure that the value is always accurate.
|
3
|
+
|
1
4
|
class Accountability::Debit < ApplicationRecord
|
2
5
|
before_validation :set_amount
|
6
|
+
after_create :update_last_balanced_at!
|
3
7
|
|
4
8
|
belongs_to :account
|
5
9
|
belongs_to :payment, optional: true
|
@@ -11,4 +15,10 @@ class Accountability::Debit < ApplicationRecord
|
|
11
15
|
|
12
16
|
self.amount = payment.amount
|
13
17
|
end
|
18
|
+
|
19
|
+
def update_last_balanced_at!
|
20
|
+
return unless account.balanced?
|
21
|
+
|
22
|
+
account.update(last_balanced_at: created_at)
|
23
|
+
end
|
14
24
|
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
# The InventoryItems class is conceptually comparable to ActiveRecord's CollectionProxy.
|
4
|
+
# It provides an interface for interacting with inventory records within a product's scope.
|
5
|
+
#
|
6
|
+
# This is an improvement over the original approach of returning a source_class::ActiveRecord_Relation object which
|
7
|
+
# lacked any information related to the Accountability::Product record itself.
|
8
|
+
#
|
9
|
+
# Usage:
|
10
|
+
# inventory = Inventory.new(product)
|
11
|
+
# inventory.active.count
|
12
|
+
|
13
|
+
module Accountability
|
14
|
+
class Inventory
|
15
|
+
extend Forwardable
|
16
|
+
|
17
|
+
attr_accessor :product
|
18
|
+
|
19
|
+
def_delegators :product, :source_class, :source_scope, :offerable_template
|
20
|
+
def_delegators :collection, :to_s, :each, :first, :last, :sort_by, :any?, :none?, :count
|
21
|
+
|
22
|
+
def initialize(product, available_only: false)
|
23
|
+
@product = product
|
24
|
+
@scope_availability = available_only
|
25
|
+
end
|
26
|
+
|
27
|
+
def collection
|
28
|
+
records = source_class.where(**source_scope)
|
29
|
+
records = records.public_send(offerable_template.whitelist) if scope_availability?
|
30
|
+
|
31
|
+
records.map { |record| InventoryItem.new(record: record, product: product) }
|
32
|
+
end
|
33
|
+
|
34
|
+
def available
|
35
|
+
@scope_availability = true
|
36
|
+
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
class InventoryItem
|
43
|
+
attr_accessor :record, :product
|
44
|
+
|
45
|
+
def initialize(record:, product:)
|
46
|
+
@record = record
|
47
|
+
@product = product
|
48
|
+
end
|
49
|
+
|
50
|
+
def price
|
51
|
+
product.price
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def scope_availability?
|
58
|
+
@scope_availability
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
class Accountability::Offerable
|
4
4
|
cattr_accessor :collection, default: {}
|
5
|
-
attr_accessor :tenant, :category, :class_name, :trait, :scopes, :properties, :callbacks
|
5
|
+
attr_accessor :tenant, :category, :class_name, :trait, :scopes, :properties, :callbacks, :whitelist
|
6
6
|
|
7
7
|
def initialize(category, tenant: :default, trait: nil, class_name:)
|
8
8
|
@category = category
|
@@ -12,6 +12,7 @@ class Accountability::Offerable
|
|
12
12
|
@scopes = {}
|
13
13
|
@properties = {}
|
14
14
|
@callbacks = {}
|
15
|
+
@whitelist = :all
|
15
16
|
end
|
16
17
|
|
17
18
|
def self.add(category, tenant: :default, trait: nil, class_name:)
|
@@ -22,18 +23,44 @@ class Accountability::Offerable
|
|
22
23
|
offerable
|
23
24
|
end
|
24
25
|
|
26
|
+
# Used when creating a product to define queries identifying saleable records from the host table.
|
27
|
+
# This can be used to de-scope sold inventory, but it is recommended to define a whitelist for that instead.
|
28
|
+
#
|
29
|
+
# CONSIDER: Add a parameter for indicating optional scopes
|
25
30
|
def add_scope(name, title: name, options: :auto)
|
26
31
|
scopes[name] = { title: title.to_s, options: options, category: category }
|
27
32
|
|
28
33
|
self
|
29
34
|
end
|
30
35
|
|
36
|
+
# Used for limiting the product's full inventory scope to a subset.
|
37
|
+
# Non-whitelisted records will be treated the same as a private product's inventory.
|
38
|
+
#
|
39
|
+
# The method takes the name of a pre-defined ActiveRecord scope as an argument.
|
40
|
+
# If no whitelist is specified, :all will be used instead.
|
41
|
+
def inventory_whitelist(whitelist_scope)
|
42
|
+
self.whitelist = whitelist_scope.to_sym
|
43
|
+
|
44
|
+
self
|
45
|
+
end
|
46
|
+
|
47
|
+
# Used to differentiate records within a product's scope/inventory.
|
48
|
+
# For example, consider a co-location that allows customers to choose their own cabinet:
|
49
|
+
# `offer.add_property :cabinet_location_column, title: 'Asset Tag'`
|
50
|
+
#
|
51
|
+
# Use the `position` column if it is important that the properties display in a specific order.
|
52
|
+
#
|
53
|
+
# CONSIDER: Add support for property-specific pricing
|
31
54
|
def add_property(name, title: name, position: nil)
|
32
55
|
position = properties.size.next if position.nil?
|
33
56
|
properties[name] = { title: title.to_s, position: position }
|
57
|
+
|
34
58
|
self
|
35
59
|
end
|
36
60
|
|
61
|
+
# Used for setting multiple properties in a single line.
|
62
|
+
# Column names will be used as titles, and property order retained.
|
63
|
+
# `offer.add_properties :asset_tag, :room_number, :color`
|
37
64
|
def add_properties(*names)
|
38
65
|
names.each do |name|
|
39
66
|
add_property(name)
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# Scope objects represent an offerable's scoping options as defined by `offer.add_scope`
|
2
|
+
# The plan is to replace the Offerable#scopes hash attribute with an array of scope objects
|
3
|
+
|
4
|
+
class Accountability::Offerable::Scope
|
5
|
+
attr_accessor :source_class, :category, :attribute, :title
|
6
|
+
attr_writer :options
|
7
|
+
|
8
|
+
def initialize(source_class:, category:, attribute:, title: nil, options: :auto)
|
9
|
+
@source_class = source_class
|
10
|
+
@category = category
|
11
|
+
@attribute = attribute
|
12
|
+
@title = title.presence || attribute.to_s.titleize
|
13
|
+
@options = options
|
14
|
+
end
|
15
|
+
|
16
|
+
# Returns an array of values to choose from when defining the scope for a new product.
|
17
|
+
# The product's `source_scope` column stores a hash mapping each scopes' @attribute with the selected options.
|
18
|
+
#
|
19
|
+
# For example, if an offerable called 'Bucket' has a scoped :color attribute with options %w[red green black grey],
|
20
|
+
# a "colored bucket" product can be created with a `source_scope` of `{ color: %w[red green] }`.
|
21
|
+
# Buckets available for purchase would be automatically found by querying `Bucket.where(color: %w[red green])`.
|
22
|
+
#
|
23
|
+
# When an offerable's scope has no options, it defaults to `:auto` and is generated automatically based on the
|
24
|
+
# attribute type when `#options` is called instead.
|
25
|
+
# String - Returns a unique array of values plucked from the attribute's column
|
26
|
+
# Enum - Returns am array containing each valid enum value
|
27
|
+
# Boolean - Returns [true, false]
|
28
|
+
# Any other attribute type will return an empty array.
|
29
|
+
def options
|
30
|
+
return @options unless @options == :auto
|
31
|
+
|
32
|
+
@options = case attribute_type
|
33
|
+
when ActiveModel::Type::String
|
34
|
+
source_class.distinct.pluck(attribute)
|
35
|
+
when ActiveRecord::Enum::EnumType
|
36
|
+
enums = source_class.defined_enums.with_indifferent_access
|
37
|
+
enums[attribute].keys
|
38
|
+
when ActiveModel::Type::Boolean
|
39
|
+
[true, false]
|
40
|
+
else
|
41
|
+
[]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def attribute_type
|
46
|
+
source_class.type_for_attribute(attribute)
|
47
|
+
end
|
48
|
+
end
|
@@ -33,14 +33,10 @@ class Accountability::OrderItem < ApplicationRecord
|
|
33
33
|
def accruing?
|
34
34
|
return false unless accruable?
|
35
35
|
return true if credits.none?
|
36
|
+
return false if product.accrues_one_time?
|
36
37
|
|
37
|
-
|
38
|
-
|
39
|
-
when 'monthly' then 1.month.ago
|
40
|
-
when 'annually' then 1.year.ago
|
41
|
-
end
|
42
|
-
|
43
|
-
last_accruement_date.before? threshold
|
38
|
+
billing_cycle_threshold = product.billing_cycle_length.ago
|
39
|
+
last_accruement_date.before? billing_cycle_threshold
|
44
40
|
end
|
45
41
|
|
46
42
|
def accruable?
|
@@ -1,8 +1,27 @@
|
|
1
|
-
# TODO: Destroy associated debit when marked as failed
|
2
|
-
|
3
1
|
class Accountability::Payment < ApplicationRecord
|
2
|
+
after_validation :process_transaction!, if: :pending?
|
3
|
+
|
4
4
|
belongs_to :account
|
5
|
-
|
5
|
+
belongs_to :billing_configuration
|
6
|
+
|
7
|
+
has_one :debit, dependent: :restrict_with_error
|
6
8
|
|
7
9
|
enum status: %i[pending processing complete failed]
|
10
|
+
|
11
|
+
validates :amount, presence: true, numericality: { greater_than: 10.00 }
|
12
|
+
|
13
|
+
def process_transaction!
|
14
|
+
return if debit.present?
|
15
|
+
return if errors.present?
|
16
|
+
|
17
|
+
if billing_configuration.charge(amount)
|
18
|
+
self.status = :complete
|
19
|
+
build_debit(account: account, amount: amount)
|
20
|
+
elsif billing_configuration.invalid?
|
21
|
+
self.status = :failed
|
22
|
+
billing_configuration.errors.full_messages.each { |error| errors.add(:base, error.titleize) }
|
23
|
+
else
|
24
|
+
self.status = :failed
|
25
|
+
end
|
26
|
+
end
|
8
27
|
end
|