Skip to content

Recurring Payments Example

Complete example of implementing recurring payments with saved cards.

Overview

This example demonstrates:

  • Saving customer cards
  • Processing recurring payments
  • Managing subscriptions
  • Handling payment failures

Prerequisites

bash
bun add @joyida/payme

Database Schema

sql
-- Users table
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- Saved cards table
CREATE TABLE IF NOT EXISTS saved_cards (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  token TEXT UNIQUE NOT NULL,
  card_number TEXT NOT NULL,
  card_expire TEXT NOT NULL,
  verified BOOLEAN DEFAULT FALSE,
  is_default BOOLEAN DEFAULT FALSE,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

-- Subscriptions table
CREATE TABLE IF NOT EXISTS subscriptions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  plan_id TEXT NOT NULL,
  amount INTEGER NOT NULL,
  status TEXT DEFAULT 'active',
  card_token TEXT NOT NULL,
  next_billing_date DATE NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

-- Payments table
CREATE TABLE IF NOT EXISTS payments (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  subscription_id INTEGER NOT NULL,
  receipt_id TEXT NOT NULL,
  amount INTEGER NOT NULL,
  status TEXT DEFAULT 'pending',
  paid_at DATETIME,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

-- Indexes
CREATE INDEX idx_saved_cards_user_id ON saved_cards(user_id);
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_next_billing_date ON subscriptions(next_billing_date);
CREATE INDEX idx_payments_subscription_id ON payments(subscription_id);

Complete Implementation

typescript
import { PaymeSubscribe, RECEIPT_STATES, PaymeError, ValidationError } from '@joyida/payme';
import { Database } from 'bun:sqlite';

// Initialize clients
const subscribeClient = new PaymeSubscribe({
  merchantId: process.env.PAYME_MERCHANT_ID!
}, 'client');

const subscribeServer = new PaymeSubscribe({
  merchantId: process.env.PAYME_MERCHANT_ID!,
  password: process.env.PAYME_PASSWORD!
}, 'server');

const db = new Database('app.db');

// ==================== CARD MANAGEMENT ====================

/**
 * Save customer card
 */
async function saveCustomerCard(
  userId: number,
  cardNumber: string,
  cardExpiry: string
): Promise<string> {
  console.log(`💳 Saving card for user ${userId}...`);
  
  try {
    // 1. Create card token
    const created = await subscribeClient.cardsCreate({
      card: { number: cardNumber, expire: cardExpiry },
      save: true,
      account: { user_id: userId.toString() }
    });
    
    console.log('✅ Card token created:', created.card.token);
    
    // 2. Request SMS verification
    const sms = await subscribeClient.cardsGetVerifyCode({
      token: created.card.token
    });
    
    console.log(`📱 SMS sent to ${sms.phone}`);
    
    // 3. Get SMS code from user (your implementation)
    const smsCode = await promptUserForSMSCode();
    
    // 4. Verify card
    const verified = await subscribeClient.cardsVerify({
      token: created.card.token,
      code: smsCode
    });
    
    if (!verified.card.verify) {
      throw new Error('Card verification failed');
    }
    
    console.log('✅ Card verified');
    
    // 5. Save to database
    db.run(`
      INSERT INTO saved_cards 
      (user_id, token, card_number, card_expire, verified, is_default)
      VALUES (?, ?, ?, ?, ?, ?)
    `, [
      userId,
      verified.card.token,
      verified.card.number,
      verified.card.expire,
      true,
      true // Set as default card
    ]);
    
    console.log('✅ Card saved to database');
    
    return verified.card.token;
    
  } catch (error) {
    if (error instanceof PaymeError) {
      console.error('Payme error:', error.code, error.message);
    }
    throw error;
  }
}

/**
 * Get user's default card
 */
function getUserDefaultCard(userId: number) {
  return db.query(`
    SELECT * FROM saved_cards 
    WHERE user_id = ? AND verified = TRUE AND is_default = TRUE
    LIMIT 1
  `).get(userId);
}

/**
 * Get all user cards
 */
function getUserCards(userId: number) {
  return db.query(`
    SELECT * FROM saved_cards 
    WHERE user_id = ? AND verified = TRUE
    ORDER BY is_default DESC, created_at DESC
  `).all(userId);
}

/**
 * Remove saved card
 */
async function removeCard(userId: number, cardToken: string) {
  console.log(`🗑️ Removing card ${cardToken}...`);
  
  try {
    // Remove from Payme
    await subscribeServer.cardsRemove({
      token: cardToken
    });
    
    // Remove from database
    db.run(`
      DELETE FROM saved_cards 
      WHERE user_id = ? AND token = ?
    `, [userId, cardToken]);
    
    console.log('✅ Card removed');
  } catch (error) {
    console.error('Failed to remove card:', error);
    throw error;
  }
}

// ==================== SUBSCRIPTION MANAGEMENT ====================

/**
 * Create subscription
 */
function createSubscription(
  userId: number,
  planId: string,
  amount: number,
  cardToken: string
) {
  const nextBillingDate = new Date();
  nextBillingDate.setMonth(nextBillingDate.getMonth() + 1);
  
  const result = db.run(`
    INSERT INTO subscriptions 
    (user_id, plan_id, amount, status, card_token, next_billing_date)
    VALUES (?, ?, ?, ?, ?, ?)
  `, [
    userId,
    planId,
    amount,
    'active',
    cardToken,
    nextBillingDate.toISOString().split('T')[0]
  ]);
  
  return result.lastInsertRowid;
}

/**
 * Get active subscriptions due for billing
 */
function getSubscriptionsDueForBilling() {
  const today = new Date().toISOString().split('T')[0];
  
  return db.query(`
    SELECT s.*, u.email, u.name
    FROM subscriptions s
    JOIN users u ON s.user_id = u.id
    WHERE s.status = 'active' AND s.next_billing_date <= ?
  `).all(today);
}

/**
 * Update subscription billing date
 */
function updateSubscriptionBillingDate(subscriptionId: number) {
  const nextBillingDate = new Date();
  nextBillingDate.setMonth(nextBillingDate.getMonth() + 1);
  
  db.run(`
    UPDATE subscriptions 
    SET next_billing_date = ?, updated_at = CURRENT_TIMESTAMP
    WHERE id = ?
  `, [nextBillingDate.toISOString().split('T')[0], subscriptionId]);
}

/**
 * Cancel subscription
 */
function cancelSubscription(subscriptionId: number) {
  db.run(`
    UPDATE subscriptions 
    SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
    WHERE id = ?
  `, [subscriptionId]);
}

// ==================== PAYMENT PROCESSING ====================

/**
 * Process recurring payment
 */
async function processRecurringPayment(subscription: any) {
  console.log(`\n💰 Processing payment for subscription ${subscription.id}...`);
  console.log(`   User: ${subscription.name} (${subscription.email})`);
  console.log(`   Amount: ${subscription.amount} tiyin`);
  
  try {
    // 1. Check card status
    console.log('🔍 Checking card status...');
    const cardCheck = await subscribeServer.cardsCheck({
      token: subscription.card_token
    });
    
    if (!cardCheck.card.verify) {
      throw new Error('Card not verified');
    }
    
    if (!cardCheck.card.recurrent) {
      throw new Error('Card does not support recurring payments');
    }
    
    console.log('✅ Card is valid');
    
    // 2. Create receipt
    console.log('📝 Creating receipt...');
    const receipt = await subscribeServer.receiptsCreate({
      amount: subscription.amount,
      account: {
        subscription_id: subscription.id.toString(),
        user_id: subscription.user_id.toString()
      },
      description: `Subscription payment - ${subscription.plan_id}`
    });
    
    console.log('✅ Receipt created:', receipt.receipt._id);
    
    // 3. Save payment record
    const paymentId = db.run(`
      INSERT INTO payments 
      (subscription_id, receipt_id, amount, status)
      VALUES (?, ?, ?, ?)
    `, [
      subscription.id,
      receipt.receipt._id,
      subscription.amount,
      'pending'
    ]).lastInsertRowid;
    
    // 4. Pay with card token
    console.log('💳 Processing payment...');
    const payment = await subscribeServer.receiptsPay({
      id: receipt.receipt._id,
      token: subscription.card_token
    });
    
    if (payment.receipt.state === RECEIPT_STATES.PAID) {
      console.log('✅ Payment successful!');
      
      // 5. Update payment record
      db.run(`
        UPDATE payments 
        SET status = 'paid', paid_at = CURRENT_TIMESTAMP
        WHERE id = ?
      `, [paymentId]);
      
      // 6. Update next billing date
      updateSubscriptionBillingDate(subscription.id);
      
      // 7. Send confirmation email (your implementation)
      await sendPaymentConfirmation(subscription.email, {
        amount: subscription.amount,
        receiptId: receipt.receipt._id
      });
      
      return {
        success: true,
        paymentId,
        receiptId: receipt.receipt._id
      };
    } else {
      throw new Error('Payment failed');
    }
    
  } catch (error) {
    console.error('❌ Payment failed:', error.message);
    
    // Update payment status
    db.run(`
      UPDATE payments 
      SET status = 'failed'
      WHERE subscription_id = ? AND status = 'pending'
    `, [subscription.id]);
    
    // Send failure notification
    await sendPaymentFailureNotification(subscription.email, {
      reason: error.message
    });
    
    // Suspend subscription after 3 failed attempts
    const failedPayments = db.query(`
      SELECT COUNT(*) as count FROM payments 
      WHERE subscription_id = ? AND status = 'failed'
    `).get(subscription.id);
    
    if (failedPayments.count >= 3) {
      console.log('⚠️ Suspending subscription after 3 failed attempts');
      db.run(`
        UPDATE subscriptions 
        SET status = 'suspended', updated_at = CURRENT_TIMESTAMP
        WHERE id = ?
      `, [subscription.id]);
    }
    
    throw error;
  }
}

/**
 * Process all due subscriptions
 */
async function processDueSubscriptions() {
  console.log('🔄 Processing due subscriptions...\n');
  
  const subscriptions = getSubscriptionsDueForBilling();
  console.log(`Found ${subscriptions.length} subscriptions due for billing`);
  
  const results = {
    successful: 0,
    failed: 0,
    errors: []
  };
  
  for (const subscription of subscriptions) {
    try {
      await processRecurringPayment(subscription);
      results.successful++;
    } catch (error) {
      results.failed++;
      results.errors.push({
        subscriptionId: subscription.id,
        error: error.message
      });
    }
  }
  
  console.log('\n📊 Processing complete:');
  console.log(`   ✅ Successful: ${results.successful}`);
  console.log(`   ❌ Failed: ${results.failed}`);
  
  return results;
}

// ==================== HELPER FUNCTIONS ====================

function promptUserForSMSCode(): Promise<string> {
  // Your implementation to get SMS code from user
  // For example, via web form, mobile app, etc.
  return Promise.resolve('666666'); // Test code
}

async function sendPaymentConfirmation(email: string, data: any) {
  // Your implementation to send email
  console.log(`📧 Sending confirmation to ${email}`);
}

async function sendPaymentFailureNotification(email: string, data: any) {
  // Your implementation to send email
  console.log(`📧 Sending failure notification to ${email}`);
}

// ==================== EXAMPLE USAGE ====================

async function main() {
  // 1. Create test user
  const userId = db.run(`
    INSERT INTO users (email, name) VALUES (?, ?)
  `, ['user@example.com', 'Test User']).lastInsertRowid;
  
  console.log(`Created user: ${userId}`);
  
  // 2. Save card
  const cardToken = await saveCustomerCard(
    userId,
    '8600069195406311',
    '0399'
  );
  
  // 3. Create subscription
  const subscriptionId = createSubscription(
    userId,
    'premium-monthly',
    500000, // 5000 UZS
    cardToken
  );
  
  console.log(`\n✅ Subscription created: ${subscriptionId}`);
  
  // 4. Process payment immediately (for testing)
  const subscription = db.query(`
    SELECT s.*, u.email, u.name
    FROM subscriptions s
    JOIN users u ON s.user_id = u.id
    WHERE s.id = ?
  `).get(subscriptionId);
  
  await processRecurringPayment(subscription);
  
  // 5. Schedule daily billing job
  console.log('\n⏰ Setting up daily billing job...');
  setInterval(async () => {
    await processDueSubscriptions();
  }, 24 * 60 * 60 * 1000); // Run daily
}

// Run example
main().catch(console.error);

Cron Job for Billing

typescript
// billing-cron.ts
import { processDueSubscriptions } from './recurring-payments';

async function runBillingJob() {
  console.log(`\n🕐 Running billing job at ${new Date().toISOString()}`);
  
  try {
    const results = await processDueSubscriptions();
    
    // Log results
    console.log('Billing job completed:', results);
    
    // Send admin notification if there were failures
    if (results.failed > 0) {
      await sendAdminNotification({
        subject: 'Billing Job - Failed Payments',
        failed: results.failed,
        errors: results.errors
      });
    }
  } catch (error) {
    console.error('Billing job failed:', error);
    await sendAdminNotification({
      subject: 'Billing Job - Critical Error',
      error: error.message
    });
  }
}

// Run immediately
runBillingJob();

// Schedule to run daily at 2 AM
const schedule = require('node-cron');
schedule.schedule('0 2 * * *', runBillingJob);

Best Practices

✅ Do's

  1. Verify cards before saving
  2. Check card status before payment
  3. Handle payment failures gracefully
  4. Send payment confirmations
  5. Suspend after multiple failures
  6. Keep audit trail
  7. Run billing jobs at off-peak hours

❌ Don'ts

  1. Don't charge without verification
  2. Don't ignore failed payments
  3. Don't retry immediately
  4. Don't lose payment records
  5. Don't forget to notify users

Next Steps

Released under MIT License.