Commission allocation for brokerage deals. Rails 8, MySQL, RSpec.
Live: https://splitrail.levelbrook.com
A closed deal produces a gross commission that has to reach several parties in a particular order, in whole cents, exactly once. This app does that and holds itself to three properties that are easy to state and easy to get wrong.
Allocations sum to exactly the gross. Every time, for every combination of percentages. Rounding each share independently is how a penny goes missing:
$10,000.01 split three ways at 33.33% / 33.33% / 33.34%
rounded independently -> 3333.00 + 3333.00 + 3334.00 = $9,999.99
Shares are floored and the leftover cents are handed out by largest fractional remainder, which is deterministic and never invents or destroys money. Ties break on the deal's own participant order, so the same deal always allocates the same way. There is no float anywhere near the money: percentages are carried as basis points and the whole calculation is integer.
A property test runs a thousand random gross amounts against random splits and asserts the total every time, seeded so a failure is reproducible.
Stage order is enforced. Referrals come off the gross before the house takes its share. Computing the house against the gross instead of the post-referral balance overpays the house on every referred deal, which on a $100,000 commission with a 10% referral and a 50% house split is $5,000 wrong on one deal. The waterfall stages are explicit so that ordering is reviewable rather than implied by where somebody put a multiplication.
A deal settles once. Not once per click, once. A deal cannot be settled before it closes, and it cannot move backwards out of settled.
The deployed app has a Race 8 settlements button on every closed deal. It fires eight concurrent settlement attempts and reports what the ledger did:
8 workers raced this deal
settlements written : 1
refused : 7
errors : none
/deals/2 is seeded with a gross of $33,333.33, a referral, a house split, and three
brokers at 33.33/33.33/33.34. It balances to the cent.
Settlement originally just inserted the row and let the unique index on
settlements.deal_id reject the loser. Every sequential test passed. The concurrent one
did not:
ActiveRecord::Deadlocked
InnoDB takes a shared lock on the duplicate key, and when several transactions try to upgrade it to the exclusive lock the insert needs, they wait on each other and the server kills some of them. Callers saw a random deadlock instead of a clean "already settled."
Settlement now takes SELECT ... FOR UPDATE on the deal row first, so competing workers
queue on the aggregate root and each gets a coherent read before deciding. The unique
index stays as the backstop.
Two smaller things the same suite forced:
- The sequential double-settle raises
ActiveRecord::RecordInvalidbecause the model validation runs before the insert, while a genuine race raisesActiveRecord::RecordNotUnique. Both are rescued. Handling only one leaves the other surfacing as an unhandled error. - The concurrency example has to opt out of transactional fixtures. Left on, Rails wraps the example in a transaction the other threads cannot see and pins the connection, so the threads serialise and the example passes without proving anything.
mysql -u"$USER" -e "CREATE DATABASE splitrail_development; CREATE DATABASE splitrail_test;"
bin/rails db:prepare db:seed
bin/rails serverbundle exec rspec
# 27 examples, 0 failuresDB_HOST, DB_USER, DB_PASSWORD, DB_NAME and DB_SOCKET all come from the
environment, so the same config runs against a local socket or a networked server.
app/models/commission_split.rb largest-remainder allocation in whole cents
app/models/commission_waterfall.rb referral -> house -> brokers, every stage closed
app/models/settle_deal.rb settle once, under a row lock, in one transaction
app/models/deal.rb forward-only stage transitions
spec/models/ 27 examples, one of them genuinely concurrent