Class: Returns::ReminderLadder

Inherits:
Object
  • Object
show all
Defined in:
app/services/returns/reminder_ladder.rb

Overview

Shared query + send mechanics for the advance-replacement reminder ladder:
RMA_REMINDER_1 (10 days) → RMA_REMINDER_2 (20 days) → RMA_CHARGE (30 days).

Each stage used to match invoices.document_date = N.days.ago.to_date
exactly, so a single missed daily run dropped that day's cohort permanently.
The match is now "at least N days old AND not yet notified", with the mark
kept per-RMA on rmas.<stage>_sent_at — the same shape as
quotes.expiration_notice_sent.

Stages are ordered AND day-separated: stage N requires stage N-1 to have been
sent on an EARLIER day. RmaReminderWorker runs all three back to back, so a
non-null check alone would let an invoice that first becomes eligible while
already 20+ days old collect two or three notices in a single pass.

Defined Under Namespace

Classes: Result

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(days:, sent_column:, system_code:, requires: nil) ⇒ ReminderLadder

Returns a new instance of ReminderLadder.

Parameters:

  • days (Integer)

    minimum invoice age, in days

  • sent_column (Symbol)

    rmas column marking this stage as done

  • requires (Symbol, nil) (defaults to: nil)

    preceding stage's column, which must be set
    on an earlier day

  • system_code (String)

    EmailTemplate system_code to send



36
37
38
39
40
41
# File 'app/services/returns/reminder_ladder.rb', line 36

def initialize(days:, sent_column:, system_code:, requires: nil)
  @days = days
  @sent_column = sent_column
  @requires = requires
  @system_code = system_code
end

Instance Attribute Details

#daysObject (readonly)

Returns the value of attribute days.



43
44
45
# File 'app/services/returns/reminder_ladder.rb', line 43

def days
  @days
end

#requiresObject (readonly)

Returns the value of attribute requires.



43
44
45
# File 'app/services/returns/reminder_ladder.rb', line 43

def requires
  @requires
end

#sent_columnObject (readonly)

Returns the value of attribute sent_column.



43
44
45
# File 'app/services/returns/reminder_ladder.rb', line 43

def sent_column
  @sent_column
end

#system_codeObject (readonly)

Returns the value of attribute system_code.



43
44
45
# File 'app/services/returns/reminder_ladder.rb', line 43

def system_code
  @system_code
end

Instance Method Details

#deliver_all(logger: Rails.logger) {|invoice, rma| ... } ⇒ Result

Sends this stage for every pending invoice, claiming each RMA first so
overlapping runs can't double-send.

Parameters:

  • logger (Logger) (defaults to: Rails.logger)

    where per-RMA failures are recorded

Yield Parameters:

  • invoice (Invoice)

    delivered invoice, for stage-specific
    follow-up work (the 20-day stage opens an RMAOUT activity)

  • rma (Rma)

Returns:



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'app/services/returns/reminder_ladder.rb', line 76

def deliver_all(logger: Rails.logger)
  delivered = []
  skipped = []
  failed = []

  pending_invoices.each do |invoice|
    rma = invoice.order.rma
    if rma.nil? || rma.skip_reminders
      skipped << rma&.rma_number
      next
    end

    # Atomic claim: whoever flips NULL → now() owns this stage for this RMA.
    # A concurrent run updates 0 rows and moves on rather than sending twice.
    unless claim(rma)
      skipped << rma.rma_number
      next
    end

    begin
      deliver(invoice, rma)
      yield(invoice, rma) if block_given?
      delivered << rma.rma_number
    rescue StandardError => e
      # Release the claim so a later run retries — a stage that failed to
      # send must not stay marked as done.
      release(rma)
      failed << rma.rma_number
      # One bad RMA must not strand the rest of the cohort; that's the whole
      # point of this rework.
      ErrorReporting.error(e, { rma_id: rma.id, stage: system_code })
      logger.error "[#{system_code}] RMA #{rma.rma_number} failed: #{e.message}"
    end
  end

  Result.new(delivered:, skipped: skipped.compact, failed:)
end

#pending_invoicesActiveRecord::Relation<Invoice>

Unpaid advance-replacement invoices whose RMA is still awaiting a return,
old enough for this stage, and not yet notified at this stage.

Returns:

  • (ActiveRecord::Relation<Invoice>)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'app/services/returns/reminder_ladder.rb', line 49

def pending_invoices
  scope = Invoice
          .joins(order: [:rma, { deliveries: [:payments] }])
          .where(invoices: { state: 'unpaid' })
          .where(payments: { category: Payment::ADV_REPL })
          .where(rmas: { state: 'awaiting_return' })
          .where(Invoice.arel_table[:document_date].lteq(days.days.ago.to_date))
          .where(rmas: { sent_column => nil })
  # Strictly BEFORE today, not merely present: the worker runs all three
  # stages in one pass, so `IS NOT NULL` would let a stage fire moments after
  # its predecessor stamped the same RMA.
  scope = scope.where(Rma.arel_table[requires].lt(Time.current.beginning_of_day)) if requires
  # `joins` only builds the filtering INNER JOIN — it does not populate the
  # association cache, so `invoice.order.rma` in the delivery loop would fire
  # two queries per row. `preload` fetches both in one extra query each,
  # without touching the SQL this relation's callers assert on.
  scope.distinct.preload(order: :rma)
end