Skip to content

Card payment endpoint

API / backend · Difficulty 5/5 · TypeScript · 46 linesOrigin: Fictional

Task

What the AI was asked to do

Write the handler for the POST /charges endpoint: charge amountCents to the account's saved card. On a timeout the client sends the same request again with the same Idempotency-Key; a repeated request with the same key gets the first answer back without charging the card again. The client chooses the key. accountId comes from the session, and the router has already checked the key (not empty, at most 255 characters). Answer 400 if the amount is not a positive integer, 402 if the card is declined and 201 on success. The service runs as a single Node.js process; answers is shared by all requests.

Handoff note

What the AI wrote when it handed over the code:

“Retries are safe: every answer is stored under its idempotency key, and when the same key comes back the stored answer is returned without charging the card again. Declined payments are stored too, and the amount is validated.”

The code the AI wrote

TypeScript46 lines

Mark the lines you think are wrong; give each mark a severity and a one-sentence reason.

Click or tap a line. With a keyboard, move to the line and press Enter.

0 of 5 marks
How to pick a severity
  • CriticalData loss, a security hole or lost money
  • MajorThe user gets a wrong result in a case the task asks for
  • MinorA rare edge case outside the task, or readability

In the Testing topic, severity is set by the effect of the broken implementation the test doesn't catch.

Your reason isn't graded; you'll compare it with a sample answer.

export interface ChargeRequest {
  accountId: string;
  idempotencyKey: string;
  amountCents: number;
}
6
export interface ChargeAnswer {
  status: 201 | 400 | 402;
  body: { chargeId?: string; error?: string };
}
11
export type CardResult =
  { ok: true; chargeId: string } | { ok: false; reason: string };
14
export interface Dependencies {
  /** Charges the account's saved card. Never throws. */
  chargeCard(accountId: string, amountCents: number): Promise<CardResult>;
  /** Shared by all requests; keeps each answer for 24 hours. */
  answers: {
    get(key: string): Promise<ChargeAnswer | undefined>;
    set(key: string, answer: ChargeAnswer): Promise<void>;
  };
}
24
export function createChargeHandler({ chargeCard, answers }: Dependencies) {
  return async function handleCharge(
    request: ChargeRequest,
  ): Promise<ChargeAnswer> {
    const { accountId, idempotencyKey, amountCents } = request;
    if (!Number.isInteger(amountCents) || amountCents <= 0) {
      return { status: 400, body: { error: "invalid_amount" } };
    }
    const key = `charge:${idempotencyKey}`;
34
    const saved = await answers.get(key);
    if (saved !== undefined) {
      return saved;
    }
    const result = await chargeCard(accountId, amountCents);
    const answer: ChargeAnswer = result.ok
      ? { status: 201, body: { chargeId: result.chargeId } }
      : { status: 402, body: { error: result.reason } };
    await answers.set(key, answer);
    return answer;
  };
}

The test that proves it

The right option is the test that fails on this code, which means it exposes the bug.

Test options
Mark at least one line to submit.

Submitting sends seven pieces of information to a counter; your reason text stays on your device. Data