Agentora TechnologiesAgentora
Payments architecture

Separating the payment from the permission

The tempting design puts access behind a payment row's status field. That collapses two different questions — did money move, and may this user see this — into one column, and every refund, retry, duplicate webhook and closed browser tab exposes the mistake.

Agentora EngineeringJuly 25, 20268 min read

What this system does

Agentora's client portal sells three independently purchasable components — Architecture, Delivery, and Website — each unlocked with a one-time payment, buyable in any order. This case study covers how access to a paid component is decided.

The problem

The obvious implementation is one table. A payment row has a status, and if that status is paid, the user gets access. It is fewer moving parts and it demonstrably works on the happy path.

It fails as soon as reality arrives, because it conflates two genuinely different facts. Whether money moved is a question about a transaction. Whether someone may open a page is a question about a grant. They usually agree, and the interesting cases are all the ones where they do not.

A refund is a money event that must revoke a permission. A failed retry is a money event that must not revoke a permission already granted. A payment provider delivers a webhook more than once by design, and the browser's own confirmation call races it — so the same settlement arrives twice, from two directions. A customer can close the tab between being charged and the confirmation landing, which means the grant has to be completable by something other than the browser.

There is also a quieter failure. If access is computed inline wherever it is needed — once in the page, once in the sidebar, once in the API — those three copies of the rules drift. The dangerous drift is the one where the UI hides a button but the endpoint behind it still answers.

Constraints

The boundaries the design had to respect, before any solution was chosen.

  • A granted, paid entitlement must survive any later stray payment rows on the same component.
  • Duplicate settlement — the browser's verify call and the provider's webhook — must be idempotent.
  • A crash between marking the payment paid and writing the grant must heal itself on a later retry.
  • A double-click or refresh must never produce two simultaneously settleable orders.
  • An unconfigured price must never result in a zero-value order that silently grants access for free.
  • The gate has to hold at the API, not only in the interface.

The architecture

Two tables model the two facts, one resolver decides access for everyone, and settlement is written to be re-run safely.

  1. 1

    Two sources of truth

    component_entitlements is the access grant and the only thing consulted to decide access. payments is the transaction record. Payment failure lives on the payment's status; the entitlement deliberately has no failed state, because a failed charge is not a kind of permission.

  2. 2

    One resolver, used by the page, the sidebar and the gate

    A single server-side function resolves access for a component, and the workspace page, the navigation, and the API gate all call it. Because there is one implementation, the interface and the endpoint cannot disagree about whether something is unlocked.

  3. 3

    Deterministic precedence

    Resolution is ordered and first match wins: an unmet prerequisite yields unavailable; a paid entitlement yields paid and outranks any stray payment rows; a refunded entitlement returns to locked; otherwise it is locked, with a messaging hint derived from the newest payment row. Access state is never pending — that is a client-side transient while the paywall polls after a charge.

  4. 4

    Settlement that repairs instead of returning early

    On settlement the handler marks the payment paid and then ensures the grant exists — and it does this even when the payment is already marked paid, rather than short-circuiting. A crash between the two writes is healed by the next webhook or verify retry. The original settlement timestamp and amount are preserved once set, so a repair never rewrites history.

  5. 5

    Order reuse with a TTL

    Requesting checkout reuses an existing open order inside a configurable window (twenty minutes by default) and retires a stale one before creating a fresh one, so at most one active order exists per engagement and component. A double-click, a refresh, or an abandoned checkout cannot produce two live orders that could both settle.

  6. 6

    Fail loudly on misconfiguration

    A price of zero is treated as a configuration error and returns a 503, not a free unlock. The gate itself returns a standard 402 with the component name, and every mutating endpoint — generate, save, approve, publish, export — enforces it.

Key decisions and their trade-offs

Every decision below cost something. The trade-off is stated alongside the reasoning.

Model the grant and the transaction as two separate tables

Why
They answer different questions and diverge in exactly the cases that matter — refunds, failed retries, and repeated webhooks. Separating them means a money event can be recorded truthfully without implying a permission change, and vice versa.

Trade-off
Two tables must be kept coherent, and the state the UI needs is derived from both rather than read from one column.

A paid grant outranks every payment row

Why
Once access has been legitimately granted, a later stray or failed payment row must not be able to take it away. Ordering the precedence explicitly makes that guarantee readable instead of emergent.

Trade-off
Revocation has to be a deliberate act — writing a refunded entitlement — rather than something that happens as a side effect of payment records changing.

Make settlement idempotent by repairing, not by returning early

Why
The naive idempotency check — if already paid, do nothing — is wrong precisely in the crash case, where the payment is marked paid but the grant was never written. Re-ensuring the grant on every retry turns duplicate delivery from a hazard into the recovery mechanism.

Trade-off
Every retry does a little redundant work, and the write has to be careful to preserve the original timestamp and amount rather than overwriting them.

Treat a zero price as an error rather than as free

Why
A missing price is a configuration mistake. Interpreting it as free would silently give away a paid component, which is the worst possible default for a quiet misconfiguration.

Trade-off
Checkout breaks visibly when a price is not configured. That is the intended behaviour, but it means the rate card has to be populated before a component can be sold.

Enforce the gate at every mutating endpoint, not just in the UI

Why
A hidden button is a suggestion. Anything that changes state has to check access itself, because the interface is not a security boundary.

Trade-off
The check is repeated across many handlers, which is more code than gating in one place in the UI — the repetition is the point.

Technologies used

Data

  • PostgreSQL
  • Unique & partial indexes
  • Row-level security

Payments

  • Razorpay orders
  • Webhook + verify reconciliation

Application

  • TypeScript
  • Next.js route handlers

Outcome

  • Access is decided in exactly one place, so the page, the navigation and the API cannot disagree about what is unlocked.
  • Duplicate settlement and a crash between writes both resolve to the correct state on retry, without manual intervention.
  • At most one settleable order exists per component at a time, and an unconfigured price fails loudly instead of unlocking a component for free.

Known limitations

What this design does not do. Stated because an architecture without documented trade-offs has usually not been examined closely enough.

  • There is no refund flow yet. The schema and the stored provider payment id are ready for one, but the operation itself is not implemented.
  • Installment splitting for a component whose price exceeds the single-transaction cap is not implemented.
  • The "payment processing" state users see is a client-side transient; the server never reports pending as an access state, so an abandoned checkout reads as locked.

Want this level of rigour on your AI initiative?

Start with a free AI Readiness Assessment, or book a Discovery Workshop to get a scored, costed roadmap.