• donation-payments
  • ameriabank-vpos

Teach For Armenia donation platform, in production

Published on
Talk about a full-time role
Duration
January 23 to April 7, 2026
Role
Full-stack developer, solo build
Technology
Fastify, PostgreSQL, Docker, AmeriaBank vPOS 3.1

The donor form. Amounts and copy come from the database, so staff change them without a deploy.

Teach For Armenia takes donations by bank card. I built the platform behind that page: the donor form, the payment integration with AmeriaBank, and the admin dashboard staff use to review, refund, reverse and export donations. It launched in 2026, I built it solo, and it runs in production.

Goal

The client wanted donations to arrive through an Armenian bank, in the donor's language, without a developer in the loop for everyday work. Staff needed to change the suggested amounts and the page copy themselves, rotate the bank credentials themselves, and pull a donation report when a stakeholder asked for one. The setup they had before could not do those things.

Constraints

One gateway, and it sets the rules. Payments go through AmeriaBank's vPOS 3.1, the bank's card payment service. Its callback behavior decides what the rest of the system has to tolerate.

Real money. A donation written to the wrong status means a donor was charged and the record disagrees with the bank. Recovering from that is manual work for someone at the nonprofit.

Two languages, and the donor leaves the site halfway. The card details are entered on the bank's page, so the donor's language choice has to survive a round trip through a system I do not control.

Credentials live in the database. Staff rotate the bank credentials from the admin interface, which means those credentials sit in PostgreSQL, where they have to be unreadable to anyone reading the table.

One developer, one window. I was the only person on it. The platform work ran from January 23 to April 7, 2026, inside the contract I already had with Teach For Armenia for their web work.

What I built

Multiple callbacks for one payment, one final answer

The gateway can send more than one callback for a single payment, and a network failure can land anywhere in the sequence. Left alone, a late callback can overwrite a donation that already succeeded.

I gave the donation record a state machine: a fixed set of statuses and a fixed set of moves between them. Once a donation reaches a final status (approved, declined, error, refunded, reversed), a later callback cannot move it back.

const DONATION_STATUS = {
  PENDING: "pending",
  APPROVED: "approved",
  DECLINED: "declined",
  ERROR: "error",
  REFUNDED: "refunded",
  REVERSED: "reversed",
};

function isValidTransition(currentStatus, nextStatus) {
  if (currentStatus === DONATION_STATUS.PENDING) {
    return [
      DONATION_STATUS.APPROVED,
      DONATION_STATUS.DECLINED,
      DONATION_STATUS.ERROR,
      DONATION_STATUS.REVERSED,
    ].includes(nextStatus);
  }

  if (currentStatus === DONATION_STATUS.APPROVED) {
    return [DONATION_STATUS.REFUNDED, DONATION_STATUS.REVERSED].includes(
      nextStatus,
    );
  }

  return false;
}

An invalid move is logged with the order id and the two statuses, and the record is returned unchanged. The bank's retries stop being a source of corruption and become noise in a log.

Bank credentials staff can rotate and nobody can read

The bank credentials are stored encrypted with AES-256-GCM, an encryption mode that also detects tampering: decryption fails if a stored value was edited. Each field gets its own random initialization vector, the key comes from an environment variable the database has no access to, and the admin interface is write-only for these fields. An administrator can replace a credential and never see the one in place.

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;  // 96 bits, the NIST recommendation for GCM
const TAG_LENGTH = 16; // 128-bit authentication tag

function encryptSecret(plaintext) {
  const key = getEncryptionKey();
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);

  let ciphertext = cipher.update(plaintext, 'utf8');
  ciphertext = Buffer.concat([ciphertext, cipher.final()]);

  return { ciphertext, iv, tag: cipher.getAuthTag() };
}

Every comparison in the authentication paths runs through crypto.timingSafeEqual, so the time a check takes gives nothing away about the value being checked.

A language choice that survives the bank redirect

The donor picks English or Armenian on our page, then gets handed to the bank. When the bank hands them back, the site has to know which language they were reading and which campaign they came from. I put that state in a small signed token that travels with the redirect. The signature is HMAC-SHA256, a keyed fingerprint: change any part of the token and the fingerprint stops matching, so a donor cannot edit their way into someone else's campaign.

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));

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

Interface text ships with the code. The headline, the suggested amounts and the campaign copy come from the database, one row per language, so a copy change is an admin action rather than a deploy.

The admin work: refunds, reversals, exports

A refund applies to a payment the bank has already deposited; a reversal applies to one that is still held. Both are available from the donation detail page. Before either runs, the server asks the gateway for the payment's current state and refuses if that state disagrees with what the dashboard shows. The refund amount, the reason, the timestamp and the administrator who ran it are written to the donation record.

The donations list has filters, a date range, search and status badges. Exports are CSV with a column picker, and the file carries a UTF-8 byte order mark so Armenian names open correctly in Excel rather than as broken characters.

Result

The platform launched in 2026 and is in production. I built all of it: the donor form, the Fastify API, the database schema, the payment integration and the admin dashboard.

I have no sourced figure for donation volume, payment success rate or uptime, so this case states none.

ItemWhat it is
StatusIn production, launched in 2026
BuildSolo, under contract from January 23 to April 7, 2026
PaymentsAmeriaBank vPOS 3.1: initialization, callbacks, refunds, reversals, recurring card binding
Secrets at restAES-256-GCM
LanguagesEnglish and Armenian
StackFastify, PostgreSQL, Docker

Stack

Fastify and PostgreSQL on the server, Docker for deployment behind an Nginx reverse proxy with SSL termination, a Next.js front end in TypeScript styled with Tailwind CSS, and AmeriaBank vPOS 3.1 for the payments.