Decoupling Refund Ledger from Digital Entitlements: State Machine Design for SaaS & Digital Goods

A customer with a $1,200 enterprise SaaS subscription requests SLA compensation after a 40-minute API outage. Support clicks Partial Refund in the operations console and returns 120 USDC as a goodwill credit. Three seconds after the refund confirms, the system revokes every workspace collaboration permission. Team members see “Your order has been refunded; access has ended,” and running production pipelines stop immediately.

The root cause is simple and common: the backend originally combined financial status and digital entitlement into one orders.status enum. A refund changes the order from PAID to REFUNDED, while downstream services and gateway middleware authorize access with if (order.status !== 'PAID') throw new UnauthorizedException().

In modern commerce supporting digital goods such as software licenses, offline datasets, and membership passes alongside stablecoin micropayments, fund movements and entitlement lifecycles are different business dimensions. Separating the refund ledger from fulfillment entitlements is a foundational requirement for a robust payment platform.


1. Seven Coupling Antipatterns: Why order.status Cannot Be an Access Gate

A typical monolithic order table often uses transitions like these:

[ Incorrect monolithic state machine ]
   PENDING ──> PAID ──> FULFILLED ──> REFUNDED
                          │
                          └──> PARTIALLY_REFUNDED (state explosion and ambiguity)

Real business scenarios quickly expose conflicts in this tightly coupled model:

  1. Partial refund ambiguity: A customer buys a five-module software package and refunds one module. If the order becomes PARTIALLY_REFUNDED, the gateway cannot tell which modules the customer may still access.
  2. The dispute window: Suspicious funding sources or fraud allegations may require risk controls to freeze refund requests or suspend withdrawals temporarily. That does not necessarily mean historical download permissions should expire immediately.
  3. Goodwill refunds without revocation: A merchant compensates all customers 10% after an uncontrollable outage. The ledger must record the expense, while digital entitlements remain fully ACTIVE.
  4. Double-refund leaks: If refunds depend only on the main order row, two concurrent $50 partial refunds can push cumulative refunds above the original payment (sum(refunds) > paid_amount) without an independent refund ledger and row-level locking.

2. Two Independent State Machines: Financial Ledger and Entitlements

Decoupling Refund Ledger from Digital Entitlements: State Machine Design for SaaS & Digital Goods

The production implementation of StableIn Payments separates financial transactions and entitlements into two independent finite-state machines (FSMs), coordinated through a one-way event bus.

+----------------------------------------------------------------------------------------------------+
|                         Decoupled Dual-State-Machine Architecture (StableIn Payments)               |
+----------------------------------------------------------------------------------------------------+

   [ Financial FSM ]                                      [ Entitlement FSM ]
   
      +-------------------+                                  +-------------------+
      |      QUOTED       |                                  |   UNFULFILLED     |
      +---------+---------+                                  +---------+---------+
                │                                                      │
                ▼                                                      ▼
      +-------------------+       OrderPaidEvent             +-------------------+
      |    CONFIRMING     |─────────────────────────────────>|      ACTIVE       |
      +---------+---------+                                  +---+-------+---+---+
                │                                                │       │   │
                ▼                                                │       │   │
      +-------------------+                                      │       │   │
      |   PAID_SETTLED    |                                      │       │   │
      +----+---------+----+                                      │       │   │
           │         │                                           │       │   │
           │         ▼ RefundIssuedEvent (Partial: Keep Access)  │       │   │
           │         +-----------------------+                   │       │   │
           │         |   PARTIALLY_REFUNDED  | (No-op on rights) │       │   │
           │         +-----------------------+                   │       │   │
           │                                                     │       │   │
           ▼ RefundIssuedEvent (Full / Explicit Revoke)          ▼       │   │
      +-------------------+                        +-------------------+ │   │
      |  FULLY_REFUNDED   |───────────────────────>|      REVOKED      | │   │
      +-------------------+                        +-------------------+ │   │
                                                                         ▼   │
                                                   +-------------------+     │
                                                   |     SUSPENDED     |     │
                                                   +-------------------+     ▼
                                                                     +-------------------+
                                                                     |      EXPIRED      |
                                                                     +-------------------+
+----------------------------------------------------------------------------------------------------+

1. Separate Core Tables

In MySQL, replace complex refund and permission fields in orders with three core tables that have strict foreign keys and unique indexes:

-- 1. Main order table: original intent and final financial state only
CREATE TABLE payment_orders (
    id VARCHAR(64) PRIMARY KEY,
    merchant_id VARCHAR(64) NOT NULL,
    customer_id VARCHAR(64) NOT NULL,
    quote_currency VARCHAR(16) NOT NULL,
    quote_raw_amount VARCHAR(78) NOT NULL, -- Integer in minimum units, e.g. 1000000 (1 USDC)
    settled_raw_amount VARCHAR(78) DEFAULT '0',
    refunded_raw_amount VARCHAR(78) DEFAULT '0',
    financial_status ENUM('QUOTED', 'CONFIRMING', 'PAID_SETTLED', 'PARTIALLY_REFUNDED', 'FULLY_REFUNDED') NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- 2. Refund ledger: immutable append-only log
CREATE TABLE payment_refund_ledgers (
    id VARCHAR(64) PRIMARY KEY,
    order_id VARCHAR(64) NOT NULL,
    refund_raw_amount VARCHAR(78) NOT NULL,
    refund_tx_hash VARCHAR(66) UNIQUE, -- On-chain refund transaction hash
    reason VARCHAR(255) NOT NULL,
    operator_id VARCHAR(64) NOT NULL,
    status ENUM('PENDING', 'BROADCASTED', 'CONFIRMED', 'FAILED') NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT fk_refund_order FOREIGN KEY (order_id) REFERENCES payment_orders(id)
) ENGINE=InnoDB;

-- 3. Digital entitlements: consumable resource permissions and lifecycle
CREATE TABLE digital_entitlements (
    id VARCHAR(64) PRIMARY KEY,
    order_id VARCHAR(64) NOT NULL,
    customer_id VARCHAR(64) NOT NULL,
    resource_id VARCHAR(128) NOT NULL, -- Specific permission identifier, e.g. "license:analytics_pro"
    entitlement_status ENUM('UNFULFILLED', 'ACTIVE', 'SUSPENDED', 'REVOKED', 'EXPIRED') NOT NULL,
    granted_at TIMESTAMP NULL,
    revoked_at TIMESTAMP NULL,
    expires_at TIMESTAMP NULL,
    CONSTRAINT fk_entitlement_order FOREIGN KEY (order_id) REFERENCES payment_orders(id),
    UNIQUE KEY uk_order_resource (order_id, resource_id)
) ENGINE=InnoDB;

3. Production Implementation: Atomic Refunds and Explicit Entitlement Decisions in NestJS Transactions

A controller must not hide the decision to revoke access inside a generic refund function. Refund instructions from the operations console need two independent parameters: refund amount (refundAmount) and entitlement action (entitlementAction).

The core service below uses NestJS with TypeORM / MySQL:

// stablein-platform/src/modules/refund/refund.service.ts
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { PaymentOrder, FinancialStatus } from '../order/entities/order.entity';
import { PaymentRefundLedger, RefundStatus } from './entities/refund-ledger.entity';
import { DigitalEntitlement, EntitlementStatus } from '../entitlement/entities/entitlement.entity';

export enum EntitlementDisposition {
  KEEP_ACTIVE = 'KEEP_ACTIVE',       // Goodwill compensation/partial downgrade; retain permissions
  SUSPEND = 'SUSPEND',               // Temporarily suspend access during a dispute investigation
  REVOKE_SPECIFIC = 'REVOKE_SPECIFIC', // Revoke specified resource permissions
  REVOKE_ALL = 'REVOKE_ALL',         // Revoke every download and access permission
}

export interface ProcessRefundDto {
  orderId: string;
  refundRawAmount: bigint;
  reason: string;
  operatorId: string;
  disposition: EntitlementDisposition;
  targetResourceIds?: string[];
}

@Injectable()
export class RefundExecutionService {
  constructor(private readonly dataSource: DataSource) {}

  public async executeRefundWithEntitlementPolicy(dto: ProcessRefundDto): Promise<void> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 1. Pessimistically lock the order row to prevent concurrent refund races
      const order = await queryRunner.manager
        .createQueryBuilder(PaymentOrder, 'order')
        .setLock('pessimistic_write')
        .where('order.id = :id', { id: dto.orderId })
        .getOne();

      if (!order) {
        throw new BadRequestException(`Order ${dto.orderId} not found.`);
      }

      const settledAmount = BigInt(order.settled_raw_amount);
      const alreadyRefunded = BigInt(order.refunded_raw_amount);
      const newTotalRefunded = alreadyRefunded + dto.refundRawAmount;

      // 2. Core invariant: cumulative refunds must never exceed settled funds
      if (newTotalRefunded > settledAmount) {
        throw new ConflictException(
          `Refund overflow: Settled ${settledAmount}, already refunded ${alreadyRefunded}, attempted ${dto.refundRawAmount}`
        );
      }

      // 3. Append a refund ledger entry
      const refundRecord = queryRunner.manager.create(PaymentRefundLedger, {
        id: `ref_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
        order_id: order.id,
        refund_raw_amount: dto.refundRawAmount.toString(),
        reason: dto.reason,
        operator_id: dto.operatorId,
        status: RefundStatus.PENDING,
      });
      await queryRunner.manager.save(refundRecord);

      // 4. Update order financial state
      order.refunded_raw_amount = newTotalRefunded.toString();
      if (newTotalRefunded === settledAmount) {
        order.financial_status = FinancialStatus.FULLY_REFUNDED;
      } else {
        order.financial_status = FinancialStatus.PARTIALLY_REFUNDED;
      }
      await queryRunner.manager.save(order);

      // 5. Apply explicit entitlement transitions independently of financial amounts
      await this.applyEntitlementPolicy(
        queryRunner.manager,
        order.id,
        dto.disposition,
        dto.targetResourceIds
      );

      await queryRunner.commitTransaction();
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw err;
    } finally {
      await queryRunner.release();
    }
  }

  private async applyEntitlementPolicy(
    manager: any,
    orderId: string,
    disposition: EntitlementDisposition,
    targetResourceIds?: string[]
  ): Promise<void> {
    const entitlements: DigitalEntitlement[] = await manager.find(DigitalEntitlement, {
      where: { order_id: orderId },
    });

    for (const ent of entitlements) {
      switch (disposition) {
        case EntitlementDisposition.KEEP_ACTIVE:
          // Refund funds while leaving permissions unchanged
          break;

        case EntitlementDisposition.SUSPEND:
          if (ent.entitlement_status === EntitlementStatus.ACTIVE) {
            ent.entitlement_status = EntitlementStatus.SUSPENDED;
            await manager.save(ent);
          }
          break;

        case EntitlementDisposition.REVOKE_ALL:
          ent.entitlement_status = EntitlementStatus.REVOKED;
          ent.revoked_at = new Date();
          await manager.save(ent);
          break;

        case EntitlementDisposition.REVOKE_SPECIFIC:
          if (targetResourceIds && targetResourceIds.includes(ent.resource_id)) {
            ent.entitlement_status = EntitlementStatus.REVOKED;
            ent.revoked_at = new Date();
            await manager.save(ent);
          }
          break;
      }
    }
  }
}

4. Comparing a Monolithic State Machine with Two Independent State Machines

Dimension Coupled monolithic state machine (orders.status) Decoupled state machines (Financial + Entitlement)
Partial refunds PARTIALLY_REFUNDED leaves gateway authorization uncertain Ledger accumulates refunds exactly; resource-level entitlements stay ACTIVE or change selectively
Goodwill SLA compensation Refund accidentally revokes access, causing a second incident for high-value customers Explicit KEEP_ACTIVE records the expense without interrupting customer service
Concurrent double refunds No atomic row lock; concurrent partial refunds can exceed payment SELECT ... FOR UPDATE with strict sum(refunds) <= settled invariant
Permission provenance and audit Cannot establish when, by whom, or through which refund a permission was revoked digital_entitlements and payment_refund_ledgers provide tamper-resistant operation logs
Asynchronous on-chain reorgs Main order state cannot roll back after a refund reorg failure Independent refund stays PENDING until CONFIRMED; exceptions do not contaminate entitlements

5. Production Integration and Recovery

After separating the state machines, engineering teams should prepare defenses for two adjacent infrastructure concerns:

  1. Distributed download tokens and cache invalidation: SaaS and digital-asset gateways often cache JWTs or permission lists in Redis, perhaps with a 15-minute TTL. When an entitlement becomes REVOKED, publish EntitlementRevokedEvent through Redis Pub/Sub or Kafka and force a token_version increment or blacklist the access_token. This prevents refunded users from continuing to download the latest model weights.
  2. Prevent refund gas costs from exceeding the payment: A customer may request a 2 USDC micropayment refund, while an ERC-20 transfer on Ethereum mainnet costs $4 in ETH gas. In StableIn Payments, we recommend merchant policies with a minimum net amount for return-to-source refunds, or alternatives such as L2 tokens and account credits.

Commercial Consulting and Source-Code Delivery

If you are building a Web3 digital-product store, SaaS stablecoin checkout platform, or automated API billing system for international developers or high-value enterprise customers: