Skip to main content

Overview

CrediBill is a production-grade billing infrastructure for SaaS applications using African payment providers. It handles subscription billing, payment orchestration, and real-time webhook delivery while maintaining multi-tenant isolation and security.

Key Capabilities

  • Multi-Tenant Architecture: Each SaaS app maintains isolated payment provider credentials
  • 4 African Payment Providers: Flutterwave, PawaPay, Pesapal, DPO
  • Automated Billing Cycles: Cron-based trial expirations, recurring payments, and retries
  • Production-Grade Security: HMAC signature verification, replay attack prevention, encrypted credentials
  • Real-Time Webhooks: Bi-directional webhook delivery with retry logic
  • Comprehensive Logging: Full audit trail of all payment events

Architecture

Multi-Tenant Design

┌─────────────────────────────────────────────────┐
│         SaaS App 1          SaaS App 2          │
│    (Flutterwave + DPO)  (PawaPay + Pesapal)    │
└────────┬──────────────────────────┬──────────────┘
         │                          │
         └──────────┬───────────────┘

         ┌──────────▼──────────┐
         │    CrediBill        │
         │  Orchestration      │
         └──────────┬──────────┘

         ┌──────────┴────────────────┬──────────────┬─────────────┐
         │                           │              │             │
    ┌────▼─────┐  ┌────────────┐  ┌─▼────────┐  ┌─▼──────┐  ┌──▼──────┐
    │Flutterwave   │PawaPay    │  │Pesapal   │  │DPO    │  │Others   │
    └──────────┘  └────────────┘  └──────────┘  └───────┘  └─────────┘
Each SaaS app:
  • Configures their own payment provider credentials
  • Maintains encrypted credential storage
  • Receives isolated webhook events
  • Has payment transactions flow directly to their provider account
CrediBill acts as orchestrator, not custodian - funds never touch CrediBill accounts.

Payment Lifecycle

1. Trial to Paid Conversion

Trigger: Customer’s trial period expires Flow:
Daily 2 AM UTC


Check for expired trials

    ├─ Find subscriptions with trialEndsAt <= now


For each expired trial:

    ├─ Create invoice from subscription + usage

    ├─ Get app's primary payment provider

    ├─ Call provider adapter (initiatePayment)

    ├─ Store payment transaction record

    ├─ Set transaction status: "initiated"


Update subscription status: "active"
Implementation Functions:
  • convex/cronHandlers.ts: processTrialExpirations()
  • convex/payments.ts: initiateSubscriptionPayment()
  • convex/paymentsNode.ts: Provider-specific adapters

2. Recurring Payments

Trigger: Subscription renewal date reached Flow:
Daily 3 AM UTC


Check for due subscriptions

    ├─ Find where currentPeriodEnd <= now


For each due subscription:

    ├─ Aggregate usage (if usage-based)

    ├─ Create invoice for next period

    ├─ Initiate payment via provider

    ├─ Update billing dates
    │     ├─ currentPeriodStart = old.currentPeriodEnd
    │     └─ currentPeriodEnd += interval


Log payment transaction
Implementation Functions:
  • convex/cronHandlers.ts: processRecurringPayments()
  • convex/payments.ts: processRecurringPayment()

3. Payment Confirmation (Webhook)

Trigger: Payment provider sends webhook confirmation Flow:
Provider sends POST /webhooks/{provider}


Verify signature

    ├─ Get webhook secret from config

    ├─ Compute HMAC-SHA256(timestamp.body)

    ├─ Compare with header (timing-safe)

    ├─ Reject if mismatch


Check replay attack

    ├─ Validate timestamp within 5 minutes

    ├─ Reject if too old or future


Check idempotency

    ├─ Search webhookLogs for same provider event ID

    ├─ Reject if already processed


Find matching transaction

    ├─ Look up by providerTransactionId

    ├─ Verify transaction exists


Update transaction status

    ├─ Transaction: pending → success/failed

    ├─ Guard against terminal states
    │     └─ Never overwrite: success/canceled/refunded

    ├─ Mark invoice as paid (if success)


Send outgoing webhook to SaaS app

    ├─ Event: "payment.success" or "payment.failed"

    ├─ Include transaction details

    ├─ Retry if SaaS app endpoint fails


Log webhook event
Implementation Functions:
  • convex/http.ts: Webhook route handlers
  • convex/webhookActions.ts: Event handlers
  • convex/webhookQueries.ts: Transaction lookups
  • convex/webhookMutations.ts: Status updates
  • convex/outgoingWebhooks.ts: SaaS app notifications

4. Failed Payment Handling

Trigger: Payment fails or webhook indicates failure Flow:
Payment marked as FAILED


Subscription status: ACTIVE → PAST_DUE

    ├─ Increment failedAttempts counter

    ├─ Record failure timestamp

    ├─ Set nextRetryAt = now + exponentialBackoff


Send outgoing webhook

    ├─ Event: "payment.failed"

    ├─ Include failure reason from provider

    ├─ SaaS app decides action (restrict access, etc)


Schedule retry (Cron: Daily 4 AM UTC)

    ├─ Check subscriptions in PAST_DUE status

    ├─ Where nextRetryAt <= now AND failedAttempts < 3

    ├─ Re-initiate payment via provider

    ├─ Update retry count


After 3 failures

    ├─ Leave subscription in PAST_DUE

    ├─ Send "final notice" webhook

    ├─ SaaS app must disable access


If payment succeeds on retry:

    ├─ Status: PAST_DUE → ACTIVE

    ├─ Send "payment.success" webhook

    ├─ Reset retry counter


Optional: Proration

    └─ If retry delayed, calculate proration
        └─ Credit customer for failed period
Implementation Functions:
  • convex/cronHandlers.ts: retryFailedPayments()
  • convex/webhookMutations.ts: updateTransactionFromWebhook()

Database Schema

Payment Providers

// Table: paymentProviders
{
  _id: Id<"paymentProviders">,
  _creationTime: number,

  // Multi-tenant isolation
  organizationId: Id<"organizations">,
  appId: Id<"apps">,

  // Provider identification
  provider: "flutterwave" | "pawapay" | "pesapal" | "dpo",

  // Encrypted credentials
  credentials: {
    // Common fields
    secretKeyEncrypted: string, // AES-256-GCM encrypted

    // Provider-specific
    publicKey?: string,        // Flutterwave, DPO
    merchantId?: string,       // Some providers
    apiUrl?: string,           // Custom or provider-specific
    apiToken?: string,         // PawaPay
    consumerKey?: string,      // Pesapal
    consumerSecretEncrypted?: string, // Pesapal
  },

  // Configuration
  environment: "test" | "live",
  isPrimary: boolean,
  isActive: boolean,

  // Webhooks
  webhookSecret?: string,      // Provider's webhook signing secret
  webhookUrl: string,

  // Metadata
  lastTestedAt?: number,
  lastSuccessfulPaymentAt?: number,
  connectionStatus: "active" | "error" | "unconfigured",
  errorMessage?: string,
}

Payment Transactions

// Table: paymentTransactions
{
  _id: Id<"paymentTransactions">,
  _creationTime: number,

  // Multi-tenant isolation
  organizationId: Id<"organizations">,
  appId: Id<"apps">,

  // Relationships
  customerId: Id<"customers">,
  subscriptionId?: Id<"subscriptions">,
  invoiceId?: Id<"invoices">,
  paymentProviderId: Id<"paymentProviders">,

  // Payment details
  amount: number,              // In smallest currency unit (e.g., cents)
  currency: string,            // ISO 4217 code
  paymentMethod: "mobile_money_mtn" | "mobile_money_airtel"
               | "card_visa" | "card_mastercard" | "bank_transfer",

  // Provider reference
  providerTransactionId?: string,   // From provider's response
  providerReference?: string,       // Transaction reference for customer
  merchantReference: string,        // Internal reference

  // Status flow: pending → initiated → processing → success/failed
  status: "pending" | "initiated" | "processing" | "success" | "failed" | "canceled" | "refunded",

  // Retry tracking
  attemptNumber: number,
  isRetry: boolean,
  failureReason?: string,
  failureCode?: string,
  nextRetryAt?: number,

  // Provider response
  providerResponse?: Record<string, any>,

  // Timestamps
  initiatedAt: number,
  completedAt?: number,
  expiresAt?: number,           // When transaction expires if not completed

  // Idempotency
  idempotencyKey?: string,
}

Webhook Logs (Incoming)

// Table: webhookLogs
{
  _id: Id<"webhookLogs">,
  _creationTime: number,

  // Multi-tenant isolation
  organizationId: Id<"organizations">,
  appId: Id<"apps">,

  // Source
  provider: "flutterwave" | "pawapay" | "pesapal" | "dpo",

  // Event details
  eventId: string,              // Provider's unique event ID
  event: string,                // e.g., "charge.completed"
  payload: Record<string, any>,

  // Processing status
  status: "received" | "processing" | "processed" | "failed" | "ignored",

  // Validation
  signatureValid: boolean,
  signatureError?: string,
  replayCheckPassed: boolean,
  idempotencyCheckPassed: boolean,

  // Matching
  paymentTransactionId?: Id<"paymentTransactions">,
  subscriptionId?: Id<"subscriptions">,

  // Timing
  receivedAt: number,
  processedAt?: number,
  processingDurationMs?: number,

  // Error tracking
  error?: string,
}

Outgoing Webhooks

// Table: outgoingWebhookDeliveries
{
  _id: Id<"outgoingWebhookDeliveries">,
  _creationTime: number,

  // Multi-tenant isolation
  organizationId: Id<"organizations">,
  appId: Id<"apps">,

  // Event details
  event: "payment.success" | "payment.failed" | "subscription.past_due" | ...,

  // Related entities
  customerId: Id<"customers">,
  paymentTransactionId?: Id<"paymentTransactions">,
  subscriptionId?: Id<"subscriptions">,

  // Payload
  payload: Record<string, any>,

  // Delivery tracking
  webhookUrl: string,
  status: "pending" | "delivered" | "failed" | "retrying",
  attemptCount: number,
  maxAttempts: number,

  // Response
  lastHttpStatus?: number,
  lastErrorMessage?: string,

  // Retry scheduling
  nextRetryAt?: number,

  // Timing
  firstAttemptAt?: number,
  lastAttemptAt?: number,
  deliveredAt?: number,
}

Security Features

Webhook Signature Verification

All incoming webhooks must be verified using HMAC-SHA256:
// Verification pseudocode
function verifyWebhookSignature(
  providedSignature: string,
  timestamp: string,
  body: string,
  webhookSecret: string
): boolean {
  const payload = `${timestamp}.${body}`;
  const expectedSignature = HMAC_SHA256(payload, webhookSecret);

  // Timing-safe comparison prevents timing attacks
  return timingSafeEqual(expectedSignature, providedSignature);
}
Security considerations:
  • Timing-safe comparison: Prevents attackers from determining signature validity through response timing
  • Timestamp validation: Reject webhooks older than 5 minutes (prevent replay attacks)
  • Signature verification: Ensures webhook originated from provider, not attacker

Credential Encryption

All stored provider credentials use AES-256-GCM encryption:
function encryptCredential(plaintext: string, appId: Id<"apps">): string {
  // Derive key from appId using SHA-256
  const key = SHA256(`${appId}:${process.env.MASTER_KEY}`);

  // Encrypt with AES-256-GCM
  const iv = randomBytes(16);
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  const encrypted =
    cipher.update(plaintext, "utf-8", "hex") + cipher.final("hex");
  const authTag = cipher.getAuthTag().toString("hex");

  // Return: iv:encrypted:authTag (format for storage)
  return `${iv}:${encrypted}:${authTag}`;
}
Security implications:
  • Per-app encryption: Each app has isolated encryption keys
  • AES-256-GCM: Authenticated encryption prevents tampering
  • No central key storage: Master key in environment variables

Race Condition Protection

Critical updates use atomic transactions:
// Transaction status update (simplified)
async function updateTransactionStatus(
  transactionId: Id<"paymentTransactions">,
  newStatus: "success" | "failed"
) {
  const transaction = await db.paymentTransactions.get(transactionId);

  // Guard against overwriting terminal states
  const terminalStates = ["success", "canceled", "refunded"];
  if (terminalStates.includes(transaction.status)) {
    throw new Error("Cannot update transaction in terminal state");
  }

  // Atomic update
  await db.paymentTransactions.patch(transactionId, {
    status: newStatus,
    completedAt: Date.now(),
  });
}

Cron Jobs Schedule

JobFrequencyTime (UTC)Purpose
Trial ExpirationsDaily2:00 AMConvert expired trials to paid
Recurring PaymentsDaily3:00 AMProcess subscription renewals
Failed Payment RetriesDaily4:00 AMRetry failed transactions
Cleanup ExpiredDaily5:00 AMMark old pending as failed
Webhook RetriesEvery 5 min-Retry failed outgoing webhooks

Next Steps