Class: CustomerOutletPurchase

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable
Defined in:
app/models/customer_outlet_purchase.rb

Overview

Links an Opportunity a sales rep worked to the Invoice the customer
actually generated through a retail outlet — Amazon, Home Depot, Costco,
Build.com, a distributor — instead of buying direct.

Verification is a single gate answering two questions at once: is this order
part of that project, and does the rep earn commission on it?
Sales
management set the rate at 1% (Setting.outlet_purchase_commission_rate),
treating it like an assisted tech call rather than a direct sale.

Nothing here writes to the Opportunity: orders.opportunity_id and the rep
columns on Order/Invoice deliberately stay untouched, because four separate
reporting paths key off them and would silently count an outlet sale as a
direct win. Commission is read FROM this table, never pushed into those.
See doc/tasks/202608081330_OUTLET_PURCHASE_ATTRIBUTION.md § 3.

The invoice is the anchor for every channel, never the order: Amazon FBA
sales produce an invoice with no order at all.

The nightly sweep's proposals land unverified and the orders team rules on
them. A rep adding an order they already know about skips the queue.

Defined Under Namespace

Classes: Commission, MatchEvidence, RepEngagement

Constant Summary collapse

MATCH_KINDS =

How the opportunity and the invoice were tied together. manual is a human
asserting the link — the only route that works for Amazon FBA, which lands
as a consignment invoice (Invoice::CI, no order) whose ship-to Amazon
anonymises: no name at all, and street1 merely repeats the city.

%i[name address both manual].freeze
SOURCES =

Who proposed the row.

%i[nightly rep manager].freeze
OUTLET_LABELS =

Catalog → the vocabulary Opportunity#purchase_outlet already uses. 327
existing records use these labels, so mapping keeps old and new rows
grouping together in reports instead of fragmenting "Amazon.com" from
"Amazon Seller (USA)". Anything unmapped falls back to the catalog name.

{
  CatalogConstants::AMAZON_SC_US_CATALOG_ID => 'Amazon.com',
  CatalogConstants::AMAZON_SC_CA_CATALOG_ID => 'Amazon.com',
  CatalogConstants::HOME_DEPOT_USA => 'Home Depot',
  CatalogConstants::HOME_DEPOT_CANADA => 'Home Depot',
  CatalogConstants::COSTCO_CANADA => 'Costco.com',
  CatalogConstants::LOWES_USA => 'Lowes',
  # The Lowe's Canada catalog is retired — the chain is RONA now, and RONA has
  # no entry of its own in `Opportunity#purchase_outlet`, so both land on the
  # same label RONA_CANADA already uses rather than claiming a Lowe's sale.
  CatalogConstants::LOWES_CANADA => 'Other Retailer',
  CatalogConstants::WAYFAIR_USA => 'Other E-Tailer',
  CatalogConstants::WAYFAIR_CANADA => 'Other E-Tailer',
  CatalogConstants::BUILD_COM => 'Other E-Tailer',
  CatalogConstants::RONA_CANADA => 'Other Retailer',
  CatalogConstants::WALMART_SELLER_USA => 'Other Retailer'
}.freeze
WRITTEN_OFF_STATES =

States where we recorded that the sale did not happen. A verified outlet
purchase says it did — just not through us — so the record is wrong and gets
corrected. cancelled is deliberately NOT here: cancelling is an explicit
"this project is not happening" decision, often a duplicate or junk record,
and silently reversing one would be a surprise rather than a correction.

%w[lost abandoned].freeze
MATCH_WINDOW_DAYS =

An invoice more than this many days after the opportunity is coincidence,
not follow-through: 0-60 days holds 89% of the revenue a 180-day window
finds, and 80% of it lands inside 30.

60

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Belongs to collapse

Instance Attribute Summary collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#customerCustomer

Returns whose purchase this is; the anchor, always present.

Returns:

  • (Customer)

    whose purchase this is; the anchor, always present



75
# File 'app/models/customer_outlet_purchase.rb', line 75

belongs_to :customer, inverse_of: :outlet_purchases

#invoiceInvoice

Returns the retailer invoice; never an order, FBA has none.

Returns:

  • (Invoice)

    the retailer invoice; never an order, FBA has none



81
# File 'app/models/customer_outlet_purchase.rb', line 81

belongs_to :invoice, inverse_of: :outlet_purchases

#invoice_idObject (readonly)

Mirror the two partial UNIQUE indexes so a duplicate surfaces as a form
error instead of a 500. Project-level and customer-level rows are scoped
separately, exactly as the indexes are.

Validations (if => #opportunity_id? ):

  • Uniqueness ({ scope: :opportunity_id, message: 'is already linked to this opportunity' })

Validations (unless => #opportunity_id? ):

  • Uniqueness ({ scope: %i[customer_id opportunity_id], message: 'is already linked to this customer' })


94
95
96
# File 'app/models/customer_outlet_purchase.rb', line 94

validates :invoice_id,
uniqueness: { scope: :opportunity_id, message: 'is already linked to this opportunity' },
if: :opportunity_id?

#opportunityOpportunity?

Returns the project, when the purchase relates to one.

Returns:

  • (Opportunity, nil)

    the project, when the purchase relates to one



78
# File 'app/models/customer_outlet_purchase.rb', line 78

belongs_to :opportunity, inverse_of: :outlet_purchases, optional: true

#verified_byParty?

Returns whoever accepted the attribution.

Returns:

  • (Party, nil)

    whoever accepted the attribution



84
# File 'app/models/customer_outlet_purchase.rb', line 84

belongs_to :verified_by, class_name: 'Party', optional: true

Class Method Details

.acceptedActiveRecord::Relation<CustomerOutletPurchase>

A relation of CustomerOutletPurchases that are accepted. Active Record Scope

Returns:

See Also:



131
# File 'app/models/customer_outlet_purchase.rb', line 131

scope :accepted, -> { where(state: 'pending') }

.closingActiveRecord::Relation<CustomerOutletPurchase>

A relation of CustomerOutletPurchases that are closing. Active Record Scope

Returns:

See Also:



134
# File 'app/models/customer_outlet_purchase.rb', line 134

scope :closing, -> { accepted.where(closes_opportunity: true) }

.customer_levelActiveRecord::Relation<CustomerOutletPurchase>

A relation of CustomerOutletPurchases that are customer level. Active Record Scope

Returns:

See Also:



136
# File 'app/models/customer_outlet_purchase.rb', line 136

scope :customer_level, -> { where(opportunity_id: nil) }

.unverifiedActiveRecord::Relation<CustomerOutletPurchase>

A relation of CustomerOutletPurchases that are unverified. Active Record Scope

Returns:

See Also:



128
# File 'app/models/customer_outlet_purchase.rb', line 128

scope :unverified, -> { where(state: 'unverified') }

Instance Method Details

#announce_rulingvoid

This method returns an undefined value.

Announce a ruling so the handlers can react to committed state.

after_commit rather than the state machine: a row born pending from
#accept_when_added_by_hand never transitions, and hanging reactions off
after_transition twice produced the same silent bug — the rep's own link
skipping the channel stamp, then skipping the written-off correction.

Rejection is only announced when it withdraws an ACCEPTED attribution.
Rejecting an unverified proposal undoes nothing, because nothing had been
counted.



217
218
219
220
221
222
223
224
225
226
227
228
# File 'app/models/customer_outlet_purchase.rb', line 217

def announce_ruling
  return unless saved_change_to_state?

  if pending?
    publish_event(Events::OutletPurchaseVerified,
                  data: { customer_outlet_purchase_id: id, opportunity_id: opportunity_id,
                          verified_by_id: verified_by_id })
  elsif rejected? && state_before_last_save == 'pending'
    publish_event(Events::OutletPurchaseRejected,
                  data: { customer_outlet_purchase_id: id, opportunity_id: opportunity_id })
  end
end

#attributed?Boolean

Verified: belongs to this project and approved for commission.

Returns:

  • (Boolean)


233
234
235
# File 'app/models/customer_outlet_purchase.rb', line 233

def attributed?
  pending?
end

#candidate_opportunityOpportunity?

The project a customer-level row came off, recomputed.

Opportunity::OutletPurchaseMatcher matches on name and address, then
detaches the row when the purchase shares no product line with anything
quoted — a towel warmer against a floor-heating quote. It does not record
WHICH project it walked away from, so a reviewer opening one of these saw a
purchase, a confidence score, and no way to tell whether a rep had been
anywhere near this customer.

Recomputed rather than stored: the matcher's own rule is "nearest in time
within the window", which is cheap to reproduce and needs no backfill for
the rows already written. Every customer-level row in the first backlog
resolves to exactly one project this way.

Nil on an attached row — #opportunity is already the answer there.

Returns:



404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'app/models/customer_outlet_purchase.rb', line 404

def candidate_opportunity
  return @candidate_opportunity if defined?(@candidate_opportunity)
  return @candidate_opportunity = nil unless product_detached?

  # Ambiguity resolves to silence. The matcher picked its project with the
  # normalised ship-to name and address, which this does not reproduce — so
  # with more than one project in the window there is no way to say WHICH one
  # it walked away from, and naming the nearest would be a guess printed as a
  # fact. Every detached row in the first backlog has exactly one.
  window = (invoice_date - MATCH_WINDOW_DAYS).beginning_of_day..invoice_date.end_of_day
  in_window = Opportunity.where(customer_id: customer_id, created_at: window).limit(2).to_a

  @candidate_opportunity = in_window.one? ? in_window.first : nil
end

#channel_labelString?

The Opportunity#purchase_outlet vocabulary this purchase maps onto.

Returns:

  • (String, nil)


271
272
273
# File 'app/models/customer_outlet_purchase.rb', line 271

def channel_label
  OUTLET_LABELS[invoice&.customer&.catalog_id] || outlet_name
end

#close_opportunity!Boolean

Mark this purchase as satisfying the whole opportunity and close it won.

Never automatic. 28% of matched purchases are under a quarter of the quoted
value, and won cancels every follow-up activity — so a machine closing a
$402 towel warmer against a $10,079 floor-heating quote would destroy the
chase on the remainder. coverage_ratio and similarity exist to inform
this decision, not to make it.

Returns:

  • (Boolean)

    whether the opportunity ended up won



339
340
341
342
343
344
345
346
347
348
349
350
# File 'app/models/customer_outlet_purchase.rb', line 339

def close_opportunity!
  # `sales_present?` reads `closing`, which is `attributed AND
  # closes_opportunity`. Persisting the flag on an unclaimed row would leave a
  # close decision on record with no `won` opportunity behind it.
  return false if opportunity.nil? || !attributed?

  update!(closes_opportunity: true)
  # `win` covers open states; a terminal opportunity needs `reopen`, which
  # routes to `won` on the same `sales_present?` guard.
  opportunity.win || opportunity.reopen unless opportunity.won?
  opportunity.reload.won?
end

#commissionCustomerOutletPurchase::Commission

What this purchase pays the rep, once verified. Extracted so the model stays
about persistence and lifecycle; see Commission.



356
357
358
# File 'app/models/customer_outlet_purchase.rb', line 356

def commission
  @commission ||= CustomerOutletPurchase::Commission.new(self)
end

#commission_basisObject

Alias for Commission#basis

Returns:

  • (Object)

    Commission#commission_basis

See Also:



203
# File 'app/models/customer_outlet_purchase.rb', line 203

delegate :value, :basis, :rate, to: :commission, prefix: true

#commission_rateObject

Alias for Commission#rate

Returns:

  • (Object)

    Commission#commission_rate

See Also:



203
# File 'app/models/customer_outlet_purchase.rb', line 203

delegate :value, :basis, :rate, to: :commission, prefix: true

#commission_valueObject

Alias for Commission#value

Returns:

  • (Object)

    Commission#commission_value

See Also:



203
# File 'app/models/customer_outlet_purchase.rb', line 203

delegate :value, :basis, :rate, to: :commission, prefix: true

#confidence_breakdownObject

Alias for Evidence#confidence_breakdown

Returns:

  • (Object)

    Evidence#confidence_breakdown

See Also:



200
201
# File 'app/models/customer_outlet_purchase.rb', line 200

delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines,
:quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence

#correct_a_written_off_opportunityBoolean

Turn a written-off opportunity won on verification, and leave every other
state alone.

Two cases, opposite handling:

  • Already written off (lost / abandoned) — the record is simply
    wrong. The customer bought; we logged a loss. 16 abandoned and 13
    lost in the dev backfill were mis-stated this way. Correcting it is
    the whole point of "mark it won instead of lost".
  • Still open (follow_up, quoting, …) — leave it open. A marketplace
    order routinely misses the thermostat, sensor and rough-in kit, and 28%
    of matches cover under a quarter of the quoted value. Closing those
    would cancel every follow-up activity and kill a live upsell.

Note this wins on ANY coverage: a verified purchase against a written-off
project is better recorded as a partial win than as a loss that never
happened. #close_opportunity! stays available for the open-project case,
where a human decides the purchase finished the job.

Returns:

  • (Boolean)

    whether it corrected anything



295
296
297
298
299
300
301
302
303
304
305
306
# File 'app/models/customer_outlet_purchase.rb', line 295

def correct_a_written_off_opportunity
  return false unless opportunity&.state&.in?(WRITTEN_OFF_STATES)

  # Remembered so {#undo_written_off_correction} can put it back. The state
  # machine has no route from `won` to `lost`, so this cannot be re-derived
  # after the fact.
  was = opportunity.state
  return false unless close_opportunity!

  update_column(:corrected_opportunity_from, was)
  true
end

#coverage_basis_descriptionObject

Alias for Evidence#coverage_basis_description

Returns:

  • (Object)

    Evidence#coverage_basis_description

See Also:



200
201
# File 'app/models/customer_outlet_purchase.rb', line 200

delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines,
:quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence

#evidenceCustomerOutletPurchase::MatchEvidence

The reasoning behind this match — similarity, coverage, confidence and the
line-item comparison. Extracted so the model stays about persistence and
lifecycle; see MatchEvidence.



188
189
190
# File 'app/models/customer_outlet_purchase.rb', line 188

def evidence
  @evidence ||= CustomerOutletPurchase::MatchEvidence.new(self)
end

#invoice_dateDate?

Returns:

  • (Date, nil)


378
379
380
# File 'app/models/customer_outlet_purchase.rb', line 378

def invoice_date
  invoice&.document_date || invoice&.created_at&.to_date
end

#lag_daysInteger?

Days between the opportunity opening and the outlet invoice.

Returns:

  • (Integer, nil)


371
372
373
374
375
# File 'app/models/customer_outlet_purchase.rb', line 371

def lag_days
  return if opportunity_date.nil? || invoice_date.nil?

  (invoice_date - opportunity_date).to_i
end

#matched_item_idsObject

Alias for Evidence#matched_item_ids

Returns:

  • (Object)

    Evidence#matched_item_ids

See Also:



200
201
# File 'app/models/customer_outlet_purchase.rb', line 200

delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines,
:quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence

#opportunity_dateDate?

Returns nil for a customer-level link with no project attached.

Returns:

  • (Date, nil)

    nil for a customer-level link with no project attached



383
384
385
# File 'app/models/customer_outlet_purchase.rb', line 383

def opportunity_date
  opportunity&.created_at&.to_date
end

#outlet_nameString?

The outlet this purchase came through, for display and for seeding
Opportunity#purchase_outlet.

Returns:

  • (String, nil)


364
365
366
# File 'app/models/customer_outlet_purchase.rb', line 364

def outlet_name
  invoice&.customer&.catalog&.reported_name
end

#product_detached?Boolean

Whether the nightly sweep matched this purchase to a project and then
detached it because the products had nothing in common.

The narrow test matters: a row can be customer-level for reasons that say
nothing about products — a rep adding an order by hand (match_kind
manual), or Opportunity#outlet_purchases' dependent: :nullify orphaning
one when a project is deleted. Offering those a "matched, then detached"
explanation would invent a match that never happened, and the copy renders
match_kind literally — "Ship-to manual matched this project".

different_category is exactly what the matcher stamps before it clears
opportunity_id (see Opportunity::OutletPurchaseMatcher#build_candidate),
and every customer-level row in production carries it.

Returns:

  • (Boolean)


434
435
436
437
# File 'app/models/customer_outlet_purchase.rb', line 434

def product_detached?
  opportunity_id.nil? && from_nightly? && similarity == 'different_category' &&
    customer_id.present? && invoice_date.present?
end

#purchased_linesObject

Alias for Evidence#purchased_lines

Returns:

  • (Object)

    Evidence#purchased_lines

See Also:



200
201
# File 'app/models/customer_outlet_purchase.rb', line 200

delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines,
:quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence

#quoted_lines_by_quoteObject

Alias for Evidence#quoted_lines_by_quote

Returns:

  • (Object)

    Evidence#quoted_lines_by_quote

See Also:



200
201
# File 'app/models/customer_outlet_purchase.rb', line 200

delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines,
:quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence

#rep_engagementCustomerOutletPurchase::RepEngagement

What the rep actually did on the project — the credit question, which
#evidence deliberately does not answer.



196
197
198
# File 'app/models/customer_outlet_purchase.rb', line 196

def rep_engagement
  @rep_engagement ||= CustomerOutletPurchase::RepEngagement.new(self)
end

#restamp_purchase_outletvoid

This method returns an undefined value.

Recompute opportunity.purchase_outlet from whatever is still accepted,
after this row stopped being one of them.

The ad conversion is NOT retracted. Retracting an uploaded conversion is a
different Google API (conversion adjustments) and a decision about a
signal we already delivered, so it is deliberately out of scope here — see
the ledger's open items.



255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/models/customer_outlet_purchase.rb', line 255

def restamp_purchase_outlet
  return if opportunity.nil?
  # Undo only what this row plausibly wrote. `purchase_outlet` predates this
  # feature — 327 opportunities carry a value set by hand — and
  # `stamp_purchase_outlet` never overwrites, so a channel that isn't ours was
  # never ours to clear. (Two rows carrying the same label are
  # indistinguishable, but then the survivor rewrites the same string.)
  return unless opportunity.purchase_outlet == channel_label

  survivor = opportunity.outlet_purchases.accepted.order(:created_at).first
  opportunity.update_column(:purchase_outlet, survivor&.channel_label)
end

#reviewed_opportunityOpportunity?

The project this ruling is about, attached or not — what the reviewer needs
on screen either way.

Returns:



443
444
445
# File 'app/models/customer_outlet_purchase.rb', line 443

def reviewed_opportunity
  opportunity || candidate_opportunity
end

#shared_category_rootsObject

Alias for Evidence#shared_category_roots

Returns:

  • (Object)

    Evidence#shared_category_roots

See Also:



200
201
# File 'app/models/customer_outlet_purchase.rb', line 200

delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines,
:quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence

#stamp_purchase_outletvoid

This method returns an undefined value.

Record the channel on the opportunity so a won project never hides where
the sale landed.



240
241
242
243
244
# File 'app/models/customer_outlet_purchase.rb', line 240

def stamp_purchase_outlet
  return if opportunity.nil? || opportunity.purchase_outlet.present?

  opportunity.update_column(:purchase_outlet, channel_label)
end

#undo_written_off_correctionBoolean

Put an opportunity back the way we found it when the verification that moved
it is withdrawn.

Only ever undoes OUR change: it does nothing unless this row is the one that
corrected the opportunity, and it stands down if anything else now justifies
the won — another accepted closing purchase, or a real order. Written with
update_columns because the state machine has no wonlost route; this
is a correction of an automated change, not a business transition.

Returns:

  • (Boolean)

    whether it restored anything



318
319
320
321
322
323
324
325
326
327
328
# File 'app/models/customer_outlet_purchase.rb', line 318

def undo_written_off_correction
  return false if corrected_opportunity_from.blank? || opportunity.nil?

  update_column(:closes_opportunity, false)
  restored = corrected_opportunity_from
  update_column(:corrected_opportunity_from, nil)
  return false unless opportunity.reload.won? && !opportunity.sales_present?

  opportunity.update_columns(state: restored, won_lost_date: nil)
  true
end

#value_sourceObject

Alias for Evidence#value_source

Returns:

  • (Object)

    Evidence#value_source

See Also:



200
201
# File 'app/models/customer_outlet_purchase.rb', line 200

delegate :confidence_breakdown, :coverage_basis_description, :matched_item_ids, :purchased_lines,
:quoted_lines_by_quote, :shared_category_roots, :value_source, to: :evidence