blog

Partial refunds with no if statement

by Chris · August 28, 2026

Every payment system has this branch. The processor confirms a refund, and you have to decide: was that the last of it? If what's already come back plus what just came back equals the original amount, the payment is refunded. If not, it stays live and more may follow. One small if. I built a payment domain in Hecks — the DSL where the declaration boots as the runtime — and there was nowhere to put it. That turned out to be the interesting part.

The wall

A Hecks command changes an aggregate through sets. And sets :status, to: takes exactly two kinds of thing: a fixed literal, or a copy of another same-shaped field. Never a computed value, never a conditional. So a single ConfirmRefund command cannot choose between "refunded" and "succeeded" — the language has no syntax for the choice. I tried adding a partially_refunded state to route around it and hit the same wall from the other direction: DeclineRefund, reverting a refund attempt, would then need to pick which of two prior states to go back to, and it can't compute that either.

Here is the lifecycle that survived, disputes elided:

lifecycle :status, default: "pending" do
  transition "Succeed"             => "succeeded", from: "pending"
  transition "Fail"                => "failed",    from: "pending"
  transition "Refund"              => "refunding", from: "succeeded"
  transition "CompleteRefund"      => "refunded",  from: "refunding"
  transition "RecordPartialRefund" => "succeeded", from: "refunding"
  transition "DeclineRefund"       => "succeeded", from: "refunding"
end

No partially_refunded. A payment sitting at refunded_amount: 30 of amount: 100 with status: "succeeded" is an ordinary, valid state. status answers one question — is this payment live and collectible — and refunded_amount answers the other — how much has already come back. Two facts, two fields, and refunding is only ever entered from succeeded, so there is only ever one place to revert to.

Two commands, opposite guards

What a sets can't compute, a given can read. A given sees both the command's arguments and the aggregate's stored state. So the branch became two commands with guards that are exact logical opposites — == in one, < in the other, same two operands:

command "CompleteRefund" do
  role "System"
  goal "Record that the processor gave back the last of what was owed"

  reference_to Payment
  attribute :confirmed_amount,      PositiveMoney
  attribute :refund_transaction_id, ProcessorTransactionId
  attribute :reported_processor,    Processor

  given("only a refund in progress can complete") { status == "refunding" }
  given("the processor matches the one this payment was initiated with") { processor.value == reported_processor.value }
  given("the confirmed amount matches what was actually requested") { confirmed_amount == refunding_amount }
  given("this refund finishes the payoff") { refunded_amount.cents + confirmed_amount.cents == amount.cents }

  sets :refund_transaction_id
  sets :refunded_amount, increment: :confirmed_amount
  sets :status, to: "refunded"

  emits "PaymentRefunded"
end

command "RecordPartialRefund" do
  role "System"
  goal "Record that the processor gave back part of what was owed, more still to come"

  # ...same arguments, same first three givens...
  given("this refund does not yet finish the payoff") { refunded_amount.cents + confirmed_amount.cents < amount.cents }

  sets :refund_transaction_id
  sets :refunded_amount, increment: :confirmed_amount
  sets :status, to: "succeeded"

  emits "PaymentPartiallyRefunded"
end

Neither is ever dispatched directly. Both are reached through policies — and here is the trick: two policies listen to the same event.

policy "OnPaymentRefundConfirmedByProcessor" do
  on "PaymentRefundConfirmedByProcessor"
  trigger Payment::CompleteRefund
end

policy "OnPaymentRefundPartiallyConfirmedByProcessor" do
  on "PaymentRefundConfirmedByProcessor"
  trigger Payment::RecordPartialRefund
end

Every time the processor confirms a refund, both policies fire. Exactly one command's guards hold. The other refuses — and a refused reaction is a silent no-op, not an error thrown back at the webhook. That isn't a special case invented for refunds; it's the same mechanism that already makes webhook replay idempotent in this domain. A second Succeed for an already-confirmed payment is refused the same way.

A policy's own where guard can't do this job, by the way — it sees only the triggering event's payload, never the aggregate's stored state. Only a command's given reads both. So the decision lives on the command, which is where you'd want to find it.

What the grammar refused, and what it caught

The expression language admits + but not -, and no parentheses. So "a refund can't exceed what's left" is written addition-only — requested plus already-refunded may not pass the original:

command "Refund" do
  role "Support"
  goal "Ask the processor to give the customer some or all of their money back"

  reference_to Payment
  attribute :refund_amount, PositiveMoney

  given("only a succeeded payment can be refunded") { status == "succeeded" }
  given("a refund cannot exceed what is left to refund") { refund_amount.cents + refunded_amount.cents <= amount.cents }
  given("the refund currency matches the payment's own") { refund_amount.currency == amount.currency }

  sets :refunding_amount, to: :refund_amount
  sets :status, to: "refunding"

  emits "RefundRequested"
end

That third given is the one I want to be honest about. Every cents comparison in the file compared raw integers across whatever currency each side happened to carry. Nothing checked they were the same currency. Every example used USD, which is exactly how it went unnoticed — this was found by review, not by a test. One line closed it. And later, in CompleteRefund, a separate cents check and currency check that had been added at different times for different gaps collapsed into one: confirmed_amount == refunding_amount is structural equality between two value objects — same type, same fields — so one comparison covers both and can't drift the way two separately maintained checks eventually would.

What's still open

Two things, and the bluebook says so in its own comments rather than pretending otherwise. First, refunding_amount can't be cleared once a refund resolves — sets ... to: nil is treated as absent by the same argument gate every required attribute goes through, and remove: only works on list fields. So it's stale outside status == "refunding", and anything reading it has to check status first. Second, the two guards above must stay exact opposites, and nothing in the language enforces that. Drift one without the other and either both commands fire for the same confirmation and double-count, or neither fires and the confirmation vanishes without a record. A boundary-sweep spec exists specifically to catch that, not just to prove today's values work.

The if didn't go away

It became two named guards. A reader sees them. The model checker sees them — it will tell you if a lifecycle state is unreachable or a transition is dead. The generated CLI prints them under --help as "how it refuses." And the Rust binary compiled from this same file enforces the same two guards, byte-for-byte in agreement with the Ruby runtime, because that agreement is tested on every run.

The full payment.bluebook — with every one of these decisions documented inline, in the file, where the next person will actually find them — lives in the payments package of embryonaut_bluebooks. Disputes and chargebacks are in there too, and they turned out to be a genuinely different shape from refunds. That's the next one.

← All posts