💳 Secure Payment

Full-Service Web & Software Agency · Klamath Falls and Redding

One feature, scope to launch

Work with Sean

The course has followed one feature a link at a time. This lesson carries it whole, from the written scope to the review before launch, through the scenario that matters most when it fails: a declined card.

On the Sean Dinwiddie’s Webmastery team, a feature is done when every scenario its scope names passes at every owner that holds part of it, and the review against the scope finds nothing missing.

1. The scope names the stories and the stack

Every job starts with a written scope. Its first lines name the job, the fee, the delivery date and the webmaster. Beneath them, the feature is named by its user stories, each story by the scenarios that test it, and the project by the stack it runs on. For the checkout this course has built, that part reads:

Feature: Checkout

Stack: React views over Redux Toolkit slices, with RTK Query endpoints
  generated from the API's OpenAPI document; a Haskell (Servant) API;
  Postgres on Nile, one tenant per shop; cards held and charged by
  the payment provider

Story 1. As a customer, I want to place my order with the card I chose,
  so that the shop has my order and I am charged once.
    Scenario: Placing the order
    Scenario: Placing the order with a declined card
    Scenario: Placing the order while payments are down
Story 2. As a customer, I want to see my past orders,
  so that I can tell what I ordered and when.
    Scenario: Listing my orders
Story 3. As a shop owner, I want my shop's orders kept apart from every
  other shop's, so that no one outside it can place or read them.
    Scenario: Placing an order in someone else's shop

Not in this scope: refunds; the confirmation email; a cart that follows
  the customer from phone to laptop

Done when: each scenario passes at every owner that holds part of it,
  and the review against this scope finds nothing missing

Each line is something the review can check:

  • The stories say who wants what and why, in the words the owner read before work started; the scenarios under them are the acceptance criteria, already in Gherkin.
  • The stack names the owners that will hold each part, and it follows the project: this one holds state and serves many shops, so it runs the whole chain, while a brochure site’s scope names WordPress instead.
  • “Not in this scope” binds as firmly as the rest: a story added later is quoted and approved as a change, as Collaborative sessions to review and refine user stories sets out.

2. One scenario, four owners

The scenario that tests the scope hardest is the one where the card fails, from Creating BDD scenarios for real-world cases:

Feature: Checkout

  Scenario: Placing the order with a declined card
    Given Ada's cart holds "Widget A" at $25
    And her card will be declined
    When she places the order
    Then no order is recorded
    And her cart still holds "Widget A"
    And she is asked for another card

Each Then has an owner, and each owner proves its part in its own runner, under the scenario’s name:

  • No order is recorded: the API refuses with its reason and writes nothing, and the endpoint leaves the order history as it was.
  • Her cart still holds “Widget A”: the slice, which empties the cart only when the API accepts an order.
  • She is asked for another card: the view, which shows what the request’s state says.
One scenario, four owners At the top, the scenario Placing the order with a declined card. Highlighted lines run down from it to four owners, each with the part it proves: the API refuses and writes nothing, the endpoint leaves the history as it was, the slice keeps Widget A in the cart, and the view asks for another card. Placing the orderwith a declined cardAPIrefuses, writes nothingendpointhistory as it wasslicecart keeps Widget Aviewasks for another card
Each owner proves its own part of the one scenario, in its own runner and under the scenario’s name. The violet lines run from the scenario to every owner that holds part of it.

“Her card will be declined” belongs to none of them. It is the payment provider’s answer, so each test sets it at its own boundary: a stand-in for the provider in the API’s test, and a stand-in for the API behind fetch in the client’s. The lecture Redux Toolkit and RTK Query Best Practices sets out which owner holds which kind of state.

The scope’s third scenario under Story 1 is the same refusal with a different answer from the provider. It has the same owners, and only its last Then changes, because a provider that never saw the request has declined nothing:

  Scenario: Placing the order while payments are down
    Given Ada's cart holds "Widget A" at $25
    And the payment provider can't be reached
    When she places the order
    Then no order is recorded
    And her cart still holds "Widget A"
    And she is asked to try again

3. The API refuses with a reason

The card never reaches the API. The customer chose it in the payment provider’s own form, which the provider’s script draws inside the checkout and which sends the number to the provider, never to the shop, and the provider keeps the card on file for her.

The API only asks the provider to charge the card she chose. That request is an effect, so it lives in the shell, behind a record of functions: the running app is given the real provider, and a test is given a stand-in. The names below are the lesson’s own:

newtype ChargeId = ChargeId Text

data ChargeRequest = ChargeRequest
  { idempotencyKey :: UUID     -- the new order's id, so a retried call never charges twice
  , customer       :: UserId   -- the provider charges the card she chose
  , amountCents    :: Int
  }

-- Unavailable: the connection never opened, so the provider never saw the request.
data Charge = Charged ChargeId | Declined | Unavailable

newtype Payments = Payments { charge :: ChargeRequest -> IO Charge }

Decide, charge, then write

The handler from The API: Haskell Servant and Nile now works in three steps:

  1. It reads today’s prices and lets the pure core decide, in one transaction.
  2. It charges the card, outside any transaction.
  3. Only when the charge succeeds does it write the order, in a second transaction, through an insertOrder that now also takes the order’s id and the charge’s id.
The handler's three steps The handler's three steps down a highlighted line: decide, in one transaction; charge the card, in no transaction; write the order, in a second transaction; then 201 Created. Exits branch to the right: an invalid order answers 422, a declined card answers 422, and a provider that can't be reached answers 503. decidein one transactioncharge the cardin no transactionwrite the orderin a second transactionCharged201 Createdinvalid422Declined422Unavailable503
The violet line is an order that is valid and charged: written in a second transaction and answered 201 Created. Each refusal leaves with its reason: 422 for an invalid order or a declined card, 503 when the provider can’t be reached.

Why keep the charge outside? A transaction held open across a call to another company’s server holds its locks for as long as that server takes, and no rollback can take back a charge.

A decline is an answer, not an exception, so it leaves the handler the way an invalid order does: as a refusal with its reason. A provider that can’t be reached is an answer too, and a different one: the request never reached it, so nothing was charged and a later try can succeed. It leaves as a 503 rather than a 422.

server :: Payments -> Pool Connection -> Server OrdersAPI
server payments pool (Authenticated user) tenantId = listOrders :<|> placeOrder
  where
    listOrders = withTenant pool user tenantId selectOrders
    placeOrder new = do
      -- Decide: today's prices, then the pure check.
      decided <- withTenant pool user tenantId $ \conn -> do
        prices <- selectPrices conn new
        pure (validateOrder prices new)
      order <- either refuse pure decided
      -- Charge, outside any transaction.
      orderId <- liftIO nextRandom
      charged <- liftIO (charge payments (ChargeRequest orderId user (orderTotal order)))
      -- Record only what was paid for.
      case charged of
        Declined -> refuse CardDeclined
        Unavailable -> refuse PaymentsUnavailable
        Charged chargeId -> withTenant pool user tenantId $ \conn -> insertOrder conn orderId chargeId order
server _ _ _ _ = throwAll err401

-- Every refusal the API expects, as data.
data Problem = EmptyOrder | UnknownProduct Text | CardDeclined | PaymentsUnavailable

instance ToJSON Problem where
  toJSON problem = object ["reason" .= reasonOf problem]

reasonOf :: Problem -> Text
reasonOf EmptyOrder = "empty-order"
reasonOf (UnknownProduct _) = "unknown-product"
reasonOf CardDeclined = "card-declined"
reasonOf PaymentsUnavailable = "payments-unavailable"

-- 503 when a later try can succeed; 422 for every other refusal. Every case is
-- named, so a new Problem without a status is flagged by -Wincomplete-patterns.
statusOf :: Problem -> ServerError
statusOf EmptyOrder = err422
statusOf (UnknownProduct _) = err422
statusOf CardDeclined = err422
statusOf PaymentsUnavailable = err503

-- The status, with the reason as JSON and a header that says so.
refuse :: Problem -> Handler a
refuse problem =
  throwError (statusOf problem) { errBody = encode problem, errHeaders = [(hContentType, "application/json")] }

Servant’s err422 and err503 carry no headers of their own, so refuse names the content type; without it, the client could still parse the body, but the tests below would fail.

statusOf names every Problem rather than ending in a wildcard, so a refusal added later without a status is flagged by -Wincomplete-patterns, part of -Wall: the compiler’s version of the status table in The view stays minimal.

The order keeps the id of the charge that paid for it, in one column the API lesson’s orders table gains: ALTER TABLE orders ADD COLUMN charge_id text.

Testing the refusals

The tests give the app a stand-in for the provider that declines every card, and one that can’t be reached. In each, they check the two things the API owns: the refusal, as JSON with its reason and its status, and no order recorded. The order history is read before the attempt and must read the same after it:

{-# LANGUAGE QuasiQuotes #-}
import Test.Hspec.Wai.JSON (json)            -- from hspec-wai-json
import Test.Hspec.Wai.Matcher (bodyEquals)

app :: JWTSettings -> Payments -> Pool Connection -> Application
app jwt payments pool =
  serveWithContext (Proxy :: Proxy OrdersAPI) (defaultCookieSettings :. jwt :. EmptyContext) (server payments pool)

-- Stand-ins for the payment provider: each test chooses its answer.
approving, declining, unavailable :: Payments
approving = Payments (\_ -> pure (Charged (ChargeId "test-charge")))
declining = Payments (\_ -> pure Declined)
unavailable = Payments (\_ -> pure Unavailable)

spec :: Spec
spec = do
  jwt <- runIO (defaultJWTSettings <$> generateKey)
  pool <- runIO localNile   -- Nile in Docker, with Ada a member of the shop's tenant
  let signIn user = either (error . show) toStrict <$> makeJWT user jwt Nothing
      asUser token = [(hAuthorization, "Bearer " <> token), (hContentType, "application/json")]
      ordersFor token = request methodGet (ordersPath shop) (asUser token) ""
  describe "Feature: Checkout" $ do
    with (pure (app jwt approving pool)) $
      it "Scenario: Placing the order" $ do
        token <- liftIO (signIn ada)
        request methodPost (ordersPath shop) (asUser token) widgetAOrder `shouldRespondWith` 201
    with (pure (app jwt declining pool)) $
      it "Scenario: Placing the order with a declined card" $ do
        token <- liftIO (signIn ada)
        history <- simpleBody <$> ordersFor token
        -- When she places the order, and the provider declines her card
        request methodPost (ordersPath shop) (asUser token) widgetAOrder
          `shouldRespondWith` [json|{"reason": "card-declined"}|] {matchStatus = 422}
        -- Then no order is recorded
        ordersFor token `shouldRespondWith` 200 {matchBody = bodyEquals history}
    with (pure (app jwt unavailable pool)) $
      it "Scenario: Placing the order while payments are down" $ do
        token <- liftIO (signIn ada)
        history <- simpleBody <$> ordersFor token
        -- When she places the order, and the provider can't be reached
        request methodPost (ordersPath shop) (asUser token) widgetAOrder
          `shouldRespondWith` [json|{"reason": "payments-unavailable"}|] {matchStatus = 503}
        -- Then no order is recorded
        ordersFor token `shouldRespondWith` 200 {matchBody = bodyEquals history}

The pure core is tested on its own, with no database and no provider: validateOrder returns an empty order or an unknown product as a value the test compares, the way the lecture Practical Applications of Functional Programming represents expected failure as plain data.

The provider, like the database, stays in the shell, the line the lecture Functional Programming in Other Languages draws between a functional core and an imperative shell.

4. The endpoint and the slice change nothing

In the client, the same refusal arrives as data. RTK Query dispatches placeOrder’s rejected action with the status and the API’s reason, { status: 422, data: { reason: 'card-declined' } }, and two owners have to do nothing with it. A provider that is down arrives the same way, as 503 with a reason of its own, and they do nothing with it either.

One test proves both, run once for each refusal, beside the placed-order test from the lesson on endpoints at the boundary, in a real store with a stand-in for the API behind fetch:

// features/orders/ordersApi.test.ts, continued, inside describe('Feature: Checkout')
const earlier = {
  id: 'order-0',
  lines: [{ productId: 'widget-b', name: 'Widget B', priceCents: 1200, quantity: 2 }],
  totalCents: 2400,
}

// Story 1's two refusals: the same Thens here, so one test runs once for each
it.each([
  ['Scenario: Placing the order with a declined card', 422, 'card-declined'],
  ['Scenario: Placing the order while payments are down', 503, 'payments-unavailable'],
])('%s', async (_scenario, status, reason) => {
  // And her card will be declined, or payments are down: the API refuses with a status and a reason
  const server = vi.fn(async (request: Request) =>
    request.method === 'POST' ? Response.json({ reason }, { status }) : Response.json([earlier]),
  )
  vi.stubGlobal('fetch', server)
  const store = makeStore()
  // Given Ada's cart holds "Widget A" at $25, and her order history is on screen
  store.dispatch(itemAdded({ productId: 'widget-a', name: 'Widget A', priceCents: 2500 }))
  const history = store.dispatch(ordersApi.endpoints.listOrders.initiate({ tenantId }))
  await history
  // When she places the order
  const placing = await store.dispatch(
    ordersApi.endpoints.placeOrder.initiate({
      tenantId,
      newOrder: { lines: [{ productId: 'widget-a', quantity: 1 }] },
    }),
  )
  // Then no order is recorded: the refusal arrives as data, and the history is neither changed nor refetched
  expect(placing.error).toEqual({ status, data: { reason } })
  expect(ordersApi.endpoints.listOrders.select({ tenantId })(store.getState()).data).toEqual([earlier])
  expect(server.mock.calls.map(([request]) => request.method)).toEqual(['GET', 'POST'])
  // And her cart still holds "Widget A"
  expect(selectLines(store.getState()).map((line) => line.name)).toEqual(['Widget A'])
  history.unsubscribe()
})

The history stays as it was for a reason the test can see. RTK Query invalidates tags on a handled error as well as on success, so placeOrder’s invalidatesTags returns none when there is no order. Make the tags a fixed list, and this test fails on a third request: the history, refetched for nothing.

The cart stays because matchFulfilled never fires. What fires is matchRejected, and the slice has no case for it: it empties the cart on one event only, the API accepting the order. Break that on purpose, as Reviewing and enhancing BDD scenarios as a group asks, by emptying the cart on matchRejected, and the test fails on her cart.

5. The view asks for another card

The view’s part is already written. In The view stays minimal, a pure function, orderNotice, finds the API’s reason in the error’s data, and the view’s test reads the screen the way Ada would:

// features/checkout/PlaceOrder.test.tsx, from The View Stays Minimal
it('Scenario: Placing the order with a declined card', async () => {
  const user = userEvent.setup()
  // And her card will be declined
  renderCheckout(() => Response.json({ reason: 'card-declined' }, { status: 422 }))
  // When she places the order
  await user.click(screen.getByRole('button', { name: 'Place order' }))
  // Then her cart still holds "Widget A", and she is asked for another card
  expect(await screen.findByText(/choose another card/)).toBeInTheDocument()
  expect(screen.getByRole('listitem')).toHaveTextContent('Widget A')
})

Beside it in the same file, a twin for payments down sets the API’s answer to 503 and looks for other words, because its last Then is the one that changes:

// features/checkout/PlaceOrder.test.tsx, continued
it('Scenario: Placing the order while payments are down', async () => {
  const user = userEvent.setup()
  // And the payment provider can't be reached
  renderCheckout(() => Response.json({ reason: 'payments-unavailable' }, { status: 503 }))
  // When she places the order
  await user.click(screen.getByRole('button', { name: 'Place order' }))
  // Then her cart still holds "Widget A", and she is asked to try again
  expect(await screen.findByText(/try again/)).toBeInTheDocument()
  expect(screen.getByRole('listitem')).toHaveTextContent('Widget A')
})

Each refusal now has three tests that carry its scenario’s name and cover its four owners:

  • the API’s, in Hspec;
  • the endpoint’s and the slice’s together, since they share a store;
  • the view’s.

A failure in any of them names the same behavior, so the owner can read a test report in the scope’s own words. The lecture Functional Programming in Other Languages makes the same point across languages: the test names and fixtures can be shared even where the implementations can’t.

6. The review against the scope

Before anything goes live, the results are reviewed against the written scope, line by line, and every launch gets Sean’s review. The review checks results, never how the work was done; on a custom app, its passing scenarios are part of the result. For the checkout it reads:

Review against the scope: Checkout

[x] Story 1   Placing the order: passes at the API, the endpoint, the slice and the view
[x] Story 1   Placing the order with a declined card: passes at all four
[x] Story 1   Placing the order while payments are down: passes at all four
[x] Story 2   Listing my orders: passes at the API and the endpoint
[x] Story 3   Placing an order in someone else's shop: the API answers 403
[x] Stack     as named; the client's endpoints regenerated from the API's
              current openapi.json, and the diff read
[x] Journey   one order placed end to end on a phone and one declined,
              with the payment provider's test cards
[x] Scope     nothing outside it: no refunds, no email, no cart on the server

A line that fails sends the work back; it moves to a later scope only if the owner approves the change. Anything the review finds that the scope never named, however small, is written up as its own story and quoted before it starts.

The job then ends the way every job does: a check that it works, and a plain note the owner keeps, saying what was built, which scenarios prove it and how to run it.

Before asking for the review, the webmaster makes their own pass over the code, and the lecture Functional Programming Maintenance Strategy keeps a checklist for it.

Two end-to-end journeys, one placed and one declined, are enough: the lecture Modern Redux Architecture Patterns keeps end-to-end tests to a small number of critical journeys, because each owner’s own test has already proved its part.

That is the course’s chain in one feature: a story in the written scope, its scenarios in Gherkin, a typed API that refuses with a reason, an endpoint and a slice that change nothing they shouldn’t, a view that says what happened, and a review against the scope before anything goes live.

The next feature starts where this one did, with the people who will use it. Webmasters at any stage of the craft build this way under the Sean Dinwiddie’s Webmastery name, and Joining the team sets out the terms.

Copyright Sean Paul Payne Dinwiddie
All Rights Reserved