The payment step brackets a checkout with two events. Opening says the visitor has committed and a domain module should hold whatever the run owns; closing says the step will not charge after all and it can let go. Neither can be answered with "no".
So there is no way for the module that owns the subject to say that this run is not allowed to pay at all. That is a different question from what the amount is, and only the domain can answer it: a booking whose rules have been broken since the visitor filled in the form can still be priced perfectly well. Today the step prices it, sends the payer to the gateway, and the domain gets its first chance to object after the money has moved.
A run that owes nothing is worse off still. It never sees the landing page: the step signals the no-payment outcome and a flow carries it onward, so there is no surface on which a refusal could even be shown.
Proposed resolution
A third event, CheckoutEvents::CHECKING, asking whether this run may pay. A subscriber puts one or more reasons on the event and the step renders them; a reason is a sentence, so the step understands nothing about the rule behind it. Refusing does not stop propagation, so a run breaking two rules is told both at once.
Fired twice. As the step draws the page, so the reasons appear above what is due and the payment control is rendered inert rather than inviting a click that fails. And on the click immediately after the opening event, so the answer is given about a subject something is already holding still, and before the resolver is asked for anything, so a refused run is never priced and no payment is created for it. The step then closes the checkout, which releases the hold, and renders the page the click came from with the reasons on it.
A subscriber therefore holds nothing while answering, because the first firing is a page render and freezing a subject because somebody opened a page takes it away from a visitor who has not asked to pay.
A refused run that owes nothing stops on the landing page instead of being signalled onward, since that is the only page it would ever have shown.
Remaining tasks
The consumer half is yoyaku #3614531: Nothing checks the constraint policies before payment, so a violating order can be charged and only refused afterwards, which subscribes and answers from its constraint policies. This issue is the generic mechanism only and blocks that one.
Issue fork orchestra-3614892
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #5
mably commented