next-jsReact Developmenttypescriptfastifypostgresqlpayment-integrationencryptionsecuritydockertailwind-css

Teach For Armenia Donation Platform (Next.js + Fastify)

Samvel Avagyan
Samvel Avagyan
Published on
11 min read
Book a Working Session →
TFA Donation Platform homepage with emotional hero banner and donation form

Duration
3 Months
Role
Full-Stack Developer
Atmosphere
Solo Development
Technology
Next.js, Fastify, PostgreSQL
TFA Donation Platform homepage with emotional hero banner and donation form
TFA Donation Platform homepage with emotional hero banner and donation form
Donations admin list with filters, date range, search, status badges, and CSV download
Donations admin list with filters, date range, search, status badges, and CSV download
Donation form scrolled view showing preset amounts, custom amount, donor details, trust badges, and primary CTA
Donation form scrolled view showing preset amounts, custom amount, donor details, trust badges, and primary CTA
+4

This project represents a complete end-to-end donation platform built for Teach For Armenia, a nonprofit organization focused on educational equity in Armenia. The platform processes real financial transactions through AmeriaBank's vPOS 3.1 payment gateway, supports both one-time and recurring donations, and provides a comprehensive admin interface for managing donations, content, and system configuration—all while maintaining bilingual support for English and Armenian users.

Project Overview

Teach For Armenia needed a modern, secure donation platform that could handle bank card payments through Armenia's banking infrastructure. The existing solution was outdated and lacked features critical for donor engagement and administrative efficiency.

I was responsible for designing and implementing the entire system from scratch, including:

  • Payment Gateway Integration: Full implementation of AmeriaBank vPOS 3.1 API including payment initialization, callbacks, refunds, reversals, and recurring card binding
  • Frontend Application: A responsive, mobile-first donation interface with real-time validation and bilingual support
  • Backend API: A robust Fastify-based REST API with comprehensive error handling and security measures
  • Admin Dashboard: A full-featured management interface for donations, content, and system settings
  • Infrastructure: Docker-based deployment with Nginx reverse proxy and SSL termination

Technologies and Tools Used

Next.js 14 with React 18 powers the frontend, chosen for its excellent server-side rendering capabilities, built-in routing, and seamless TypeScript integration. The app-router architecture enables clean code organization with locale-based routing for internationalization:

// Frontend donation page with dynamic config loading
export function DonatePage({ locale }: DonatePageProps) {
  const [config, setConfig] = useState<PublicContentConfig | null>(null);
  const t = getTranslation(locale);
  
  // Load CMS content on mount with locale awareness
  useEffect(() => {
    async function loadConfig() {
      const publicConfig = await fetchPublicContent(locale);
      setConfig(publicConfig);
    }
    loadConfig();
  }, [locale]);

  // Validate donor fields with i18n error messages
  const validateDonorFields = (): boolean => {
    const errors: typeof donorErrors = {};
    if (!firstName.trim()) {
      errors.firstName = donorLabels.required;
    }
    if (!EMAIL_REGEX.test(email.trim())) {
      errors.email = t.invalidEmail;
    }
    setDonorErrors(errors);
    return Object.keys(errors).length === 0;
  };
}

Fastify 4 serves as the backend framework, selected for its exceptional performance, built-in validation via JSON Schema, and plugin architecture. The server bootstraps with environment-aware configuration and runtime payment settings:

// Backend server initialization with payment config caching
async function start() {
  const app = fastify({
    logger: { level: "info", transport: { target: "pino-pretty" } },
    trustProxy: config.app.trustProxy,
  });

  // Load and cache runtime payment configuration
  // Combines ENV secrets with DB settings for payment flow
  const paymentConfig = await paymentSettingsService.getRuntimePaymentConfig(config);
  app.decorate("paymentConfig", paymentConfig);
  
  // Hot-reload capability for config changes without restart
  app.decorate("paymentConfigCache", {
    async reload() {
      const newConfig = await paymentSettingsService.getRuntimePaymentConfig(config);
      app.paymentConfig = newConfig;
      app.log.info('Payment config reloaded');
    },
  });
}

PostgreSQL 16 handles data persistence with a carefully designed schema supporting the donation lifecycle, recurring subscriptions, and administrative operations. Database migrations are versioned and applied sequentially:

-- Donation lifecycle with state machine support
CREATE TABLE IF NOT EXISTS donations (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT NOT NULL UNIQUE,
    payment_id VARCHAR(100),
    amount NUMERIC(12,2) NOT NULL CHECK (amount > 0),
    currency VARCHAR(3) NOT NULL DEFAULT 'AMD',
    status VARCHAR(32) NOT NULL DEFAULT 'pending',
    -- Donor information
    first_name VARCHAR(255),
    last_name VARCHAR(255),
    client_email VARCHAR(255),
    -- Recurring donation support
    recurrence_type VARCHAR(20) DEFAULT 'once',
    cardholder_id VARCHAR(100),
    binding_id VARCHAR(100),
    -- Refund tracking
    refunded_amount NUMERIC(12,2),
    refunded_at TIMESTAMPTZ,
    refund_reason VARCHAR(500)
);

-- Auto-update timestamps via trigger
CREATE TRIGGER trigger_update_donations_updated_at
    BEFORE UPDATE ON donations
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

Tailwind CSS provides the styling foundation with a custom design system featuring Armenian-inspired color palettes and mobile-first responsive breakpoints.

Development Process and Challenges

Challenge 1: Payment State Machine Complexity

The AmeriaBank vPOS system can send multiple callbacks for a single payment, and network failures can occur at any point. Without proper handling, this led to donations being incorrectly marked as failed after initially succeeding.

Solution: I implemented a robust state machine with terminal state protection. Once a donation reaches a terminal state (APPROVED, DECLINED, ERROR, REFUNDED, REVERSED), it cannot be downgraded by subsequent callbacks:

// Donation status constants with state machine logic
const DONATION_STATUS = {
  PENDING: "pending",
  APPROVED: "approved",
  DECLINED: "declined",
  ERROR: "error",
  REFUNDED: "refunded",
  REVERSED: "reversed",
};

function isValidTransition(currentStatus, nextStatus) {
  // PENDING can transition to any terminal state
  if (currentStatus === DONATION_STATUS.PENDING) {
    return [
      DONATION_STATUS.APPROVED,
      DONATION_STATUS.DECLINED,
      DONATION_STATUS.ERROR,
      DONATION_STATUS.REVERSED,
    ].includes(nextStatus);
  }

  // APPROVED can only transition to REFUNDED or REVERSED
  if (currentStatus === DONATION_STATUS.APPROVED) {
    return [
      DONATION_STATUS.REFUNDED,
      DONATION_STATUS.REVERSED,
    ].includes(nextStatus);
  }

  // All other terminal states cannot transition
  return false;
}

// Safe status update that respects state machine rules
async function updateDonationStatusSafe(donation, nextStatus, extraFields, logger) {
  if (!isValidTransition(donation.status, nextStatus)) {
    logger.warn("Attempted invalid status transition", {
      orderId: donation.order_id,
      currentStatus: donation.status,
      nextStatus,
      reason: isTerminalStatus(donation.status)
        ? "Terminal state cannot be changed"
        : "Invalid transition",
    });
    return donation; // Return unchanged
  }
  // Proceed with update...
}

Challenge 2: Multi-Layer Encryption Architecture

The platform handles multiple categories of sensitive data requiring different encryption approaches: payment gateway credentials stored in the database, admin user passwords, and session tokens that travel through external systems. Each required a tailored cryptographic solution.

Solution: I implemented a comprehensive encryption strategy with three distinct layers:

Layer 1: AES-256-GCM for Payment Secrets at Rest

Bank credentials (Client ID, Username, Password) can be stored in the database with authenticated encryption, allowing admins to update credentials without server access:

// AES-256-GCM encryption for payment credentials
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;  // 96 bits for GCM (NIST recommendation)
const TAG_LENGTH = 16; // 128 bits authentication tag

function encryptSecret(plaintext) {
  const key = getEncryptionKey(); // 32-byte key from PAYMENT_SECRETS_KEY env
  const iv = crypto.randomBytes(IV_LENGTH); // Unique IV per encryption
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
  
  let ciphertext = cipher.update(plaintext, 'utf8');
  ciphertext = Buffer.concat([ciphertext, cipher.final()]);
  
  const tag = cipher.getAuthTag(); // Authentication tag prevents tampering
  
  return { ciphertext, iv, tag };
}

function decryptSecret(ciphertext, iv, tag) {
  const key = getEncryptionKey();
  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(tag); // Verify integrity before decryption
  
  let plaintext = decipher.update(ciphertext, undefined, 'utf8');
  plaintext += decipher.final('utf8');
  
  return plaintext;
}

Layer 2: PBKDF2 for Admin Password Hashing

Admin passwords use key derivation with 100,000 iterations, making brute-force attacks computationally infeasible:

// PBKDF2 password hashing with timing-safe verification
function hashPassword(password) {
  const salt = crypto.randomBytes(16).toString('hex');
  const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
  return `$pbkdf2$${salt}$${hash}`;
}

function verifyPassword(password, passwordHash) {
  const parts = passwordHash.split('$');
  const salt = parts[2];
  const storedHash = parts[3];

  const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');

  // Constant-time comparison prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(storedHash, 'hex'),
    Buffer.from(hash, 'hex')
  );
}

Layer 3: HMAC-SHA256 for Signed Tokens

Session tokens that travel through external payment redirects use HMAC signatures to prevent tampering:

// HMAC-signed opaque tokens for payment flow
function buildOpaque({ campaign, locale, nonce, signingSecret }) {
  const payload = { c: campaign, l: locale || 'en', n: nonce, v: 1 };

  const hmac = crypto.createHmac("sha256", signingSecret);
  hmac.update(JSON.stringify(payload));
  const sig = hmac.digest("hex");

  return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
}

function verifyOpaque(opaque, signingSecret) {
  const token = JSON.parse(Buffer.from(opaque, "base64url").toString("utf-8"));
  
  const hmac = crypto.createHmac("sha256", signingSecret);
  hmac.update(JSON.stringify(token.payload));
  const expectedSig = hmac.digest("hex");

  // Timing-safe comparison prevents timing attacks
  if (!crypto.timingSafeEqual(
    Buffer.from(expectedSig, "hex"),
    Buffer.from(token.sig, "hex")
  )) {
    return null;
  }
  return token.payload;
}

Challenge 3: Bilingual Support with CMS-Managed Content

The platform needed to support both English and Armenian while allowing administrators to update content without code deployments.

Solution: I built a hybrid i18n system combining static translations for UI elements with database-stored content for CMS-managed sections. The locale is persisted in the opaque token to maintain language preference across the payment redirect:

// Translation system with locale-aware content
const translations: Record<Locale, Translation> = {
  en: {
    continueToPayment: "Continue to Payment",
    redirectingToPayment: "Redirecting to secure payment...",
    invalidEmail: "Please enter a valid email address",
    // ... 60+ translation keys
  },
  hy: {
    continueToPayment: "Շdelays դdelays դdelays վdelays delays",
    redirectingToPayment: "Վdelays delays delays delays delays...",
    invalidEmail: "Խdelays delays delays delays delays delays",
    // ... Armenian translations
  },
};

export function getTranslation(locale: Locale = 'en'): Translation {
  return translations[locale] || translations.en;
}

Key Features Implemented

Enterprise-Grade Security Architecture

The platform implements defense-in-depth security with multiple encryption layers protecting sensitive data:

  • Credentials Encryption: AES-256-GCM authenticated encryption for payment gateway credentials stored in PostgreSQL, with unique IVs per field and authentication tags preventing tampering
  • Password Security: PBKDF2-SHA512 with 100,000 iterations and random 128-bit salts for admin authentication
  • Token Integrity: HMAC-SHA256 signed tokens for cross-domain session persistence through payment redirects
  • Timing Attack Prevention: All cryptographic comparisons use crypto.timingSafeEqual() to prevent side-channel attacks
  • Write-Only Admin UI: Payment credentials can be updated but never displayed back, even to administrators

Donation Flow with Real-Time Validation

The donation form provides immediate feedback as users interact, with preset amounts loaded from the CMS and custom amount validation against configured limits:

// DonationAmountSelector with visual feedback
export function DonationAmountSelector({
  amounts,
  selected,
  onSelect,
  disabled,
}: DonationAmountSelectorProps) {
  return (
    <div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5 sm:gap-3">
      {amounts.map((option) => {
        const isSelected = selected === option.value;
        return (
          <button
            key={option.value}
            onClick={() => !disabled && onSelect(option.value)}
            className={`
              relative group p-3 sm:p-4 rounded-lg border-2 transition-all
              ${isSelected
                ? "border-primary bg-primary-50 shadow-medium"
                : "border-neutral-200 bg-white hover:border-primary-300"}
            `}
          >
            <div className="text-xl sm:text-2xl font-bold">
              {formatCurrency(option.value)}
            </div>
            {isSelected && (
              <div className="absolute -top-1 -right-1 w-6 h-6 bg-primary rounded-full">
                <CheckIcon className="w-4 h-4 text-white" />
              </div>
            )}
          </button>
        );
      })}
    </div>
  );
}

Admin Dashboard with CSV Export

The admin panel provides comprehensive donation management including filtering, sorting, pagination, and customizable CSV exports:

// CSV Generator with field selection and Excel compatibility
const UTF8_BOM = '\uFEFF';

const EXPORT_FIELDS = {
  order_id: {
    code: 'order_id',
    header: 'Order ID',
    group: 'core',
    field: (d) => d.order_id,
  },
  created_at: {
    code: 'created_at',
    header: 'Created Date',
    group: 'core',
    field: (d) => formatDateForCSV(d.created_at),
  },
  status: {
    code: 'status',
    header: 'Status',
    group: 'core',
    field: (d) => formatStatus(d.status), // "reversed" → "Cancelled"
  },
  // ... 17 more exportable fields
};

function generateDonationsCSV(donations, selectedFields = null) {
  const fieldCodes = selectedFields?.length > 0
    ? selectedFields.filter(code => EXPORT_FIELDS[code])
    : DEFAULT_FIELD_ORDER;

  const columns = fieldCodes.map(code => EXPORT_FIELDS[code]);
  const headerRow = columns.map(col => escapeCSVField(col.header)).join(',');
  const dataRows = donations.map(donation =>
    columns.map(col => escapeCSVField(col.field(donation))).join(',')
  );

  return UTF8_BOM + headerRow + '\n' + dataRows.join('\n');
}

Refund and Reversal Operations

Administrators can process refunds (for deposited payments) or reversals (for pre-authorized payments) directly from the dashboard with full audit trails:

// Refund processing with comprehensive validation
async function refundDonation(orderId, refundData, config, logger) {
  const donation = await findByOrderId(orderId);
  
  // Validate donation state
  if (!canRefund(donation.status)) {
    return { success: false, error: 'invalid_status' };
  }

  // Verify payment status with Ameria before attempting refund
  const paymentDetails = await ameria.getPaymentDetails(config, donation.payment_id);
  const orderStatus = parseInt(paymentDetails.OrderStatus, 10);

  // OrderStatus: 2=Deposited (refundable), 4=Already refunded
  if (orderStatus !== 2) {
    return { success: false, error: 'invalid_payment_state' };
  }

  // Execute refund via Ameria API
  const ameriaResponse = await ameria.refundPayment(config, {
    PaymentID: donation.payment_id,
    Amount: refundAmount,
    Description: reason,
  });

  // Update donation record with audit information
  await db.query(`
    UPDATE donations SET 
      status = 'refunded',
      refunded_amount = $1,
      refunded_at = NOW(),
      refund_reason = $2,
      refunded_by_admin_user_id = $3
    WHERE order_id = $4
  `, [refundAmount, reason, adminUserId, orderId]);
}

Results and Impact

MetricValue
Total Donations Processed500+ in first month
Payment Success Rate97.3%
Average Processing Time< 2 seconds
Mobile Traffic68% of donations
Admin Time Saved~10 hours/week
Uptime Since Launch99.9%
Languages Supported2 (English, Armenian)
Security StandardAES-256-GCM + PBKDF2
Encryption Coverage100% of secrets at rest

The platform has been processing real donations since November 2025 with zero critical incidents. The admin dashboard eliminated manual spreadsheet tracking, and the CSV export feature streamlined monthly reporting to stakeholders.

Personal Experience and Conclusion

Building this donation platform was a rewarding challenge that pushed me to deeply understand payment gateway integration, state machine design, and the security considerations inherent in financial applications. The project reinforced the importance of:

  • Defensive Programming: Never trust external APIs to behave consistently. The state machine approach prevented countless edge cases from corrupting data.
  • User Experience Matters: A donation platform needs to build trust. The mobile-first design and visual feedback create confidence during the sensitive act of entering payment details.
  • Operational Excellence: Features like CSV export and the admin dashboard aren't glamorous, but they're what make a platform truly production-ready.

The most satisfying moment was seeing the first real donation come through in production—knowing that the careful attention to error handling, security, and user experience had paid off in a system that people trust with their money to support education in Armenia.

This project demonstrates my ability to deliver complex, full-stack applications from concept to production, integrating with external APIs, implementing robust security measures, and building intuitive user interfaces—all while maintaining clean, maintainable code.