The Purchase Flow Is the Product: Stop Treating Checkout Like a Button Click
On this page
Most marketplace checkout systems are built on a fantasy: that “buying” is one event where the user clicks a button, money moves, a database row appears, access gets granted, and the whole thing neatly resolves into Done.
That fantasy works if you are selling a T-shirt with Stripe. That is not what I am building with DevsDistro — a marketplace where developers sell private repos through Solana, with on-chain settlement, volatile exchange rates, wallet signing, backend verification, and PDF receipts that still have to make sense after a project gets deleted or a seller changes their profile.
That is not a button click so much as a distributed systems problem wearing a checkout mask. This is a teardown of the purchase flow I built for it, why the naive versions fail, and why I split the process into quote, transaction, verification, persistence, and entitlement instead of pretending those are all the same thing.
Phase 1: The Lie Your Checkout UI Tells
A normal buy button implies certainty. Click it, and the system should know what happened. Crypto checkout does not work like that.
Between the moment a buyer opens the modal and the moment the platform can safely unlock downloads, at least six unstable things are in motion:
- 1.The user session has to still be valid.
- 2.The project still has to be purchasable.
- 3.The seller still has to have a valid wallet and active GitHub packaging pipeline.
- 4.The SOL/USD rate has to still be recent enough to quote.
- 5.The wallet has to sign and broadcast the exact transaction shape the backend expects.
- 6.The backend has to verify that the chain event actually corresponds to this purchase, not just some unrelated transfer.
If you collapse all of that into one /buy endpoint, you do not get simplicity. You get ambiguity: users asking support why they paid but do not have access, engineers asking whether the chain failed or the database failed or the UI lied, duplicate payments when retries are not designed correctly, and the worst possible failure mode of all — money moved, but entitlement did not.
I avoid that by refusing to pretend payment is atomic and model the purchase as a sequence of contracts. A backend-issued quote. A client-constructed transaction. An on-chain finalization event. A backend confirmation pass. A durable purchase record. A downstream access grant.
That separation is the whole architecture.
Before funds transfer — safe to abort and restart
Quote issued
Backend validates 11 prerequisites, computes exact lamports, stores Redis intent with 600s TTL.
Tx assembled
Frontend uses backend-supplied lamports — never recomputes. Assembler, not pricing engine.
On-chain ✓
Wallet signs and broadcasts. Solana finalizes the transfer to seller and treasury.
After funds transfer — Retry Confirmation only
Backend confirms
verifySolanaTransaction runs 9 explicit checks against the chain. Idempotent on tx_signature.
Record saved
Purchase document written to MongoDB with seller, project, and wallet snapshots.
Access granted
Ledger visible, presigned download URL issued, PDF receipt generatable.
The boundary between steps 3 and 4 is where funds leave the buyer's wallet. Everything before it can be abandoned and restarted. Everything after it requires careful reconciliation — never a second payment.
Phase 2: The Backend Does Not Sell a Project. It Sells a Temporary Truth.
The purchase flow begins when the user clicks Execute Purchase, but the first meaningful step is not payment. It is quote issuance. That happens in POST /api/purchases/initiate.
The backend refuses to initiate unless the rest of the system can plausibly fulfill the sale. Before it ever returns a purchase reference, it checks that the user is authenticated, the project exists and is active, GitHub access is not revoked, the packaged repo zip is ready, the listing is not free, the buyer is not the seller, the buyer has not already purchased it, the seller has a connected wallet, and the seller's GitHub App installation is not suspended.
Too many teams treat payment as the first validation point, then discover later that they took money for something undeliverable. I validate delivery prerequisites before quoting a price.
Then it fetches the SOL/USD rate through getSolanaUsdRate() — not naively. It uses a Redis cache for the fast path, CoinGecko as the primary oracle, Binance as a fallback, and a stale-cache escape hatch up to 30 minutes old if live providers are down. If pricing depends on a third-party oracle and your app has no fallback strategy, your payment system is not a payment system. It is an availability lottery.
Once the rate is known, the backend computes the full split — total SOL price, seller amount, platform amount, exact lamports for seller and treasury — then generates a 64-character random purchase_reference and stores a Redis purchase intent with a 600-second TTL.
This is the part worth slowing down for. The backend is not just returning a quote. It is creating a temporary, server-authored truth about what this purchase is supposed to be: who the buyer is, who the seller is, which project is being bought, what USD price was agreed, what SOL conversion was used, exactly how many lamports the seller and treasury must receive, and which wallet addresses are expected.
That Redis payload is the contract the rest of the flow has to satisfy. Without it, the client could improvise, and when clients improvise in payments, they eventually improvise bugs.
Phase 3: The Frontend Must Not Be Allowed to Do Math It Can Avoid
In usePurchaseFlow.ts, the client builds the Solana transaction locally — wallet signing is client-side — but I am careful about what logic stays on the client. The hook does not re-compute the platform split from floating-point SOL values. It uses the exact backend-supplied seller_lamports and treasury_lamports. If it recalculated from price_sol_total, floating-point rounding could diverge, different clients could produce different lamport values for the same quote, and the verifier would reject legitimate payments.
The frontend is a transaction assembler, not a pricing engine.
The transaction contains three instructions: a transfer from buyer to seller, a transfer from buyer to treasury, and a memo instruction containing the purchase_reference.
That memo is doing serious security work. It binds a generic SOL transfer to this specific purchase session. Without it, a user could replay some other valid-looking payment against the current checkout. With it, the backend can say: I do not care that you sent SOL. I care whether you sent the right SOL, to the right recipients, with the exact reference this purchase intent expects.
The hook models the UI as an explicit state machine: IDLE → INITIATING → AWAITING_WALLET → BUILDING_TX → AWAITING_SIGNATURE → CONFIRMING_ONCHAIN → CONFIRMING_BACKEND → SUCCESS / FAILED.
This matters because payment UX gets dangerous when the UI cannot explain which phase failed. If your product only knows “loading” and “error,” then every failure looks identical to the user, even when the recovery path is completely different. The critical distinction is between failure before money moved and failure after on-chain success but before backend confirmation. That distinction powers the most important recovery button in the whole flow: Retry Confirmation.
Because if the transaction already finalized on-chain, asking the user to “try again” is how you get paid twice.
Money moved
UI action
Try Again
The backend rejected before a purchase_reference was issued. Nothing was reserved. Restart checkout entirely.
No funds left the wallet. Safe to restart.
Phase 4: Verification Is Where Most Crypto Checkouts Become Vibes
The confirm step is where weak systems reveal themselves. A bad confirm endpoint does one of two useless things: it trusts the client too much, or it queries the chain too loosely. Mine does neither.
POST /api/purchases/confirm starts with defensive boringness. Validate purchase_reference. Validate transaction signature format. Validate wallet address format. Ensure the authenticated user matches the stored buyer in Redis.
Then it does something subtle and correct before touching Redis: it checks whether this tx_signature is already recorded in MongoDB. This is not premature optimization. It is idempotency on the hot path, which means that if a request already succeeded and the client retries because the response got lost, the backend can short-circuit immediately and return success without reopening the entire flow.
After that, it loads the purchase intent from Redis but deliberately does not delete it yet.
This is one of the strongest design choices in the system. The naive version of confirm would consume the intent immediately, then attempt the DB write. If the DB write failed, the user would be stuck in the worst possible state: the chain says paid, the backend has no durable purchase record, the quote is gone, retry is impossible. I avoid that by treating Redis intent deletion as the final cleanup step after Purchase.create succeeds.
Then verifySolanaTransaction() checks the chain with real specificity. The transaction exists and is finalized. The transaction did not fail on-chain. The fee payer at account index 0 is the expected buyer wallet. The seller wallet appears in the account list. The treasury wallet appears in the account list. The pre/post balance delta for the seller is at least the expected lamports. Same for the treasury. A memo instruction exists. The memo content matches the purchase_reference exactly.
Notice what this means in practice. The backend is not verifying “some payment happened.” It is verifying: this buyer funded this exact transaction, this seller and this treasury received the expected value, and this chain event was explicitly labeled as this purchase.
That is a real verifier.
Also notice what it does not do: it does not use substring matching for the memo. The code explicitly rejects includes() because substring matching would let an attacker smuggle the expected reference inside a larger string.
That is the kind of microscopic decision that keeps a payment system from turning into a postmortem.
Phase 5: Retryability Is Not a Feature. It Is the Price of Admitting Distributed Systems Exist.
The most honest part of this architecture is that it assumes success can be partially observed, and that is the right assumption. The on-chain transfer, Redis state, MongoDB write, HTTP response, and frontend state update do not happen in one magical atom. The code is built around the idea that one layer can succeed while another fails.
This shows up everywhere.
Case 1 — Response lost after purchase already saved
The backend checks tx_signature first, so a repeated confirm can safely return Purchase already confirmed.
Case 2 — Purchase intent expired, but a concurrent request already persisted
The controller does a second DB lookup before returning a hard expiry error, which turns a race into a harmless idempotent success.
Case 3 — Purchase.create fails because another request won the race
Duplicate key error 11000 is treated as a success condition, not a crash.
Case 4 — DB failure after on-chain success
The backend intentionally does not delete the Redis intent. The frontend persists the transaction signature, purchase reference, and buyer wallet to localStorage — keyed per purchase_reference and expiring when the Redis intent does — so that Retry Confirmation survives a page refresh or browser close without re-sending funds. A 410 response clears the entry and surfaces Try Again instead, so the retry loop terminates cleanly.
This is the difference between a payment flow that respects reality and one that only respects happy-path demos. If the transaction finalized on-chain but backend confirmation failed, PurchaseModal.tsx shows Retry Confirmation — not a generic error. That wording communicates the recovery boundary exactly: do not pay again, only retry the server-side reconciliation step.
Phase 6: A Purchase Is Only Real When It Changes What the Buyer Can Do
Once verification passes, the backend writes a Purchase document with buyer, seller, and project IDs, USD and SOL amounts, exchange rate proof, wallet snapshots, transaction signature, and CONFIRMED status — including full project and seller snapshots.
Those snapshots are not redundancy. They are durability. If a project is later deleted or a seller changes their profile, the purchase ledger and receipt still make sense. Snapshotting is what makes purchase history survive the rest of the product evolving around it.
Then the controller deletes the Redis purchase intent, removes the project from the buyer's wishlist, and updates the seller's sales aggregates atomically — using MongoDB atomic updates to avoid a read-then-write race under concurrency.
Purchased Projects Ledger
GET /api/purchases/getPurchasedProjects returns confirmed purchases, populates project data when available, and falls back gracefully when a project has been hard-deleted.
That means the purchase remains visible even if the original listing does not.
Download Access
GET /api/purchases/download does not blindly return a file. It re-checks that the user is authenticated, the project exists, the buyer has a confirmed purchase, and the repo zip is still in SUCCESS. Only then does it generate a 15-minute presigned S3 URL with a sanitized filename.
Money moving is not the same thing as entitlement delivery. Delivery is its own guarded system.
Receipt Generation
GET /api/purchases/receipt generates a PDF with purchase details, financial breakdown, parties, and fallback snapshot data.
A lot of systems can take payment and unlock access. Far fewer can still explain the transaction cleanly later, when the surrounding product data has changed. Mine can.
Phase 7: What This Flow Gets Right, And What It Still Pays For
What it gets right:
Idempotency
The flow expects retries and handles them deliberately instead of treating them as weird accidents.
Replay resistance
The memo-bound purchase_reference turns a generic SOL transfer into a purchase-scoped proof.
Operational resilience
Oracle fallback, stale cache fallback, Redis-backed intent storage, and race-aware DB handling give the system multiple ways to degrade without collapsing.
Durable entitlement history
Snapshots keep purchase history and receipts coherent even after project or seller data changes.
Where it is still vulnerable, expensive, or structurally opinionated:
Intent TTL is a real business constraint
Ten minutes is reasonable, but the clock starts at quote issuance — not at transaction finalization. Wallet hesitation alone can consume 2–3 minutes before the TX is even sent. Page refresh and browser close within the remaining window are handled via localStorage. A different device is not: the persisted retry state is local, so a user who switches devices after a dropped connection has no recovery path.
External dependencies still own part of your uptime
If Solana RPC is unreachable or pricing oracles are unhealthy beyond the fallback window, checkout degrades. Mine handles that better than most, but it cannot abolish the dependency.
The 1% platform split is embedded deeply
It exists in price splitting, transaction construction, verification, and receipt math. Fine while the business model is stable — not a cheap constant if monetization changes.
The verifier is strict by design
Strict is mostly good, but when chain formats, RPC behavior, or wallet quirks shift, the verifier becomes a maintenance surface, not just a utility.
Conclusion
The strongest thing about this purchase flow is that it does not romanticize checkout. It assumes prices go stale, users retry, responses get lost, DB writes fail, chain events need interpretation, and purchases must outlive the objects they were originally attached to.
That is why the system works. Not because it uses Solana, not because it uses Redis, and not because it has a modal with a countdown — but because it treats payment as a reconciliation problem instead of a button press.
If you are building a marketplace for digital assets, that is the mental model you actually need.