Skip to content

Pay-In Schema

Owner Classification Review Date Status
CDO Office Internal April 2027 Active
-- ============================================================================
-- Simpaisa Pay-Ins SurrealDB Schema
-- Domain: Mobile Money Pay-Ins, OTP Verification, Tokenised Payments,
--         Recurring Subscriptions, Channel Configuration
-- Target: SurrealDB 2.x | SurrealQL
-- Markets: PK, BD, NP, IQ
-- Scale: 270M+ transactions, $1B+ processed
-- ============================================================================

-- ----------------------------------------------------------------------------
-- Namespace & Database
-- ----------------------------------------------------------------------------
-- USE NS simpaisa;
-- USE DB payments;

-- ============================================================================
-- SCOPES & ACCESS
-- ============================================================================

DEFINE SCOPE merchant SESSION 24h
  SIGNUP (
    -- Merchant onboarding handled by platform-svc; stub for completeness
    CREATE merchant_user SET
      email = $email,
      merchantId = $merchantId,
      passwordHash = crypto::argon2::generate($password),
      createdAt = time::now()
  )
  SIGNIN (
    SELECT * FROM merchant_user
    WHERE email = $email
      AND crypto::argon2::compare(passwordHash, $password)
  );

DEFINE SCOPE service SESSION 1h
  SIGNIN (
    SELECT * FROM api_key
    WHERE keyHash = crypto::sha256($apiKey)
      AND active = true
      AND expiresAt > time::now()
  );

-- ============================================================================
-- ENUMS (modelled as strict string checks via ASSERT)
-- ============================================================================

-- Transaction types: CHARGE, REFUND, REVERSAL, TOKEN_CHARGE, RECURRING_CHARGE
-- Statuses: INITIATED, OTP_SENT, OTP_VERIFIED, SUBMITTED, SUCCESS, FAILED,
--           REVERSED, EXPIRED, PENDING_CALLBACK

-- ============================================================================
-- TABLE: pay_in_transaction
-- Core pay-in transaction record
-- ============================================================================

DEFINE TABLE pay_in_transaction SCHEMAFULL
  PERMISSIONS
    FOR select WHERE merchantId = $auth.merchantId OR $scope = 'service'
    FOR create, update WHERE $scope = 'service'
    FOR delete NONE;

DEFINE FIELD id               ON pay_in_transaction TYPE string;
DEFINE FIELD merchantId       ON pay_in_transaction TYPE string
  ASSERT $value != NONE;
DEFINE FIELD operatorId       ON pay_in_transaction TYPE string
  ASSERT $value != NONE
  COMMENT 'Mobile network operator identifier (e.g. jazz_pk, bkash_bd, ncell_np)';
DEFINE FIELD transactionType  ON pay_in_transaction TYPE string
  ASSERT $value IN ['CHARGE', 'REFUND', 'REVERSAL', 'TOKEN_CHARGE', 'RECURRING_CHARGE']
  COMMENT 'Enumerated transaction type';
DEFINE FIELD amount           ON pay_in_transaction TYPE decimal
  ASSERT $value > 0;
DEFINE FIELD currency         ON pay_in_transaction TYPE string
  ASSERT string::len($value) = 3
  COMMENT 'ISO 4217 currency code (PKR, BDT, NPR, IQD)';
DEFINE FIELD msisdn           ON pay_in_transaction TYPE string
  ASSERT string::len($value) >= 10 AND string::len($value) <= 15
  COMMENT 'Subscriber mobile number in E.164 format';
DEFINE FIELD status           ON pay_in_transaction TYPE string
  ASSERT $value IN ['INITIATED', 'OTP_SENT', 'OTP_VERIFIED', 'SUBMITTED', 'SUCCESS', 'FAILED', 'REVERSED', 'EXPIRED', 'PENDING_CALLBACK']
  COMMENT 'Current transaction state';
DEFINE FIELD traceId          ON pay_in_transaction TYPE string
  COMMENT 'Distributed tracing correlation ID';
DEFINE FIELD idempotencyKey   ON pay_in_transaction TYPE string
  ASSERT $value != NONE
  COMMENT 'Client-supplied idempotency key to prevent duplicate charges';
DEFINE FIELD channelRef       ON pay_in_transaction TYPE option<string>
  COMMENT 'Upstream channel/operator reference ID';
DEFINE FIELD failureReason    ON pay_in_transaction TYPE option<string>;
DEFINE FIELD metadata         ON pay_in_transaction TYPE option<object>
  COMMENT 'Arbitrary merchant-supplied metadata (max 4KB)';
DEFINE FIELD createdAt        ON pay_in_transaction TYPE datetime
  DEFAULT time::now();
DEFINE FIELD updatedAt        ON pay_in_transaction TYPE datetime
  DEFAULT time::now()
  VALUE time::now();

-- Indexes
DEFINE INDEX idx_payin_merchant_status
  ON pay_in_transaction FIELDS merchantId, status;
DEFINE INDEX idx_payin_idempotency
  ON pay_in_transaction FIELDS idempotencyKey UNIQUE;
DEFINE INDEX idx_payin_msisdn
  ON pay_in_transaction FIELDS msisdn, createdAt;
DEFINE INDEX idx_payin_trace
  ON pay_in_transaction FIELDS traceId;
DEFINE INDEX idx_payin_created
  ON pay_in_transaction FIELDS createdAt;
DEFINE INDEX idx_payin_operator_status
  ON pay_in_transaction FIELDS operatorId, status;

-- Event: State transition audit
DEFINE EVENT payin_state_change ON TABLE pay_in_transaction WHEN $before.status != $after.status THEN {
  CREATE event_store SET
    aggregateId   = $after.id,
    aggregateType = 'pay_in_transaction',
    eventType     = string::concat('PAYIN_', $after.status),
    eventData     = {
      previousStatus: $before.status,
      newStatus:      $after.status,
      amount:         $after.amount,
      currency:       $after.currency,
      merchantId:     $after.merchantId,
      operatorId:     $after.operatorId,
      traceId:        $after.traceId
    },
    sequenceNumber = time::now(),
    createdAt      = time::now()
};

-- ============================================================================
-- TABLE: otp_request
-- OTP verification requests linked to pay-in transactions
-- ============================================================================

DEFINE TABLE otp_request SCHEMAFULL
  PERMISSIONS
    FOR select, create, update WHERE $scope = 'service'
    FOR delete NONE;

DEFINE FIELD id              ON otp_request TYPE string;
DEFINE FIELD transactionId   ON otp_request TYPE string
  ASSERT $value != NONE
  COMMENT 'References pay_in_transaction.id';
DEFINE FIELD msisdn          ON otp_request TYPE string;
DEFINE FIELD otpHash         ON otp_request TYPE string
  COMMENT 'Argon2-hashed OTP — never store plaintext';
DEFINE FIELD status          ON otp_request TYPE string
  ASSERT $value IN ['PENDING', 'VERIFIED', 'EXPIRED', 'FAILED'];
DEFINE FIELD attempts        ON otp_request TYPE int
  DEFAULT 0
  ASSERT $value <= 5
  COMMENT 'Max 5 attempts before lockout';
DEFINE FIELD expiresAt       ON otp_request TYPE datetime
  COMMENT 'OTP validity window (typically 5 minutes)';
DEFINE FIELD verifiedAt      ON otp_request TYPE option<datetime>;
DEFINE FIELD createdAt       ON otp_request TYPE datetime
  DEFAULT time::now();
DEFINE FIELD updatedAt       ON otp_request TYPE datetime
  DEFAULT time::now()
  VALUE time::now();

DEFINE INDEX idx_otp_transaction
  ON otp_request FIELDS transactionId;
DEFINE INDEX idx_otp_msisdn_status
  ON otp_request FIELDS msisdn, status;

-- ============================================================================
-- TABLE: payment_token
-- Tokenised payment credentials for repeat charges
-- ============================================================================

DEFINE TABLE payment_token SCHEMAFULL
  PERMISSIONS
    FOR select WHERE merchantId = $auth.merchantId OR $scope = 'service'
    FOR create, update WHERE $scope = 'service'
    FOR delete WHERE $scope = 'service';

DEFINE FIELD id              ON payment_token TYPE string;
DEFINE FIELD merchantId      ON payment_token TYPE string;
DEFINE FIELD msisdn          ON payment_token TYPE string;
DEFINE FIELD operatorId      ON payment_token TYPE string;
DEFINE FIELD tokenRef        ON payment_token TYPE string
  COMMENT 'Operator-issued payment token reference';
DEFINE FIELD status          ON payment_token TYPE string
  ASSERT $value IN ['ACTIVE', 'REVOKED', 'EXPIRED'];
DEFINE FIELD lastUsedAt      ON payment_token TYPE option<datetime>;
DEFINE FIELD expiresAt       ON payment_token TYPE option<datetime>;
DEFINE FIELD createdAt       ON payment_token TYPE datetime
  DEFAULT time::now();
DEFINE FIELD updatedAt       ON payment_token TYPE datetime
  DEFAULT time::now()
  VALUE time::now();

DEFINE INDEX idx_token_merchant
  ON payment_token FIELDS merchantId, status;
DEFINE INDEX idx_token_msisdn
  ON payment_token FIELDS msisdn, operatorId;

-- ============================================================================
-- TABLE: recurring_subscription
-- Merchant-initiated recurring billing subscriptions
-- ============================================================================

DEFINE TABLE recurring_subscription SCHEMAFULL
  PERMISSIONS
    FOR select WHERE merchantId = $auth.merchantId OR $scope = 'service'
    FOR create, update WHERE $scope = 'service'
    FOR delete NONE;

DEFINE FIELD id              ON recurring_subscription TYPE string;
DEFINE FIELD merchantId      ON recurring_subscription TYPE string;
DEFINE FIELD msisdn          ON recurring_subscription TYPE string;
DEFINE FIELD tokenId         ON recurring_subscription TYPE string
  COMMENT 'References payment_token.id';
DEFINE FIELD amount          ON recurring_subscription TYPE decimal
  ASSERT $value > 0;
DEFINE FIELD currency        ON recurring_subscription TYPE string
  ASSERT string::len($value) = 3;
DEFINE FIELD frequency       ON recurring_subscription TYPE string
  ASSERT $value IN ['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'];
DEFINE FIELD status          ON recurring_subscription TYPE string
  ASSERT $value IN ['ACTIVE', 'PAUSED', 'CANCELLED', 'EXPIRED', 'SUSPENDED'];
DEFINE FIELD nextChargeAt    ON recurring_subscription TYPE datetime;
DEFINE FIELD lastChargedAt   ON recurring_subscription TYPE option<datetime>;
DEFINE FIELD failureCount    ON recurring_subscription TYPE int
  DEFAULT 0
  COMMENT 'Consecutive failures — suspend after 3';
DEFINE FIELD maxRetries      ON recurring_subscription TYPE int
  DEFAULT 3;
DEFINE FIELD createdAt       ON recurring_subscription TYPE datetime
  DEFAULT time::now();
DEFINE FIELD updatedAt       ON recurring_subscription TYPE datetime
  DEFAULT time::now()
  VALUE time::now();

DEFINE INDEX idx_subscription_merchant
  ON recurring_subscription FIELDS merchantId, status;
DEFINE INDEX idx_subscription_next_charge
  ON recurring_subscription FIELDS status, nextChargeAt
  COMMENT 'Used by scheduler to find due subscriptions';

-- Event: Subscription status change
DEFINE EVENT subscription_state_change ON TABLE recurring_subscription WHEN $before.status != $after.status THEN {
  CREATE event_store SET
    aggregateId   = $after.id,
    aggregateType = 'recurring_subscription',
    eventType     = string::concat('SUBSCRIPTION_', $after.status),
    eventData     = {
      previousStatus: $before.status,
      newStatus:      $after.status,
      merchantId:     $after.merchantId,
      msisdn:        $after.msisdn,
      amount:        $after.amount
    },
    sequenceNumber = time::now(),
    createdAt      = time::now()
};

-- ============================================================================
-- TABLE: channel_config
-- Operator/channel configuration per market
-- ============================================================================

DEFINE TABLE channel_config SCHEMAFULL
  PERMISSIONS
    FOR select WHERE $scope = 'service'
    FOR create, update WHERE $scope = 'service'
    FOR delete NONE;

DEFINE FIELD id              ON channel_config TYPE string;
DEFINE FIELD operatorId      ON channel_config TYPE string;
DEFINE FIELD country         ON channel_config TYPE string
  ASSERT $value IN ['PK', 'BD', 'NP', 'IQ'];
DEFINE FIELD channelName     ON channel_config TYPE string
  COMMENT 'Human-readable name (e.g. JazzCash, bKash, eSewa)';
DEFINE FIELD apiEndpoint     ON channel_config TYPE string;
DEFINE FIELD authMethod      ON channel_config TYPE string
  ASSERT $value IN ['OAUTH2', 'API_KEY', 'HMAC', 'CERTIFICATE'];
DEFINE FIELD timeoutMs       ON channel_config TYPE int
  DEFAULT 30000;
DEFINE FIELD retryPolicy     ON channel_config TYPE object
  COMMENT '{ maxRetries: int, backoffMs: int, backoffMultiplier: float }';
DEFINE FIELD supportsOTP     ON channel_config TYPE bool
  DEFAULT true;
DEFINE FIELD supportsToken   ON channel_config TYPE bool
  DEFAULT false;
DEFINE FIELD active          ON channel_config TYPE bool
  DEFAULT true;
DEFINE FIELD createdAt       ON channel_config TYPE datetime
  DEFAULT time::now();
DEFINE FIELD updatedAt       ON channel_config TYPE datetime
  DEFAULT time::now()
  VALUE time::now();

DEFINE INDEX idx_channel_operator
  ON channel_config FIELDS operatorId UNIQUE;
DEFINE INDEX idx_channel_country
  ON channel_config FIELDS country, active;