Lesson 1’s scavenger hunt had three finds. The plaintext passwords got their answer in lesson 8. Today is the second one: the original’s orders table has a creditcard column, and checkout writes the full card number into it, in the clear, where it sits forever. This lesson builds the checkout flow — forms, validation, confirmation — and shows exactly where the card number goes in our version: nowhere. Checkpoint: lesson-09.

The flow

Cart → checkout form → confirmation page. The order itself gets written in lesson 10; today ends at “here’s what you’re about to buy.” Three routes:

GET  /checkout          the form, billing prefilled from your account
POST /checkout          validate, stash a draft in the session, redirect
GET  /checkout/confirm  the draft + the cart, ready to place

The form handler opens with two gates, each doing one job:

pub async fn form(
    AuthUser(username): AuthUser,
    State(pool): State<SqlitePool>,
    session: Session,
) -> AppResult<Response> {
    if cart::load(&session).await?.is_empty() {
        return Ok(Redirect::to("/cart").into_response());
    }
    let bill = db::account::address(&pool, &username).await?.ok_or(AppError::NotFound)?;
    Ok(Html(CheckoutTemplate { bill, error: None }.render()?).into_response())
}

AuthUser is lesson 8’s extractor doing its job silently — an anonymous visitor never reaches the function body. The empty-cart check bounces to /cart, because checking out nothing is a navigation mistake, not an error page. And billing prefills from the account via a new query_as! mapping the account columns onto the Address struct — the original prefills from Account the same way.

A draft is not an order

The POST validates and produces an OrderDraft, which lives in the session until the buyer confirms:

pub struct OrderDraft {
    pub ship: Address,
    pub bill: Address,
    pub card_type: CardType,
}

Address knows its own completeness rule (is_complete: all seven fields non-blank), so the handler asks the value instead of re-implementing the check. CardType is a three-variant enum with a TryFrom — the boundary move from lesson 4, applied to a <select>: the three brands the form offers are the three values that exist, and anything else fails at parse.

Now read the struct again for what’s missing. The form has a card number field — the original’s flow does, and the page would feel wrong without it. The CheckoutForm deserializes it. And that is the last place it exists:

// form.card_number's scope ends here. It was read to prove the flow
// works, it validated as present, and it is now gone: OrderDraft has no
// field for it, the schema has no column for it.
session.insert(DRAFT_KEY, OrderDraft { ship, bill, card_type }).await?;

This is the lesson 3 schema decision completing its arc. We didn’t add a rule that says don’t store the card number — we removed every place one could go. A future bug can’t write it to the orders table, because the column isn’t there; can’t stash it in the draft, because the field isn’t there. Absence is the strongest guarantee a type can make. (The brand survives — cardtype is not a secret, and the original stores it too.)

What do real systems do instead? The form posts the number directly to a payment processor — Stripe, Braintree — which returns an opaque token, and that is what your server sees and stores. The number never touches your process at all, and your PCI compliance scope shrinks to nearly nothing. Our fake store fakes that boundary honestly: accept, validate presence, drop.

Confirm before commit

The confirmation page reads the draft and the cart back from the session and lays them side by side: ship-to, bill-to, brand, line items, total. Two small rules in its handler are worth noticing. No draft in the session means you skipped a step — redirect to /checkout, not an error. And the cart gets re-checked for emptiness, because sessions outlive button clicks: a buyer can open the confirm page, go remove everything from their cart in another tab, and come back. State that lives across requests has to be re-checked when it matters, and lesson 10’s transaction will check one more time.

Build it

File Action What goes in it
src/domain/order.rs write Address + is_complete, the CardType enum with TryFrom and as_str, OrderDraft; two tests (completeness, card-type parsing)
src/domain/mod.rs modify pub mod order;
src/db/account.rs modify The address() query mapping account columns onto Address
src/web/checkout.rs write The form handler shown above; submit (build both addresses, validate, drop the card number, stash the draft, redirect); confirm (draft-or-redirect, cart re-check, render)
src/web/cart.rs modify load becomes pub(crate) — checkout needs it
src/web/mod.rs modify mod checkout; and the two routes
templates/checkout.html write + copy Two address blocks (prefilled), the payment block with the three-brand select; the input boilerplate is repetitive — copy freely
templates/confirm.html write Both addresses, the brand, the line-item table, the total
templates/cart.html modify The placeholder becomes a real “Proceed to Checkout” link
.sqlx/ generated cargo sqlx prepare

Done when: anonymous /checkout redirects to /signin; signed-in with an empty cart redirects to /cart; the form shows your account’s address prefilled; submitting with a blank card number re-renders with the error; a full submit lands on a confirmation showing both addresses and the total; the card number you typed appears nowhere in that page’s HTML (view source and search for it — really do this); and cargo test shows 22 passing.

Answer key: lesson-08...lesson-09.

Challenge: Luhn, for honesty’s sake

“Card number is non-empty” accepts potato. Implement the Luhn checksum — the digit-doubling algorithm every real card number passes — and validate the field with it before dropping the number as usual. It’s ~15 lines and a satisfying unit test (4111 1111 1111 1111 passes, off-by-one fails). Worth doing precisely because we never charge: the exercise is validating data you refuse to keep.

Next lesson: the Place Order button gets wired. One transaction writes the order, its line items, and the inventory decrement — all or nothing.