Company questionsGroundworkCRED

Machine coding · Wallets and payments

Payment-processing module with errors and tests

  • Reported once
  • SDE-2
  • Strategy
  • Chain of responsibility
  • Decorator
  • Factory
  • 1 source

One candidate write-up. Treat it as a single data point.

The prompt
Implement the key features of a payment-processing package in object-oriented code. The work is judged on the design patterns it uses, its error handling and its test coverage, and the tests must run without calling a real payment provider.

Restated in our own words, not a quote from CRED or any page. The answer below is written for this site.

Try it first.The answer is covered until you pick. Talk it through against a 60 minute clock, or just read it.

Clarify first

Pin the scope down before you name a single class.

Questions to ask first: which payment methods are needed now and which later, what validation each method needs, which failures are worth retrying and how many times, whether a retry could charge the customer twice, whether refunds are full or partial, what the tests must cover, and how long the round lasts so the scope can be sized.

The write-up names the round, a payment-processing package judged on patterns, error handling and test coverage, and says its feature list is not fully disclosed; card and UPI, the error kinds and the retry rule are assumptions here. Assumed scope: card and UPI with room for more, amounts in integer paise and INR only, validation before any gateway call (a positive amount, a supported currency, a card number that passes the check digit, a well-formed UPI handle), three error kinds (validation and declined are never retried, a timeout is retried with doubling backoff up to three attempts), an idempotency key on every request, full and partial refunds, and an injected gateway so tests use a scripted fake. Out of scope: real gateway protocols, 3-D Secure, settlement, fraud rules and persistence.

Entities and relationships

Who owns what, and who talks to whom.

  • PaymentProcessor is the entry point with process(request) and refund. It owns the payments, the idempotency map, a cache of method objects and the validator chain.
  • Request is a plain object with key, amount, currency, method and instrument (the card number or UPI handle).
  • Payment records the id, amount, status (CREATED, CAPTURED, FAILED, PARTIALLY_REFUNDED or REFUNDED), the refunded total, the gateway reference, the error code and the number of attempts.
  • PaymentMethod is the strategy interface with validate(instrument) and charge(request). CardMethod and UpiMethod implement it, and RetryingMethod implements it too while wrapping another method.
  • MethodFactory maps a type name to a builder, so methods are created and registered in one place.
  • Validator is the chain link with check, setNext and handle. AmountValidator, CurrencyValidator and InstrumentValidator are the links.
  • PaymentError is the base error with a code and a retryable flag. ValidationError, DeclinedError and TimeoutError extend it.
  • Gateway is the outside collaborator. ScriptedGateway returns outcomes from a list for tests and records the idempotency key of each call.

The design

The class map, the main flow and the patterns that earn their place.

Payment module class mapThe processor validates through a chain, finds a method by name and records the outcome on a payment.
ValidatorChainamount, currency, methodMethodFactoryregister, createPaymentProcessorprocess, refundPaymentstatus, refundedPaymentMethodCard, UpiRetryingMethodbackoff, attemptsGatewaycharge(amount, key)validatecreate by typechargechargescreates, updatesdecorates12345
  1. The processor runs the validator chain and stops at the first ValidationError.
  2. It asks the factory for the method object of that type and caches it.
  3. It calls charge on the method, which may be wrapped by the retrying decorator.
  4. The method calls the gateway, which can fail with a decline or a timeout.
  5. The payment records CAPTURED with a reference, or FAILED with the error code.

Open on the whiteboardAll 3 diagrams on one tidy board, with a step-by-step walkthrough.

Main flow. process rejects a request with no key, then returns the existing payment if the key was seen with the same amount, currency and method, and throws a ValidationError if it was seen with a different request. Otherwise it creates a CREATED payment, runs the validator chain, asks the factory for the method (cached per type) and calls charge. Success sets CAPTURED with the reference and attempt count. A PaymentError sets FAILED with its code. Anything that is not a PaymentError is a bug and is rethrown rather than hidden. The payment then stays CREATED with its key bound, so a retry sees the unresolved payment instead of charging again, and a reconciliation job has to settle it.

Chain of responsibility for validation. Each validator checks one rule, throws a ValidationError and otherwise passes the request on. A new rule is one class and one setNext. Cheap checks come first so a bad amount never reaches instrument checks or the gateway. The last link delegates to the method's own validate, which is where the card check digit and the UPI format live.

Strategy and factory. Card and UPI differ in validation and in the call they make, so the processor depends only on the PaymentMethod interface. The factory maps names to builders, so adding a wallet method is one register call and the processor does not change (the last check does this after the processor exists).

Decorator for retry. RetryingMethod implements the same interface and wraps another method. On a retryable error it asks an injected sleeper to wait 100 then 200 and tries again, up to the maximum number of attempts. A non-retryable error such as a decline escapes at once. Retry is a cross-cutting concern, so it sits outside the card and UPI classes. Every attempt passes the same idempotency key to the gateway, and the scripted gateway records it, because retrying a charge that timed out could otherwise charge twice.

Error design. One base error carries a machine-readable code and a retryable flag, and each cause is a subclass. Callers branch on the code or the flag, never on message text. process turns a PaymentError into the payment's status, code and reason, so callers and tests read data instead of catching exceptions. refund has no payment status to change when it rejects, so it throws a ValidationError.

Idempotency and refunds. Payments are indexed by key, so a repeated key returns the same payment and the gateway is not called again, including for a failed one; a new attempt needs a new key. Refunds are allowed only on CAPTURED or PARTIALLY_REFUNDED payments, and the total refunded cannot exceed the captured amount. A refund here only updates the record; calling the provider's refund endpoint with its own idempotency key is the next step, since a retried refund request would otherwise be applied twice up to the captured amount.

Tests. Everything with time or input and output is injected. The scripted gateway plays back outcomes and the sleeper records delays instead of waiting, so a test asserts calls, delays and statuses in microseconds.

The code

Runnable JavaScript, with the checks it passes.

cred-payment-module.js
class PaymentError extends Error {
  constructor(code, message, retryable) {
    super(message);
    this.code = code;
    this.retryable = retryable;
  }
}
class ValidationError extends PaymentError {
  constructor(message) {
    super("VALIDATION", message, false);
  }
}
class DeclinedError extends PaymentError {
  constructor() {
    super("DECLINED", "payment declined", false);
  }
}
class TimeoutError extends PaymentError {
  constructor() {
    super("GATEWAY_TIMEOUT", "gateway timed out", true);
  }
}

class Validator {
  setNext(next) {
    this.next = next;
    return next;
  }
  handle(request) {
    this.check(request);
    if (this.next) this.next.handle(request);
  }
}
class AmountValidator extends Validator {
  check(request) {
    if (!Number.isInteger(request.amount) || request.amount <= 0) throw new ValidationError("amount must be positive");
  }
}
class CurrencyValidator extends Validator {
  check(request) {
    if (request.currency !== "INR") throw new ValidationError("unsupported currency " + request.currency);
  }
}
class InstrumentValidator extends Validator {
  constructor(lookup) {
    super();
    this.lookup = lookup;
  }
  check(request) {
    this.lookup(request.method).validate(request.instrument);
  }
}

function luhnValid(number) {
  let sum = 0;
  [...number].reverse().forEach((char, i) => {
    let digit = Number(char);
    if (i % 2 === 1) digit = digit * 2 > 9 ? digit * 2 - 9 : digit * 2;
    sum += digit;
  });
  return number.length >= 12 && number.length <= 19 && sum % 10 === 0;
}

class CardMethod {
  constructor(gateway) {
    this.gateway = gateway;
  }
  validate(card) {
    if (!/^[0-9]+$/.test(card) || !luhnValid(card)) throw new ValidationError("card number failed the check digit");
  }
  charge(request) {
    return this.gateway.charge(request.amount, request.key);
  }
}
class UpiMethod {
  constructor(gateway) {
    this.gateway = gateway;
  }
  validate(handle) {
    if (!/^[a-z0-9._-]+@[a-z]+$/i.test(handle)) throw new ValidationError("malformed UPI handle");
  }
  charge(request) {
    return this.gateway.charge(request.amount, request.key);
  }
}

class RetryingMethod {
  constructor(inner, maxAttempts, backoff, sleeper) {
    Object.assign(this, { inner, maxAttempts, backoff, sleeper });
  }
  validate(instrument) {
    this.inner.validate(instrument);
  }
  charge(request) {
    for (let attempt = 1; ; attempt++) {
      try {
        return { ...this.inner.charge(request), attempts: attempt };
      } catch (error) {
        if (!error.retryable || attempt >= this.maxAttempts) {
          error.attempts = attempt;
          throw error;
        }
        this.sleeper.sleep(this.backoff * 2 ** (attempt - 1));
      }
    }
  }
}

class ScriptedGateway {
  constructor(script) {
    this.script = script;
    this.calls = 0;
    this.keys = [];
  }
  charge(amount, key) {
    this.calls += 1;
    this.keys.push(key);
    const outcome = this.script.shift() || "ok";
    if (outcome === "decline") throw new DeclinedError();
    if (outcome === "timeout") throw new TimeoutError();
    return { ref: "txn" + this.calls };
  }
}

class MethodFactory {
  constructor() {
    this.builders = new Map();
  }
  register(type, build) {
    this.builders.set(type, build);
  }
  create(type) {
    const build = this.builders.get(type);
    if (!build) throw new ValidationError("unsupported method " + type);
    return build();
  }
}

class PaymentProcessor {
  constructor(factory) {
    this.factory = factory;
    this.methods = new Map();
    this.payments = new Map();
    this.byKey = new Map();
    this.chain = new AmountValidator();
    this.chain.setNext(new CurrencyValidator()).setNext(new InstrumentValidator((type) => this.methodFor(type)));
  }
  methodFor(type) {
    if (!this.methods.has(type)) this.methods.set(type, this.factory.create(type));
    return this.methods.get(type);
  }
  process(request) {
    if (!request.key) throw new ValidationError("idempotency key required");
    const fingerprint = [request.amount, request.currency, request.method].join("|");
    const seen = this.byKey.get(request.key);
    if (seen) {
      if (seen.fingerprint !== fingerprint) throw new ValidationError("key reused for a different request");
      return seen.payment;
    }
    const payment = { id: "pay" + (this.payments.size + 1), amount: request.amount, status: "CREATED", refunded: 0 };
    this.payments.set(payment.id, payment);
    this.byKey.set(request.key, { fingerprint, payment });
    try {
      this.chain.handle(request);
      const result = this.methodFor(request.method).charge(request);
      Object.assign(payment, { status: "CAPTURED", ref: result.ref, attempts: result.attempts || 1 });
    } catch (error) {
      if (!(error instanceof PaymentError)) throw error;
      Object.assign(payment, { status: "FAILED", code: error.code, reason: error.message, attempts: error.attempts || 0 });
    }
    return payment;
  }
  refund(paymentId, amount) {
    const payment = this.payments.get(paymentId);
    if (!payment || !["CAPTURED", "PARTIALLY_REFUNDED"].includes(payment.status)) {
      throw new ValidationError("payment cannot be refunded");
    }
    if (!Number.isInteger(amount) || amount <= 0 || payment.refunded + amount > payment.amount) {
      throw new ValidationError("refund exceeds the captured amount");
    }
    payment.refunded += amount;
    payment.status = payment.refunded === payment.amount ? "REFUNDED" : "PARTIALLY_REFUNDED";
    return payment;
  }
}

Runs here in your browser, in a sandboxed worker. Nothing is sent anywhere.

Try your own input

Write JavaScript that calls the code above and prints with console.log. It runs after the solution.

Extend it

The change requests an interviewer throws next.

  1. How would you add a wallet method that debits an internal balance instead of calling the gateway?
  2. How would you split a payment into an authorise step and a later capture, and what new states does the payment need?
  3. How would you make the idempotency check safe when two requests with the same key arrive at the same moment?
  4. How would you add a circuit breaker so a failing gateway is not called for a while?
  5. How would you route to a backup gateway when the first one keeps timing out?

Where candidates slip

The mistakes this problem punishes.

Writing an if-else on the method type inside the processor. Catching every exception and returning one generic failure, so callers cannot tell a decline from a timeout. Retrying declines, or retrying without an idempotency key. Validating after the gateway call. Using floating point amounts. Sleeping for real in tests. Changing the payment status without guards, so a failed payment can be refunded. Letting refunds add up to more than was captured.

Have a go before you read.

Interviews reward the sketch you make cold. Clarify out loud, draw it, then compare with the answer.

Now make it stick

Where this was reported

Public pages only. Companies do not confirm these, and they rotate their questions, so treat each link as one person's account or one list's tag.

Read these chapters first