Module: Models::TaxableResource

Extended by:
ActiveSupport::Concern
Included in:
CreditMemo, Invoice, Order, Quote
Defined in:
app/concerns/models/taxable_resource.rb

Overview

Mixin for Order, Quote, Invoice and CreditMemo that owns the
document-level ResourceTaxRate (sales-tax / VAT / GST snapshot for
the document) and propagates it to LineItems. The rate is fetched
from the external tax service (Taxes::GetTaxRate, which talks to
TaxJar / Avalara) when the document is taxable, copied from a parent
document when there's a credit / RMA chain, or zeroed out for store
transfers, tax-exempt billers and CI marketplace invoices.

Belongs to collapse

Instance Method Summary collapse

Instance Method Details

#apply_tax_rate_to_line_itemsObject

Propagates the current resource_tax_rate to all line items without re-fetching
from the external tax service. Use this when the tax rate has already been
inherited (e.g. copied from the original invoice) and only the line items need
to be updated to reflect it.



238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'app/concerns/models/taxable_resource.rb', line 238

def apply_tax_rate_to_line_items
  # Reload to avoid acting on lines pending destroy (frozen hash errors).
  line_items.reload
  if resource_tax_rate.nil?
    line_items.each { |li| li.update_tax_rate(rate: 0, resource_tax_rate_id: nil, tax_type: nil) }
  else
    attrs = { resource_tax_rate_id: resource_tax_rate.id, tax_type: resource_tax_rate.tax_type }
    line_items.active_goods_lines.each { |li| li.update_tax_rate(**attrs.merge({ rate: manual_rate_goods || resource_tax_rate.rate_goods })) }
    line_items.active_services_lines.each { |li| li.update_tax_rate(**attrs.merge({ rate: manual_rate_services || resource_tax_rate.rate_services })) }
    line_items.active_shipping_lines.each { |li| li.update_tax_rate(**attrs.merge({ rate: manual_rate_shipping || resource_tax_rate.rate_shipping })) }
    line_items.active_tax_unclassed_lines.each { |li| li.update_tax_rate(rate: 0, resource_tax_rate_id: nil, tax_type: nil) }
  end
  true
end

#build_tax_paramsHash{Symbol => Object}

Builds the parameter hash sent to Taxes::GetTaxRate (which wraps
the tax-service API call). Includes origin / destination address
parts, the line-item breakdown (eager-loading item.product_tax_code
to avoid N+1) and the freight charged. Used both for getting rates
and for posting transactions to TaxJar.

Returns:

  • (Hash{Symbol => Object})


161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'app/concerns/models/taxable_resource.rb', line 161

def build_tax_params
  params = { effective_date:,
             from_street: origin_address.street1, from_city: origin_address.city,
             from_state: origin_address.state_code, from_zip: origin_address.zip,
             from_country: origin_address.country&.iso,
             to_street: shipping_address.street1, to_city: shipping_address.city,
             to_state: shipping_address.state_code, to_zip: shipping_address.zip,
             to_country: shipping_address.country&.iso }
  if respond_to?(:line_items)
    # Add line items break down, first we check if our association is already loaded, if not we will eager_load with item for speed
    line_items_loaded = line_items.loaded? ? line_items : line_items.eager_load(item: :product_tax_code)
    params[:line_items] = line_items_loaded.active_non_shipping_lines.map do |li|
      {
        id: li.id,
        quantity: li.quantity,
        product_tax_code: li.product_tax_code&.product_tax_code,
        tax_class: li.tax_class,
        unit_price: li.discounted_price, # Not sure if they want discount or unit price msrp here
        discount: li.discounts_total # This is total discount on the line apparently
      }
    end
    params[:freight_charged] = if respond_to?(:shipping_cost)
                                 shipping_cost
                               else
                                 0.0
                               end
  end
  params
end

#calculate_tax_for_all_linesvoid

This method returns an undefined value.

Re-runs Models::TaxableLine#calculate_tax against every line on
the document and saves it. Used after a manual-rate change or a
rate refresh to bring stale tax_total columns in line.



350
351
352
353
354
355
# File 'app/concerns/models/taxable_resource.rb', line 350

def calculate_tax_for_all_lines
  line_items.each do |li|
    li.calculate_tax
    li.save
  end
end

#copy_tax_rate(orig_rate) ⇒ ResourceTaxRate?

Clones a parent document's ResourceTaxRate onto this document so
invoiced/credited amounts match the original sale, even if the
underlying tax tables have shifted in the meantime. Builds-or-updates
the existing resource_tax_rate row and assigns it back.

Parameters:

Returns:



98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'app/concerns/models/taxable_resource.rb', line 98

def copy_tax_rate(orig_rate)
  return nil if orig_rate.nil?

  new_rate = resource_tax_rate || ResourceTaxRate.new
  new_rate.assign_attributes(
    source: orig_rate.source, tax_type: orig_rate.tax_type, tax_rate_id: orig_rate.tax_rate_id,
    rate_goods: orig_rate.rate_goods, rate_services: orig_rate.rate_services,
    rate_shipping: orig_rate.rate_shipping, retrieved_on: orig_rate.retrieved_on
  )
  new_rate.save!
  self.resource_tax_rate = new_rate
  new_rate
end

#effective_dateDate

Date the tax rate is evaluated against — tax_date (e.g. for
back-dated invoices) when present, then ship date, falling back
to today for in-flight cart/quote.

Returns:

  • (Date)


42
43
44
# File 'app/concerns/models/taxable_resource.rb', line 42

def effective_date
  try(:tax_date) || try(:shipped_date) || Date.current
end

#get_rates_for_line(line_item) ⇒ Hash{Symbol => Object}

Resolves the rate / tax-type / resource-rate-id for a given
LineItem based on its tax_class (g / svc / shp / none),
honouring any manual rate override on the document. Returns zero
rate when the resource has no resource_tax_rate (untaxable).

Parameters:

Returns:

  • (Hash{Symbol => Object})

    {rate:, resource_tax_rate_id:, tax_type:}



289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'app/concerns/models/taxable_resource.rb', line 289

def get_rates_for_line(line_item)
  return { rate: 0, resource_tax_rate_id: nil, tax_type: nil } if resource_tax_rate.nil? || line_item.tax_class == 'none'

  rate = case line_item.tax_class
         when 'svc'
           manual_rate_services || resource_tax_rate.rate_services
         when 'shp'
           manual_rate_shipping || resource_tax_rate.rate_shipping
         else
           manual_rate_goods || resource_tax_rate.rate_goods
         end
  rate ||= 0 # Roman added because errors reported when picking items. I.e. TO662607

  { rate:, resource_tax_rate_id: resource_tax_rate.id, tax_type: resource_tax_rate.tax_type }
end

#get_tax_rate(auto_save: true) ⇒ ResourceTaxRate?

Fetches a fresh ResourceTaxRate from the tax service via
Taxes::GetTaxRate for the document's origin / destination /
line-items, persists it (as a build-or-update) and assigns it onto
the resource. When the resource isn't taxable
(#resource_not_taxable?) the existing rate is destroyed and the
FK column blanked. Set auto_save: false from before_create so
the parent save's transaction owns the writes.

Parameters:

  • auto_save (Boolean) (defaults to: true)

Returns:



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'app/concerns/models/taxable_resource.rb', line 201

def get_tax_rate(auto_save: true)
  if resource_not_taxable?
    # delete any existing rate
    resource_tax_rate&.destroy
    self.resource_tax_rate = nil
    update_columns(resource_tax_rate_id: nil) if auto_save
    nil
  else
    res = Taxes::GetTaxRate.new.process(**build_tax_params)
    # create or update the linked resource_tax_rate
    new_rate = resource_tax_rate || ResourceTaxRate.new
    new_rate.assign_attributes(
      source: res.source, tax_type: res.tax_type, tax_rate_id: res.tax_rate_id,
      rate_goods: res.rate_goods, rate_services: res.rate_services,
      rate_shipping: res.rate_shipping, retrieved_on: res.retrieved_on
    )
    new_rate.save!
    self.resource_tax_rate = new_rate
    update_columns(resource_tax_rate_id: new_rate.id) if auto_save
    new_rate
  end
end

#manual_rate_goodsBigDecimal?

Manual goods tax rate (decimal form) when the rep has overridden
the auto-fetched rate on the document; nil when the auto rate
should win.

Returns:

  • (BigDecimal, nil)


258
259
260
261
262
# File 'app/concerns/models/taxable_resource.rb', line 258

def manual_rate_goods
  return nil unless try(:manual_tax_rate_goods)

  manual_tax_rate_goods / 100
end

#manual_rate_servicesBigDecimal?

Manual services tax rate (decimal form), nil when not overridden.

Returns:

  • (BigDecimal, nil)


267
268
269
270
271
# File 'app/concerns/models/taxable_resource.rb', line 267

def manual_rate_services
  return nil unless try(:manual_tax_rate_services)

  manual_tax_rate_services / 100
end

#manual_rate_shippingBigDecimal?

Manual shipping tax rate (decimal form), nil when not overridden.

Returns:

  • (BigDecimal, nil)


276
277
278
279
280
# File 'app/concerns/models/taxable_resource.rb', line 276

def manual_rate_shipping
  return nil unless try(:manual_tax_rate_shipping)

  manual_tax_rate_shipping / 100
end

#origin_addressAddress?

The fulfilling warehouse address — origin side of the
nexus/destination-tax calculation. Pulled from the document's
Store's warehouse.

Returns:



51
52
53
# File 'app/concerns/models/taxable_resource.rb', line 51

def origin_address
  store&.warehouse_address
end

#refresh_tax_ratevoid

This method returns an undefined value.

after_update hook (when #should_refresh_tax_rate? fires) — fetches
a fresh rate from the tax service and pushes it down to every line
item. Triggered on shipping-address / tax-exempt / manual-rate changes.



229
230
231
232
# File 'app/concerns/models/taxable_resource.rb', line 229

def refresh_tax_rate
  get_tax_rate(auto_save: true)
  apply_tax_rate_to_line_items
end

#resource_not_taxable?Boolean

Returns:

  • (Boolean)


112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'app/concerns/models/taxable_resource.rb', line 112

def resource_not_taxable?
  # no shipping address
  return true if shipping_address.nil?

  # no country associated with shipping address
  return true if shipping_address.country.nil?

  return true if shipping_address.country.iso == 'US' && TAXABLE_STATES.select { |k, _v| k == shipping_address.state_code.upcase.to_sym }.keys.map(&:to_s).first.nil?

  # they have a tax exemption
  return true if billing_entity && billing_entity.tax_exemptions.effective_on(effective_date).where(state_code:).any?

  # it's marked as manually tax exempt (e.g Amazon Seller Central order where Amazon has collected their exemption certificate)
  return true if respond_to?(:tax_exempt) && tax_exempt?
  # it's a store transfer
  return true if is_a?(Order) && (order_type == Order::STORE_TRANSFER)
  # it's an international order between US and CA
  return true if origin_address.country.present? && shipping_address.country.present? &&
                 origin_address.country.iso.in?(%w[US CA]) && (origin_address.country.iso != shipping_address.country.iso)

  # else it is taxable
  false
end

#resource_tax_rateResourceTaxRate



16
# File 'app/concerns/models/taxable_resource.rb', line 16

belongs_to :resource_tax_rate, dependent: :destroy, optional: true

#set_initial_tax_rateResourceTaxRate?

before_create hook — populates the document's ResourceTaxRate.
Order-of-precedence: copy from the parent Order (for invoices),
from the original invoice (for RMAs and credit memos), from the
credit-Order (for stand-alone credit memos), otherwise call
#get_tax_rate to fetch a fresh rate from the tax service.

Returns:



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'app/concerns/models/taxable_resource.rb', line 62

def set_initial_tax_rate
  if is_a?(Invoice) && order.present?
    # copy tax rate from order
    copy_tax_rate(order.resource_tax_rate)
  elsif is_a?(Order) && (order_type == 'CO') && rma.present?
    if rma.has_replacement_items? && rma.original_invoice&.invoice_type != Invoice::CI
      # If there is a replacement order, use current tax rates so the credit
      # and replacement orders line up. Skipped for CI (Consignment) invoices
      # since the marketplace (e.g. Amazon Seller Central) sets the tax and
      # we inherit those exact amounts on the credit order's line items — the
      # header rate must come from the invoice so it matches those amounts.
      get_tax_rate(auto_save: false)
    elsif rma.original_invoice.present?
      # copy tax rate from original invoice
      copy_tax_rate(rma.original_invoice.resource_tax_rate)
    else
      get_tax_rate(auto_save: false)
    end
  elsif is_a?(CreditMemo) && credit_order.present?
    # copy tax rate from credit order
    copy_tax_rate(credit_order.resource_tax_rate)
  elsif is_a?(CreditMemo) && original_invoice.present?
    # copy tax rate from original invoice
    copy_tax_rate(original_invoice.resource_tax_rate)
  else
    get_tax_rate(auto_save: false)
  end
end

#should_refresh_tax_rate?Boolean

Returns:

  • (Boolean)


22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'app/concerns/models/taxable_resource.rb', line 22

def should_refresh_tax_rate?
  # CI = Consignment Invoice (marketplace sales, e.g. Amazon Seller Central).
  # On these invoices the marketplace collects and remits tax, so the tax_total
  # on each line item is set directly by the marketplace feed — not calculated
  # from our tax rates. Credit orders created from CI-invoice RMAs inherit those
  # exact amounts. Refreshing would overwrite them with a rate-based calculation.
  return false if respond_to?(:ci_invoice_credit_order?) && ci_invoice_credit_order?

  saved_change_to_shipping_address_id? or
    (respond_to?(:tax_exempt) and saved_change_to_tax_exempt?) or
    (respond_to?(:manual_tax_rate_goods) and saved_change_to_manual_tax_rate_goods?) or
    (respond_to?(:manual_tax_rate_services) and saved_change_to_manual_tax_rate_services?) or
    (respond_to?(:manual_tax_rate_shipping) and saved_change_to_manual_tax_rate_shipping?)
end

#state_codeString?

Convenience accessor for the destination state's two-letter code.

Returns:

  • (String, nil)


139
140
141
142
143
# File 'app/concerns/models/taxable_resource.rb', line 139

def state_code
  return nil if shipping_address.nil?

  shipping_address.state_code
end

#state_code_symSymbol?

#state_code as a Symbol — matches the keys used by TAXABLE_STATES.

Returns:

  • (Symbol, nil)


148
149
150
151
152
# File 'app/concerns/models/taxable_resource.rb', line 148

def state_code_sym
  return nil if state_code.nil?

  state_code.to_sym
end

#taxes_grouped_by_rateHash{String => BigDecimal}

Compact tax-totals roll-up — { 'sales_tax' => 123.45, … }
used by reports and the audit-trail diff viewer.

Returns:

  • (Hash{String => BigDecimal})


337
338
339
340
341
342
343
# File 'app/concerns/models/taxable_resource.rb', line 337

def taxes_grouped_by_rate
  tax_grouped = {}
  line_items.group_by(&:tax_type).each do |tax_type, lines|
    tax_grouped[tax_type] = lines.sum(&:tax_total) unless tax_type.nil?
  end
  tax_grouped
end

#taxes_grouped_by_typeHash{String => Hash{Symbol => Object}}

PDF-friendly tax breakdown grouped by tax_type and rate —
{ 'sales_tax' => { tax_amount:, name:, rate: }, … } with $0
rates filtered out and the legacy tax_offset rounding adjustment
added to the first bucket so totals reconcile with old data.

Returns:

  • (Hash{String => Hash{Symbol => Object}})


311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'app/concerns/models/taxable_resource.rb', line 311

def taxes_grouped_by_type
  tax_grouped = {}
  tt = nil
  grouped_by_type = line_items.all.group_by(&:tax_type)
  grouped_by_type.delete_if { |tax_type, _lines| tax_type.nil? }
  grouped_by_type.each do |tax_type, lines_by_type|
    lines_by_type.group_by(&:tax_rate).each do |rate, lines_by_rate|
      tt = tax_type
      tax_amount = lines_by_rate.sum(&:tax_total)
      rate_percentage = (rate * 100)
      next if tax_amount.zero? # don't show $0 amounts

      rate_string = ActionController::Base.helpers.number_with_precision(rate_percentage, precision: 3, strip_insignificant_zeros: true)
      name = "#{TaxRate.description_for(tt, destination_country: try(:shipping_address)&.country)} #{rate_string}%"
      tax_grouped[tax_type] = { tax_amount:, name:, rate: rate_percentage }
    end
  end
  # add any offset amount to the total, this is for legacy documents which had a rounding issue
  tax_grouped[tt][:tax_amount] += tax_offset if tt && tax_grouped[tt] && tax_offset
  tax_grouped
end