The Itinera bookRust edition

Part 3: Controlling the flow, Chapter 23

Undoing a charge when the shipping fails

What you want to do

When the shop gives up an order's shipping, the customer has already paid: Ned's parcel of 90 kg is charged, then given up, as in chapter 17, and the shop keeps his money. You want the shop to give the charge back whenever the shipping is given up, whatever gave it up.

The code

Giving money back is something the shop does for its policies, so it is a role, as telling a customer is in chapter 19:

Rust, from the program ch23_undoing_a_charge_when_the_shipping_fails
/// What the shop does for its policies: give an order's money back.
trait Refunds {
    fn refund(&self, receipt: &str, amount: i64);
}

The workflow implements it. Here the bank is faked, and prints what it gives back:

Rust, from the program ch23_undoing_a_charge_when_the_shipping_fails
#[role]
impl Refunds for Orders {
    fn refund(&self, receipt: &str, amount: i64) {
        println!("the bank gives {amount} back on receipt {receipt}");
    }
}

heavy_parcels now does more than turn heavy parcels away, so it becomes failed_shipping. Its on step retry still gives up a parcel over the limit, and refunds the charge first. A second hook, marked #[on_step_failure], refunds the charge of a shipping that the executor gave up. It asks for #[cause], a StepFailureCause, which says why the step was given up. Both hooks ask for the receipt and the amount from the data bag, the role, and a contributor, and both call refund, a method of the policy's own:

Rust, from the program ch23_undoing_a_charge_when_the_shipping_fails
/// Deals with a shipping that fails for good. It gives up a parcel over the shop's limit rather
/// than try it again, since the limit is the same on every attempt, whichever carrier comes. And
/// whatever gave the shipping up, it refunds the order's charge.
#[derive(Default)]
struct FailedShipping;

#[step_policy(name = "failed_shipping")]
impl FailedShipping {
    #[on_step_retry]
    fn before_retrying(
        &self,
        #[reason] reason: Option<Reason>,
        #[from_workflow] receipt: String,
        #[from_workflow] amount: i64,
        #[role] refunds: &dyn Refunds,
        #[contributor] contributor: Contributor<'_>,
    ) -> Result<Option<FailWorkflow>, Error> {
        if reason.as_ref().map(Reason::code) == Some("over-parcel-limit") {
            self.refund(refunds, contributor, &receipt, amount, "over-parcel-limit");
            return Self::fail_workflow("no-carrier-can-take-it");
        }
        Self::carry_on()
    }

    #[on_step_failure]
    fn given_up(
        &self,
        #[cause] cause: StepFailureCause,
        #[from_workflow] receipt: String,
        #[from_workflow] amount: i64,
        #[role] refunds: &dyn Refunds,
        #[contributor] contributor: Contributor<'_>,
    ) -> Result<Option<FailWorkflow>, Error> {
        self.refund(refunds, contributor, &receipt, amount, &cause.to_string());
        Self::carry_on()
    }

    /// Gives the order's money back, and records why.
    fn refund(
        &self,
        refunds: &dyn Refunds,
        mut contributor: Contributor<'_>,
        receipt: &str,
        amount: i64,
        why: &str,
    ) {
        refunds.refund(receipt, amount);
        contributor.contribute("refund", format!("{amount} back on {receipt}: {why}"));
    }
}

The workflow attaches it to the shipping, in place of heavy_parcels:

Rust, from the program ch23_undoing_a_charge_when_the_shipping_fails
#[workflow(name = "orders", reporters = [Log, Desk], policies = [Notify])]
#[with_step(Lookup, name = "lookup", policies = [AlreadyCompleted])]
#[with_step(Price, name = "price")]
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit, Outage])]
#[with_step(Ship, name = "ship", retry_budget = 1, policies = [FailedShipping])]
struct Orders {
    channel: &'static str,
}

The other steps, policies and reporters it names are as earlier chapters show them: the lookup, already_completed and the log in chapter 22, the pricing, the charge and the audit in chapter 20, RenewCard in chapter 14, the shipping and outage in chapter 17, notify in chapter 19 and the desk in chapter 11.

main runs three orders: Gil's, whose parcel the backup carrier takes, Ned's, and the order of a new customer, Oli. The warehouse weighed Oli's parcel as 0 kg by mistake, and the faked carriers' booking systems fail on a parcel that weighs nothing. main also prints the refund the journey recorded:

Rust, from the program ch23_undoing_a_charge_when_the_shipping_fails
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The order, the customer, the price, the discount, the parcel's weight in kg and its size in
    // cm, the card, and whether the order is a gift card.
    let orders = [
        ("A-7", "Gil", 45, None, 60, [120, 80, 60], "gil-2027", false),
        (
            "A-12",
            "Ned",
            50,
            None,
            90,
            [120, 80, 80],
            "ned-2028",
            false,
        ),
        ("A-14", "Oli", 20, None, 0, [30, 20, 20], "oli-2029", false),
    ];
    for (order, customer, price, discount, weight, size, card, gift_card) in orders {
        println!("{customer}'s order:");
        let instance = Orders::builder()
            .channel("email")
            .initial_data(NewOrder {
                order: order.to_owned(),
                customer: customer.to_owned(),
                price,
                discount,
                parcel: Parcel { weight, size },
                card: card.to_owned(),
                gift_card,
            })
            .create()?;
        let result = local_executor_for!(Orders).run(instance)?;
        let data: Order = result.output()?;
        let receipt = data.receipt.as_deref().unwrap_or("none");
        let tracking = data.tracking.as_deref().unwrap_or("none");
        println!(
            "the journey {}, with receipt {receipt} and tracking {tracking}",
            result.status.kind(),
        );
        println!("audit: {}", data.audit.as_deref().unwrap_or("none"));
        println!("refund: {}", data.refund.as_deref().unwrap_or("none"));
    }
    Ok(())
}

NewOrder and Parcel are as chapter 21 shows them, and the output now holds the refund:

Rust, from the program ch23_undoing_a_charge_when_the_shipping_fails
/// What the shop reads back once the journey has ended.
#[derive(Output)]
struct Order {
    receipt: Option<String>,
    tracking: Option<String>,
    audit: Option<String>,
    refund: Option<String>,
}

It prints:

What ch23_undoing_a_charge_when_the_shipping_fails prints, recorded in ch23_undoing_a_charge_when_the_shipping_fails.txt
Gil's order:
log orders A-7 #1 journey_started card, customer, gift_card, order, parcel, price
log orders A-7 #2 attempt_started lookup, attempt 1
log orders A-7 #3 service_provided lookup gets order_book from ledger, a ch23_undoing_a_charge_when_the_shipping_fails::Ledger
the order book has no order A-7 yet
log orders A-7 #4 step_succeeded lookup
log orders A-7 #5 contribution_committed completed
log orders A-7 #6 hook_called already_completed: on step success of lookup
log orders A-7 #7 attempt_started price, attempt 1
log orders A-7 #8 optional_input_absent discount
log orders A-7 #9 step_succeeded price
log orders A-7 #10 contribution_committed amount
log orders A-7 #11 attempt_started charge, attempt 1
log orders A-7 #12 service_provided charge gets payments from bank, a ch23_undoing_a_charge_when_the_shipping_fails::Bank
the bank charges 45 for order A-7
log orders A-7 #13 step_succeeded charge
log orders A-7 #14 contribution_committed receipt
log orders A-7 #15 hook_called audit: on step success of charge
log orders A-7 #16 contribution_committed audit
log orders A-7 #17 attempt_started ship, attempt 1
log orders A-7 #18 input_adapter_supplied ship gets weight from parcels
log orders A-7 #19 service_provided ship gets carrier from carriers, a ch23_undoing_a_charge_when_the_shipping_fails::MainCarrier
the main carrier books order A-7
log orders A-7 #20 step_failed ship: too-heavy
log orders A-7 #21 hook_called failed_shipping: on step retry of ship
log orders A-7 #22 step_retrying ship
log orders A-7 #23 attempt_started ship, attempt 2
log orders A-7 #24 input_adapter_supplied ship gets weight from parcels
log orders A-7 #25 service_provided ship gets carrier from carriers, a ch23_undoing_a_charge_when_the_shipping_fails::BackupCarrier
the backup carrier books order A-7
log orders A-7 #26 step_info ship: the parcel leaves with receipt R-A-7
log orders A-7 #27 step_succeeded ship
log orders A-7 #28 contribution_committed tracking
log orders A-7 #29 optional_input_absent code
email to Gil: Order A-7 is on its way, tracking B-A-7.
log orders A-7 #30 hook_called notify: on workflow success
log orders A-7 #31 journey_succeeded
the journey succeeded, with receipt R-A-7 and tracking B-A-7
audit: charge took 45, receipt R-A-7
refund: none
Ned's order:
log orders A-12 #1 journey_started card, customer, gift_card, order, parcel, price
log orders A-12 #2 attempt_started lookup, attempt 1
log orders A-12 #3 service_provided lookup gets order_book from ledger, a ch23_undoing_a_charge_when_the_shipping_fails::Ledger
the order book has no order A-12 yet
log orders A-12 #4 step_succeeded lookup
log orders A-12 #5 contribution_committed completed
log orders A-12 #6 hook_called already_completed: on step success of lookup
log orders A-12 #7 attempt_started price, attempt 1
log orders A-12 #8 optional_input_absent discount
log orders A-12 #9 step_succeeded price
log orders A-12 #10 contribution_committed amount
log orders A-12 #11 attempt_started charge, attempt 1
log orders A-12 #12 service_provided charge gets payments from bank, a ch23_undoing_a_charge_when_the_shipping_fails::Bank
the bank charges 50 for order A-12
log orders A-12 #13 step_succeeded charge
log orders A-12 #14 contribution_committed receipt
log orders A-12 #15 hook_called audit: on step success of charge
log orders A-12 #16 contribution_committed audit
log orders A-12 #17 attempt_started ship, attempt 1
log orders A-12 #18 input_adapter_supplied ship gets weight from parcels
log orders A-12 #19 service_provided ship gets carrier from carriers, a ch23_undoing_a_charge_when_the_shipping_fails::MainCarrier
log orders A-12 #20 step_failed ship: over-parcel-limit
the bank gives 50 back on receipt R-A-12
log orders A-12 #21 hook_called failed_shipping: on step retry of ship
log orders A-12 #22 contribution_committed refund
log orders A-12 #23 step_given_up ship: FailWorkflow from failed_shipping, no-carrier-can-take-it
email to Ned: Sorry, order A-12 could not be completed.
log orders A-12 #24 hook_called notify: on workflow failure
log orders A-12 #25 journey_failed ship
desk: order A-12 failed at ship
the journey failed, with receipt R-A-12 and tracking none
audit: charge took 50, receipt R-A-12
refund: 50 back on R-A-12: over-parcel-limit
Oli's order:
log orders A-14 #1 journey_started card, customer, gift_card, order, parcel, price
log orders A-14 #2 attempt_started lookup, attempt 1
log orders A-14 #3 service_provided lookup gets order_book from ledger, a ch23_undoing_a_charge_when_the_shipping_fails::Ledger
the order book has no order A-14 yet
log orders A-14 #4 step_succeeded lookup
log orders A-14 #5 contribution_committed completed
log orders A-14 #6 hook_called already_completed: on step success of lookup
log orders A-14 #7 attempt_started price, attempt 1
log orders A-14 #8 optional_input_absent discount
log orders A-14 #9 step_succeeded price
log orders A-14 #10 contribution_committed amount
log orders A-14 #11 attempt_started charge, attempt 1
log orders A-14 #12 service_provided charge gets payments from bank, a ch23_undoing_a_charge_when_the_shipping_fails::Bank
the bank charges 20 for order A-14
log orders A-14 #13 step_succeeded charge
log orders A-14 #14 contribution_committed receipt
log orders A-14 #15 hook_called audit: on step success of charge
log orders A-14 #16 contribution_committed audit
log orders A-14 #17 attempt_started ship, attempt 1
log orders A-14 #18 input_adapter_supplied ship gets weight from parcels
log orders A-14 #19 service_provided ship gets carrier from carriers, a ch23_undoing_a_charge_when_the_shipping_fails::MainCarrier
the main carrier's booking system fails on order A-14
log orders A-14 #20 step_abnormal_termination ship: the booking system failed
log orders A-14 #21 hook_called failed_shipping: on step retry of ship
log orders A-14 #22 step_retrying ship
log orders A-14 #23 attempt_started ship, attempt 2
log orders A-14 #24 input_adapter_supplied ship gets weight from parcels
log orders A-14 #25 service_provided ship gets carrier from carriers, a ch23_undoing_a_charge_when_the_shipping_fails::BackupCarrier
the backup carrier's booking system fails on order A-14
log orders A-14 #26 step_abnormal_termination ship: the booking system failed
log orders A-14 #27 step_given_up ship: retries exhausted
the bank gives 20 back on receipt R-A-14
log orders A-14 #28 hook_called failed_shipping: on step failure of ship
log orders A-14 #29 contribution_committed refund
email to Oli: Sorry, order A-14 could not be completed.
log orders A-14 #30 hook_called notify: on workflow failure
log orders A-14 #31 journey_failed ship
desk: order A-14 failed at ship
the journey failed, with receipt R-A-14 and tracking none
audit: charge took 20, receipt R-A-14
refund: 20 back on R-A-14: retries exhausted

How it works

  1. on step failure runs once the executor gives a step up by its own rule. Oli's shipping ended in an error on both attempts, so it was given up with the cause retries exhausted. Then on step failure was called, and asked for that cause. It returns an Option<FailWorkflow>, as on step retry does; carrying on, as here, lets the journey fail as it would have.
  2. A step given up by a hook never reaches on step failure. Ned's shipping was given up by on step retry, with FailWorkflow, so the hook that gave it up has already decided how the journey ends, and no other hook of the shipping is called. That is why before_retrying refunds too. on step retry is called only when a retry is left: for Gil, it carries on, and his order is never refunded.
  3. A policy is a type of its own, and may hold code its hooks share. refund has no hook mark, so the executor never calls it. Both hooks call it: on step retry when it gives the shipping up, and on step failure once the executor has given it up. The giving up and the undoing of the shipping live together in one policy, and the shipping, the charge and the workflow's declaration know nothing about refunds.
  4. A hook's contributions are committed whatever the step's outcome. The shipping failed, but refund is in Ned's and Oli's data, because a hook's contributor commits as soon as the hook returns. Only a failed attempt's own contributions are thrown away, as chapter 6 showed.
  5. The undo sits with the step it undoes. notify's on workflow failure runs for every failed journey, so a refund there would first have to find out whether a charge went through. Lee's journey, for one, fails without any charge. Undoing a journey's work in a standard way isn't part of Itinera yet, so for now a policy like this one does it.

The shop now gives back what it can't deliver. But look at how main runs the orders: it makes a new executor for each one, and waits for each journey to end before it starts the next.

Outcomes

We have shown how one step policy undoes a charge, however the shipping after it is given up.

The usual way With Itinera
Undoing work after a failure is often written in each place that can give up, and one of them is easily forgotten. Whichever gave the shipping up, a hook or the executor, the hook that sees it calls the policy's own refund, so every shipping given up is refunded.
The code that undoes an order's payment often ends up inside the shipping code, which then has to know about payments. The shipping only reports what happened, and the policy attached to it decides what to undo.