Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/controllers/admin/fraud_payouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def reject
object_changes: { aasm_state: %w[pending_approval rejected] }.to_json
)

redirect_to admin_fraud_payout_path(@run), notice: "Payout run rejected. Orders have been released for the next run."
redirect_to admin_fraud_payout_path(@run), notice: "Payout run rejected. Its payouts have been released for the next run."
else
redirect_to admin_fraud_payout_path(@run), alert: "Payout run cannot be rejected in its current state."
end
Expand All @@ -55,7 +55,7 @@ def reject
def trigger
authorize FraudPayoutRun

::Fraud::CalculatePayoutsJob.perform_later(manual: true, triggered_by: current_user)
::Fraud::CalculatePayoutsJob.perform_later(triggered_by: current_user)

redirect_to admin_fraud_payouts_path, notice: "Manual payout calculation has been queued."
end
Expand Down
50 changes: 10 additions & 40 deletions app/jobs/fraud/calculate_payouts_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,25 @@
class Fraud::CalculatePayoutsJob < ApplicationJob
queue_as :literally_whenever

def perform(manual: false, triggered_by: nil)
orders = eligible_orders(manual)
def perform(triggered_by: nil)
review_payouts = FraudReviewPayout.payable.to_a
return if orders.empty? && review_payouts.empty?

now = Time.current
return if review_payouts.empty?

run = FraudPayoutRun.new(
period_start: manual ? last_run_end : nil,
period_end: now,
period_start: last_run_end,
period_end: Time.current,
total_orders: 0,
total_amount: 0
)

grouped = FraudPayoutRun.orders_by_reviewer(orders)

leaderboard = grouped.map { |user_id, user_orders| { user: user_id, total: user_orders.size } }
bracket_results = BracketCalculator.new(leaderboard, 1000).calculate
payouts_by_user = bracket_results[:results].index_by { |r| r[:user] }

FraudPayoutRun.transaction do
run.save!

grouped.each do |user_id, user_orders|
order_count = user_orders.size
amount = payouts_by_user[user_id]&.fetch(:payout, 0).to_i

line = run.lines.create!(
user_id: user_id,
order_count: order_count,
amount: amount
)

ShopOrder.where(id: user_orders.map(&:id)).update_all(fraud_payout_line_id: line.id)
end

review_total = pay_out_reviews(run, review_payouts)
total = pay_out_reviews(run, review_payouts)

run.update!(
total_orders: orders.size + review_payouts.sum(&:item_count),
total_amount: bracket_results[:total_distributed].to_i + review_total,
total_orders: review_payouts.sum(&:item_count),
total_amount: total,
approved_by_user: triggered_by
)

Expand All @@ -54,9 +32,8 @@ def perform(manual: false, triggered_by: nil)

private

# Per-person review payouts get their own line per reviewer: their amount is
# already fixed by the formula, so they do not go through the bracket split
# that shares out the per-order pot.
# One line per reviewer: their amount is already fixed by the per-person
# formula, so nothing is shared out between them.
def pay_out_reviews(run, review_payouts)
review_payouts.group_by(&:reviewer_id).sum do |reviewer_id, payouts|
amount = payouts.sum(&:credited_amount)
Expand All @@ -72,14 +49,7 @@ def pay_out_reviews(run, review_payouts)
end
end

def eligible_orders(manual)
scope = FraudPayoutRun.payout_eligible_orders

scope = scope.where("shop_orders.created_at >= ?", last_run_end) if manual && last_run_end
scope.to_a
end

def last_run_end
@last_run_end ||= FraudPayoutRun.order(period_end: :desc).pick(:period_end)
FraudPayoutRun.order(period_end: :desc).pick(:period_end)
end
end
6 changes: 1 addition & 5 deletions app/models/fraud_payout_line.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,11 @@ class FraudPayoutLine < ApplicationRecord
belongs_to :fraud_payout_run
belongs_to :user

has_many :shop_orders, dependent: :nullify
has_many :fraud_review_payouts, dependent: :nullify

# Per-person review payouts and the per-order bracket payouts land in
# separate lines, so the ledger entry says which one the reviewer is reading.
def payout_reason
people = fraud_review_payouts.size
return "Fraud squad payout for #{people} #{'person'.pluralize(people)} fully reviewed" if people.positive?

"Fraud squad payout for #{order_count} #{'order'.pluralize(order_count)} reviewed"
"Fraud squad payout for #{people} #{'person'.pluralize(people)} fully reviewed"
end
end
50 changes: 2 additions & 48 deletions app/models/fraud_payout_run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,51 +23,6 @@ class FraudPayoutRun < ApplicationRecord
has_many :lines, class_name: "FraudPayoutLine", dependent: :destroy
belongs_to :approved_by_user, class_name: "User", optional: true

REVIEW_STATES = %w[awaiting_periodical_fulfillment rejected on_hold].freeze

def self.payout_eligible_orders
ShopOrder
.where(aasm_state: REVIEW_STATES)
.where(fraud_payout_line_id: nil)
.where(fraud_review_payout_id: nil)
end

# Base scope for PaperTrail versions that could represent a fraud review.
def self.reviewer_versions
::PaperTrail::Version
.where(item_type: "ShopOrder")
.where.not(whodunnit: nil)
.where("object_changes ? 'aasm_state'")
end

# Returns the reviewer user ID if the version represents a fraud-review
# state transition, nil otherwise.
def self.reviewer_from_version(version)
changes = version.object_changes
return nil if changes.is_a?(String) && changes.start_with?("---")
changes = JSON.parse(changes) if changes.is_a?(String)
state_change = changes["aasm_state"]
return nil unless state_change.is_a?(Array) && state_change[1].in?(REVIEW_STATES)
version.whodunnit.to_i
end

def self.orders_by_reviewer(orders)
orders = orders.to_a
reviewer_by_order_id = {}

reviewer_versions
.where(item_id: orders.map(&:id))
.order(:created_at, :id)
.each do |version|
reviewer_id = reviewer_from_version(version)
reviewer_by_order_id[version.item_id.to_i] ||= reviewer_id if reviewer_id
end

orders
.group_by { |order| reviewer_by_order_id[order.id] }
.reject { |reviewer_id, _| reviewer_id.nil? }
end

aasm timestamps: true do
state :pending_approval, initial: true
state :approved
Expand All @@ -80,7 +35,7 @@ def self.orders_by_reviewer(orders)

event :reject do
transitions from: :pending_approval, to: :rejected
after { release_orders! }
after { release_payouts! }
end
end

Expand Down Expand Up @@ -110,8 +65,7 @@ def distribute_payouts!
end
end

def release_orders!
ShopOrder.where(fraud_payout_line: lines).update_all(fraud_payout_line_id: nil)
def release_payouts!
FraudReviewPayout.where(fraud_payout_line: lines).update_all(fraud_payout_line_id: nil)
end
end
8 changes: 4 additions & 4 deletions app/views/admin/fraud_payouts/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
<%= button_to "Trigger Manual Run", trigger_admin_fraud_payouts_path,
method: :post,
class: "aorder__btn aorder__btn--secondary",
data: { confirm: "Calculate payouts for all unpaid reviewed orders since the last run. Continue?" } %>
data: { confirm: "Pay out every completed review that has not been paid yet. Continue?" } %>
<% end %>
</div>

<div class="aorder__card" style="margin-bottom: var(--space-l);">
<div class="aorder__card-header">
<h2 class="aorder__card-title">Your Estimated Payout</h2>
<span class="aorder__card-subtitle">Issued automatically on the last day of each month</span>
<span class="aorder__card-subtitle">Issued when a payout run is triggered</span>
</div>
<div class="aorder__card-body">
<div style="font-size: var(--font-size-xxl); font-family: var(--font-family-subtitle); font-weight:700; color: var(--color-space-text);">
Expand Down Expand Up @@ -101,7 +101,7 @@
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Run</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Status</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Period</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Orders</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Items</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Total Tickets</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Approved By</th>
<th style="padding: var(--space-s) var(--space-m);"></th>
Expand Down Expand Up @@ -131,7 +131,7 @@
</table>
<% else %>
<p style="padding: var(--space-m); color: var(--color-space-text-muted); font-family: var(--font-family-text); font-size: var(--font-size-s); text-align: center; margin: 0;">
No payout runs yet. Trigger a manual run or wait for the scheduled job.
No payout runs yet. Trigger a run to pay out completed reviews.
</p>
<% end %>
</div>
Expand Down
6 changes: 3 additions & 3 deletions app/views/admin/fraud_payouts/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
</div>
<div class="aorder__card-body">
<div class="aorder__row">
<span class="aorder__row-label">Orders reviewed</span>
<span class="aorder__row-label">Items reviewed</span>
<span class="aorder__row-value"><strong><%= @run.total_orders %></strong></span>
</div>
<div class="aorder__row">
Expand Down Expand Up @@ -65,7 +65,7 @@
<%= button_to "Reject", reject_admin_fraud_payout_path(@run),
method: :post,
class: "aorder__btn aorder__btn--reject",
data: { confirm: "Reject this payout run and release orders for the next run?" } %>
data: { confirm: "Reject this payout run and release its payouts for the next run?" } %>
</div>
</div>
</div>
Expand All @@ -83,7 +83,7 @@
<thead>
<tr style="border-bottom: 1px solid var(--color-space-border); background: rgba(255,255,255,0.02);">
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Reviewer</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Orders Reviewed</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Items Reviewed</th>
<th style="padding: var(--space-s) var(--space-m); text-align:left; color: var(--color-space-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;">Tickets Earned</th>
</tr>
</thead>
Expand Down
6 changes: 0 additions & 6 deletions config/recurring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,6 @@ production:
concurrency: 1
description: "Calculate weekly fulfillment payouts (3 tickets per fulfilled order)"

fraud_calculate_payouts:
class: Fraud::CalculatePayoutsJob
schedule: "0 23 L * * America/New_York"
concurrency: 1
description: "Calculate monthly fraud squad payouts on the last day of the month at 11pm EST (bracket-based, max 1000 tickets)"

support_vibecheck_est:
class: SupportVibecheckJob
schedule: every day at 12pm America/New_York
Expand Down
2 changes: 1 addition & 1 deletion test/controllers/admin/fraud_payouts_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class Admin::FraudPayoutsControllerTest < ActionDispatch::IntegrationTest
sign_in @admin

assert_enqueued_with(job: ::Fraud::CalculatePayoutsJob,
args: [ { manual: true, triggered_by: @admin } ]) do
args: [ { triggered_by: @admin } ]) do
post trigger_admin_fraud_payouts_path
end

Expand Down
Loading
Loading