logo
Published on

Stairno 4. Building Payments (Test Mode)

Authors
  • avatar
    Name
    seren-wib
    Twitter
Contents

The first half of roadmap step 4: building payments in test mode up to the first release (v0.4.0). The production rehearsal has its own post, part 4a. The big picture is in Stairno 0. The whole roadmap.

In one line

From the afternoon to the night of 18 October, payments were added to the shared API. When a site creates an order, the API talks to the payment provider, and once the payment is done it records an entitlement saying "this person may use this product". Cards are registered once in the account center, and subscriptions renew automatically every day. Everything is in test mode, so no real money moves.

1. What gets built

ProviderCurrencyWhat was built
Toss PaymentsKorean won (KRW)One-time payments, card registration (billing keys), automatic subscription charges, cancellation, webhooks
PayPalUS dollars (USD)One-time payments, refunds, webhooks

The account center screens get card registration, deletion and choosing the default card, order history, cancelling and resuming subscriptions, and downloading purchased desktop programs again. Receipts, failed renewal notices and refund results go out by mail.

Switching to real payments is designed to take nothing but swapping the test keys for real keys and adding the business details. That switch is step 9, after business registration.

2. Rules set first

In payments, a mistake is a money incident. So rules came before code. They are all written in the repo's working rules, and the AI agent follows them too.

  1. Providers live only behind an adapter. The order code knows only common operations like "approve payment" and "cancel", not whether it is Toss or PayPal. Tests run on a fake adapter instead of a real provider, creating situations like "decline the next payment" or "pretend the provider is down" at will
  2. Amounts are an integer plus a currency. 1,000 won is 1000 KRW and 1 dollar is 100 USD (cents). No decimal arithmetic, and won and dollars are never added together
  3. There is no code that takes card numbers. The provider's screen takes them. We keep only the billing key (encrypted, at that), the card issuer and the last four digits
  4. The server re-checks the amount. If the amount sent by the browser differs from the order amount, it is refused and no approval request goes to the provider
  5. Webhooks are idempotent. The same notice arriving twice is processed once
  6. Orders follow the state machine. Only allowed moves happen
  7. A switch that turns off payments only. In an outage, sign-in stays up and only payments stop
  8. No personal data in logs. Order numbers and provider numbers instead of emails, tokens or card data

3. Build order

One feature per PR. Each went in in this order, test first.

OrderBuiltIn plain words
1Payment DB designTables for products, prices, orders, payments, cards, subscriptions, entitlements, refund requests, webhook records
2Adapter, fake adapter, state machineThe frame for talking to providers, and the rules for how an order moves
3Product registration command, payment switchThe operator registers products with a command. No admin screen
4Site identity, order APIA site says "I am a registered Stairno site" and creates orders
5Toss payment approvalCheck and finalize a payment that comes back from the payment window
6Toss webhooksReceive payment status notices from Toss
7PayPal paymentsDollar payments. Go to the approval screen, come back and finalize (capture)
8Card registration and deletionToss card registration window → billing key issued → stored encrypted
9Billing keys deleted with the accountDeleting an account also deletes the card keys stored at the provider
10Payment methods screenRegister cards, pick the default, delete them in the account center
11Billing key chargesCharge a saved card
12Starting a subscriptionBuying a subscription product charges the default card right away and opens the subscription
13Subscriptions follow the default cardMissing at first. Changing the default card left subscriptions tied to the old one, so it was fixed separately
14Daily automatic renewalOnce a day, cron charges subscriptions whose period has ended
15Cancelling and resuming subscriptionsAccount center only. A site cannot cancel on the user's behalf
16Entitlement lookupA site asks "may this person use this product?"
17Download linksBuyers get a download address valid for 10 minutes
18Refund requests and the operator refund commandA site requests a refund, and the operator reviews it and refunds with a command
19Payment mailReceipts, failed renewals, refund results. Once per order per kind
20Order history and re-download screensMy orders and downloadable programs in the account center

4. The path a single payment actually takes

Say a user buys a 1,000 won program on Labs. (Labs does not exist yet, so for now the rehearsal site from part 4a takes its place.)

  1. The site creates an order. The site sends its own identity (a site token) together with the buyer's (a user token). The API reads the product price from the DB and creates the order as pending. The site does not set the price. If it is not paid within 30 minutes, it expires
  2. The Toss payment window opens. The user enters a card and authenticates. No money has left yet
  3. The user comes back to the site. Toss sends them back to the site with a payment number and the amount attached
  4. The site asks the API to "finalize this". The API checks, in order:
    • Is this order really this site's?
    • Is this payment number already processed? (If so, it returns the first result as is)
    • Is the order still pending?
    • Does the amount match the order? If not, the order is set to failed and refused. No approval request goes to Toss
  5. The API asks Toss for approval. This is when money leaves. The amount Toss approved is compared once more
  6. Payment done, order fulfilled, entitlement created. All three are recorded at once
  7. A receipt goes out in the user's language
  8. Later, Toss's webhook arrives. The payment is already processed, so nothing changes. If the user had closed the window at step 3, this webhook would do step 6 instead

5. What came up along the way

No interactive transactions

Normally "write the payment record + change the order state + create the entitlement" is bundled into one transaction. But the way Workers connects to Neon (an HTTP connection) treats each request separately, so a transaction spanning several requests cannot be held open.

So every state change is written as a single SQL statement, with the condition "change it only if the order is in one of these states". The list of allowed states is generated from the state machine table. If two requests try to change the same order at once, only the first succeeds, and the other no longer matches the condition, so nothing happens.

Webhooks: verify before storing

Storing "received" first and verifying later seems natural for webhooks. But then a forged notice could claim a genuine notice's event number first, and when the genuine one arrives, it is discarded as "already received". So the order is verify → store → process. Toss's payment notices have no signature, so on receiving one the API asks Toss directly about that payment and trusts only the state Toss reports.

Download files live in Cloudflare R2. Making a "10-minute download address" in R2 needs one more separate key, and I did not want more secrets. So the download address is the API's own address, and the API itself signs "this file, until this time". When the user comes to that address, the API checks the signature and the time and streams the file.

Telling declines apart from outages

"Card over its limit" is a decline, and "the Toss server does not answer" is an outage. A decline closes the order as failed and tells the user, but an outage must leave the order as it is so that it can be retried. I went through the error codes in the Toss documentation one by one, and the adapter now separates the two by code.

6. A change midway: Git Flow

Up to step 3, a PR that passed CI went straight into main, and main went straight to production. With payments coming in, that felt risky. A half-finished payment feature reaching production weighs very differently from one word changing on the sign-in screen.

So on the evening of 18 October, after more than 20 payment PRs, the work moved to Git Flow.

  • Everyday work collects in develop. Landing in develop does not reach production
  • When a roadmap step ends, a release branch goes to main. That is the one deploy
  • An urgent production bug gets fixed on the production version alone with a hotfix
  • The middle number of the version number is the roadmap step. The first release of step 4 is v0.4.0

Checks were split into layers too: only the changed tests on my computer for each commit → the full CI on pushes to develop → CI required on the way to main → tests once more right before deploying → a rehearsal in production. The last layer is part 4a. The provider's return to our server and the webhooks can only be truly checked in production.

7. v0.4.0, and then v0.4.1

Around 10 p.m., with payment mail, order history and the re-download screen in, the first release v0.4.0 went to production.

Then I looked at the account center screens again. Profile, devices, connected sites, payment methods, subscriptions, orders, downloads and deletion were scattered over separate pages, and it was hard to find what was where. After comparing the account screens of Google, Microsoft, Apple, GitHub and Kakao, I changed it like this.

  • Four groups on one page: account management → payments → my products → security. Clicking the left-hand menu scrolls to that spot, and the menu marks the group currently in view
  • Account deletion at the very bottom, at the end of the security group. A hard place to click by accident
  • Password change was missing. Added. Changing it signs out every other device and sends a "your password was changed" email, so you know right away if it was not you
  • Country/region and time zone. Order dates are shown in the person's time zone
  • Profile photo

Birthday, gender, phone number and address are not collected. Not collecting personal data that no feature uses is a principle of Korea's Personal Information Protection Act (data minimization), and it also means less to leak.

Just after midnight it went out as v0.4.1. That is the end of building step 4. What remains is checking that it really works in production.

Next: Stairno 4a. The production payment rehearsal