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.