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