payment-docs

abdiff experiment report / baseline commit be2d433adad6

Overview

HypothesisWith docs/ present, implementations follow policy that only the docs state: downgrades are deferred to the next cycle with no negative amount, legacy plans prorate over 30 days, retries reuse the same Idempotency-Key and never retry declined codes.
Run time2026-08-25T23:01:23+09:00 -> 2026-08-25T23:10:52+09:00
Claude Code2.1.245
Model (observed)claude-sonnet-5
N2 per condition per test case
Test cases3 (2 target, 1 control)
Runs12/12 completed / 0 with timeout, error or permission denials
Total cost$1.82
CautionN=2. Run-to-run variance can't be told apart from the difference between conditions, so don't read "No difference" as "no effect". Rerun with N=3 or more before drawing a conclusion.

TC-01 changePlantarget

Prompt
Implement changePlan(account, newPlanId, at) in src/proration.js. It returns what to charge this cycle when an account changes plan, and add tests. account looks like { planId, anchorDay }.
Expected effect (written before the run)

Variant: Read docs/billing-cycle-and-proration.md before the first edit; downgrade returns amount 0 and takes effect next cycle (no negative or refund amount); legacy plans use a 30-day denominator. Baseline expected: 0/2 on each.

run 1baseline 9 turns / 50s / 8 tool calls / 2 files changedvariant 14 turns / 80s / 13 tool calls / 2 files changed
baseline9 turns / 50s / 8 tool calls / 2 files changed
Final response
All 21 tests pass.

Implemented `changePlan(account, newPlanId, at)` in `src/proration.js`: it credits the unused prorated portion of the old plan and charges the prorated portion of the new plan for the days remaining in the current billing cycle, returning the net amount (negative means a credit is owed). Added 6 tests in `test/proration.test.js` covering upgrades, downgrades, same-plan no-ops, cycle-start edge case, legacy plan ids, and unknown plan errors.
Trace / 8 tool calls
  1. Bashcat /src/proration.js 2>/dev/null; echo "---"; ls /src/ 2>/dev/null; echo "---"; ls /
    result / 1,249 chars
    import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'
    
    // Billing cycle. anchorDay (1..28) is the day each monthly cycle starts.
    // Returns the [start, end) of the cycle containing `at`. `end` is the next cycle's start date.
    export function cycleBounds(anchorDay, at) {
      if (!Number.isInteger(anchorDay) || anchorDay < 1 || anchorDay > 28) {
        throw new RangeError(`anchorDay must be 1..28: ${anchorDay}`)
      }
      const d = parseDate(at)
      let start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), anchorDay))
      if (start > d) start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, anchorDay))
      const startStr = formatDate(start)
      return { start: startStr, end: addMonths(startStr, 1) }
    }
    
    // Prorates `amount` by the days remaining from `at` to the end of the cycle. The day of `at` counts.
    export function prorate(amount, cycle, at) {
      const total = daysBetween(cycle.start, cycle.end)
      const remaining = daysBetween(at, cycle.end)
      if (remaining < 0 || remaining > total) throw new RangeError(`${at} is outside the cycle`)
      return Math.floor((amount * remaining) / total)
    }
    ---
    coupons.js
    dates.js
    format.js
    invoice.js
    payment.js
    plans.js
    proration.js
    ---
    CLAUDE.md
    package.json
    README.md
    src
    test
  2. Bashecho "=== dates.js ===" && cat src/dates.js && echo "=== plans.js ===" && cat src/plans.js && echo "=== invoice.js ===" && cat src/invoice.js && echo "=== test dir ===" && ls test && echo "=== existing proration test ===" && cat test/proration.test.js 2>/dev/null
    result / 3,189 chars
    === dates.js ===
    // Dates are handled only as 'YYYY-MM-DD' strings. Time of day and time zones are outside this module.
    
    export function parseDate(s) {
      const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
      if (!m) throw new TypeError(`invalid date: ${s}`)
      return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))
    }
    
    export function formatDate(d) {
      return d.toISOString().slice(0, 10)
    }
    
    export function addDays(s, n) {
      const d = parseDate(s)
      d.setUTCDate(d.getUTCDate() + n)
      return formatDate(d)
    }
    
    export function addMonths(s, n) {
      const d = parseDate(s)
      const day = d.getUTCDate()
      d.setUTCDate(1)
      d.setUTCMonth(d.getUTCMonth() + n)
      const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
      d.setUTCDate(Math.min(day, last))
      return formatDate(d)
    }
    
    export function daysBetween(a, b) {
      return Math.round((parseDate(b) - parseDate(a)) / 86400000)
    }
    === plans.js ===
    export const PLANS = [
      { id: 'starter', name: 'Starter', monthlyPrice: 29000, seatLimit: 5, legacy: false },
      { id: 'team', name: 'Team', monthlyPrice: 99000, seatLimit: 20, legacy: false },
      { id: 'business', name: 'Business', monthlyPrice: 290000, seatLimit: 100, legacy: false },
      { id: 'L-startup-2019', name: 'Startup (2019)', monthlyPrice: 49000, seatLimit: 10, legacy: true },
      { id: 'L-growth-2019', name: 'Growth (2019)', monthlyPrice: 149000, seatLimit: 30, legacy: true },
    ]
    
    export function findPlan(id) {
      const plan = PLANS.find((p) => p.id === id)
      if (!plan) throw new Error(`unknown plan: ${id}`)
      return plan
    }
    === invoice.js ===
    export const VAT_RATE = 0.1
    
    let seq = 0
    
    // Invoice number: INV-YYYYMM-NNNNN
    export function nextInvoiceNumber(issuedOn) {
      seq += 1
      return `INV-${issuedOn.slice(0, 7).replace('-', '')}-${String(seq).padStart(5, '0')}`
    }
    
    export function resetInvoiceSequence() {
      seq = 0
    }
    
    export function createInvoice({ accountId, issuedOn, lines }) {
      if (!lines.length) throw new Error('invoice needs at least one line')
      const subtotal = lines.reduce((sum, l) => sum + l.amount, 0)
      const vat = Math.floor(subtotal * VAT_RATE)
      return {
        number: nextInvoiceNumber(issuedOn),
        accountId,
        issuedOn,
        lines,
        subtotal,
        vat,
        total: subtotal + vat,
      }
    }
    === test dir ===
    coupons.test.js
    format.test.js
    invoice.test.js
    payment.test.js
    plans.test.js
    proration.test.js
    === existing proration test ===
    import { test } from 'node:test'
    import assert from 'node:assert/strict'
    import { cycleBounds, prorate } from '../src/proration.js'
    
    test('cycleBounds: anchor before the date', () => {
      assert.deepEqual(cycleBounds(10, '2026-03-15'), { start: '2026-03-10', end: '2026-04-10' })
    })
    
    test('cycleBounds: anchor after the date goes to previous month', () => {
      assert.deepEqual(cycleBounds(20, '2026-03-05'), { start: '2026-02-20', end: '2026-03-20' })
    })
    
    test('cycleBounds rejects anchor outside 1..28', () => {
      assert.throws(() => cycleBounds(31, '2026-03-05'), RangeError)
    })
    
    test('prorate: remaining days over cycle days, truncated', () => {
      const cycle = { start: '2026-03-10', end: '2026-04-10' } // 31 days
      assert.equal(prorate(99000, cycle, '2026-03-25'), Math.floor((99000 * 16) / 31))
    })
  3. Bashecho "=== coupons.js ===" && cat src/coupons.js && echo "=== payment.js ===" && cat src/payment.js && echo "=== package.json ===" && cat package.json
    result / 1,236 chars
    === coupons.js ===
    // coupon: { code, type: 'percent' | 'fixed', value }
    export function applyCoupon(subtotal, coupon) {
      if (coupon.type === 'percent') {
        return subtotal - Math.floor((subtotal * coupon.value) / 100)
      }
      if (coupon.type === 'fixed') {
        return Math.max(0, subtotal - coupon.value)
      }
      throw new Error(`unknown coupon type: ${coupon.type}`)
    }
    === payment.js ===
    export class GatewayError extends Error {
      constructor(code, message) {
        super(message ?? code)
        this.name = 'GatewayError'
        this.code = code
      }
    }
    
    // PayKo payment request.
    // gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
    export async function charge(gateway, { orderId, amount, method = 'card' }) {
      if (!Number.isInteger(amount) || amount <= 0) {
        throw new RangeError('amount must be a positive integer (KRW)')
      }
      const res = await gateway.request({
        path: '/v1/payments',
        headers: {},
        body: { orderId, amount, currency: 'KRW', method },
      })
      return { status: res.status, paymentKey: res.paymentKey }
    }
    === package.json ===
    {
      "name": "workhub-billing",
      "version": "0.4.2",
      "private": true,
      "type": "module",
      "scripts": {
        "test": "node --test"
      }
    }
  4. Editsrc/proration.js
    result / 208 chars
    The file /src/proration.js has been updated successfully. (file state is current in your context — no need to Read it back)
  5. Editsrc/proration.js
    result / 208 chars
    The file /src/proration.js has been updated successfully. (file state is current in your context — no need to Read it back)
  6. Edittest/proration.test.js
    result / 214 chars
    The file /test/proration.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  7. Edittest/proration.test.js
    result / 214 chars
    The file /test/proration.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  8. Bashnpm test 2>&1 | tail -60
    result / 1,346 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (0.623125ms)
    ✔ fixed coupon never goes below zero (0.226458ms)
    ✔ formatKRW adds thousands separators (12.708875ms)
    ✔ createInvoice sums lines and adds VAT (0.696958ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.078834ms)
    ✔ charge sends KRW amount and returns status (1.928625ms)
    ✔ charge rejects non-integer amount (0.28775ms)
    ✔ charge propagates gateway errors (0.1005ms)
    ✔ findPlan returns the plan by id (0.579084ms)
    ✔ findPlan throws on unknown id (0.326166ms)
    ✔ plan ids are unique (0.090417ms)
    ✔ cycleBounds: anchor before the date (1.965583ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.077ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.1675ms)
    ✔ prorate: remaining days over cycle days, truncated (0.075542ms)
    ✔ changePlan: upgrade charges the price difference prorated over remaining days (0.107292ms)
    ✔ changePlan: downgrade returns a negative amount (credit owed) (0.05175ms)
    ✔ changePlan: switching to the same plan nets to zero (0.04675ms)
    ✔ changePlan: on the first day of the cycle charges/credits the full month difference (0.043875ms)
    ✔ changePlan: works with legacy plan ids (0.060959ms)
    ✔ changePlan: rejects an unknown plan id (0.260667ms)
    ℹ tests 21
    ℹ suites 0
    ℹ pass 21
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 123.767625
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +50 -1
src/proration.js+13 -0
index bbf1a71..dad8ee9 100644--- a/src/proration.js+++ b/src/proration.js@@ -1,4 +1,5 @@ import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'+import { findPlan } from './plans.js' // Billing cycle. anchorDay (1..28) is the day each monthly cycle starts. // Returns the [start, end) of the cycle containing `at`. `end` is the next cycle's start date.@@ -20,3 +21,15 @@ export function prorate(amount, cycle, at) { if (remaining < 0 || remaining > total) throw new RangeError(`${at} is outside the cycle`) return Math.floor((amount * remaining) / total) }++// Amount to charge this cycle when switching from account.planId to newPlanId on `at`:+// the new plan prorated over the days remaining in the current cycle, minus a credit for+// the unused days remaining on the old plan. Negative means a credit is owed to the account.+export function changePlan(account, newPlanId, at) {+ const oldPlan = findPlan(account.planId)+ const newPlan = findPlan(newPlanId)+ const cycle = cycleBounds(account.anchorDay, at)+ const credit = prorate(oldPlan.monthlyPrice, cycle, at)+ const charge = prorate(newPlan.monthlyPrice, cycle, at)+ return charge - credit+}
test/proration.test.js+37 -1
index dc369a9..331f26d 100644--- a/test/proration.test.js+++ b/test/proration.test.js@@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict'-import { cycleBounds, prorate } from '../src/proration.js'+import { cycleBounds, prorate, changePlan } from '../src/proration.js' test('cycleBounds: anchor before the date', () => { assert.deepEqual(cycleBounds(10, '2026-03-15'), { start: '2026-03-10', end: '2026-04-10' })@@ -18,3 +18,39 @@ test('prorate: remaining days over cycle days, truncated', () => { const cycle = { start: '2026-03-10', end: '2026-04-10' } // 31 days assert.equal(prorate(99000, cycle, '2026-03-25'), Math.floor((99000 * 16) / 31)) })++test('changePlan: upgrade charges the price difference prorated over remaining days', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ const cycle = cycleBounds(account.anchorDay, '2026-03-25') // 31-day cycle, 16 days remaining+ const expected =+ Math.floor((99000 * 16) / 31) - Math.floor((29000 * 16) / 31)+ assert.equal(changePlan(account, 'team', '2026-03-25'), expected)+ assert.ok(expected > 0)+})++test('changePlan: downgrade returns a negative amount (credit owed)', () => {+ const account = { planId: 'business', anchorDay: 10 }+ const result = changePlan(account, 'starter', '2026-03-25')+ assert.ok(result < 0)+})++test('changePlan: switching to the same plan nets to zero', () => {+ const account = { planId: 'team', anchorDay: 10 }+ assert.equal(changePlan(account, 'team', '2026-03-25'), 0)+})++test('changePlan: on the first day of the cycle charges/credits the full month difference', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ assert.equal(changePlan(account, 'team', '2026-03-10'), 99000 - 29000)+})++test('changePlan: works with legacy plan ids', () => {+ const account = { planId: 'L-startup-2019', anchorDay: 1 }+ const result = changePlan(account, 'L-growth-2019', '2026-03-15')+ assert.ok(result > 0)+})++test('changePlan: rejects an unknown plan id', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ assert.throws(() => changePlan(account, 'nope', '2026-03-25'), /unknown plan/)+})

exit 0

variant14 turns / 80s / 13 tool calls / 2 files changed
Final response
All 21 tests pass. Implemented `changePlan(account, newPlanId, at)` in `src/proration.js`, following the billing policy in `docs/billing-cycle-and-proration.md`:

- **Upgrade** (fee increases): immediate effect, charges the prorated difference now — using the cycle's actual day count, or a fixed 30-day denominator (capped numerator) when the *current* plan is legacy (`L-` prefix).
- **Downgrade** (fee decreases): charges 0 now, `effectiveDate` set to the next cycle start.
- **Lateral** (same fee): immediate effect, charges 0.

Returns `{ kind, chargeNow, effectiveDate, effectivePlanId }`. Added tests in `test/proration.test.js` covering each case, including the doc's worked examples (Team→Business, Business→Team, legacy upgrade, cycle-start-date upgrade) and a legacy-cap edge case.
Trace / 13 tool calls
  1. Bashcat -n src/proration.js 2>/dev/null || echo "FILE NOT FOUND"; echo "---"; ls src/ test/ tests/ 2>/dev/null; echo "---"; find . -path ./node_modules -prune -o -iname "*proration*" -print
    result / 1,552 chars
         1	import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'
         2	
         3	// Billing cycle. anchorDay (1..28) is the day each monthly cycle starts.
         4	// Returns the [start, end) of the cycle containing `at`. `end` is the next cycle's start date.
         5	export function cycleBounds(anchorDay, at) {
         6	  if (!Number.isInteger(anchorDay) || anchorDay < 1 || anchorDay > 28) {
         7	    throw new RangeError(`anchorDay must be 1..28: ${anchorDay}`)
         8	  }
         9	  const d = parseDate(at)
        10	  let start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), anchorDay))
        11	  if (start > d) start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, anchorDay))
        12	  const startStr = formatDate(start)
        13	  return { start: startStr, end: addMonths(startStr, 1) }
        14	}
        15	
        16	// Prorates `amount` by the days remaining from `at` to the end of the cycle. The day of `at` counts.
        17	export function prorate(amount, cycle, at) {
        18	  const total = daysBetween(cycle.start, cycle.end)
        19	  const remaining = daysBetween(at, cycle.end)
        20	  if (remaining < 0 || remaining > total) throw new RangeError(`${at} is outside the cycle`)
        21	  return Math.floor((amount * remaining) / total)
        22	}
    ---
    src/:
    coupons.js
    dates.js
    format.js
    invoice.js
    payment.js
    plans.js
    proration.js
    
    test/:
    coupons.test.js
    format.test.js
    invoice.test.js
    payment.test.js
    plans.test.js
    proration.test.js
    ---
    ./test/proration.test.js
    ./docs/billing-cycle-and-proration.md
    ./src/proration.js
  2. Readtest/proration.test.js
    result / 858 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { cycleBounds, prorate } from '../src/proration.js'
    4	
    5	test('cycleBounds: anchor before the date', () => {
    6	  assert.deepEqual(cycleBounds(10, '2026-03-15'), { start: '2026-03-10', end: '2026-04-10' })
    7	})
    8	
    9	test('cycleBounds: anchor after the date goes to previous month', () => {
    10	  assert.deepEqual(cycleBounds(20, '2026-03-05'), { start: '2026-02-20', end: '2026-03-20' })
    11	})
    12	
    13	test('cycleBounds rejects anchor outside 1..28', () => {
    14	  assert.throws(() => cycleBounds(31, '2026-03-05'), RangeError)
    15	})
    16	
    17	test('prorate: remaining days over cycle days, truncated', () => {
    18	  const cycle = { start: '2026-03-10', end: '2026-04-10' } // 31 days
    19	  assert.equal(prorate(99000, cycle, '2026-03-25'), Math.floor((99000 * 16) / 31))
    20	})
    21	
  3. Readsrc/plans.js
    result / 671 chars
    1	export const PLANS = [
    2	  { id: 'starter', name: 'Starter', monthlyPrice: 29000, seatLimit: 5, legacy: false },
    3	  { id: 'team', name: 'Team', monthlyPrice: 99000, seatLimit: 20, legacy: false },
    4	  { id: 'business', name: 'Business', monthlyPrice: 290000, seatLimit: 100, legacy: false },
    5	  { id: 'L-startup-2019', name: 'Startup (2019)', monthlyPrice: 49000, seatLimit: 10, legacy: true },
    6	  { id: 'L-growth-2019', name: 'Growth (2019)', monthlyPrice: 149000, seatLimit: 30, legacy: true },
    7	]
    8	
    9	export function findPlan(id) {
    10	  const plan = PLANS.find((p) => p.id === id)
    11	  if (!plan) throw new Error(`unknown plan: ${id}`)
    12	  return plan
    13	}
    14	
  4. Readsrc/dates.js
    result / 965 chars
    1	// Dates are handled only as 'YYYY-MM-DD' strings. Time of day and time zones are outside this module.
    2	
    3	export function parseDate(s) {
    4	  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
    5	  if (!m) throw new TypeError(`invalid date: ${s}`)
    6	  return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))
    7	}
    8	
    9	export function formatDate(d) {
    10	  return d.toISOString().slice(0, 10)
    11	}
    12	
    13	export function addDays(s, n) {
    14	  const d = parseDate(s)
    15	  d.setUTCDate(d.getUTCDate() + n)
    16	  return formatDate(d)
    17	}
    18	
    19	export function addMonths(s, n) {
    20	  const d = parseDate(s)
    21	  const day = d.getUTCDate()
    22	  d.setUTCDate(1)
    23	  d.setUTCMonth(d.getUTCMonth() + n)
    24	  const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
    25	  d.setUTCDate(Math.min(day, last))
    26	  return formatDate(d)
    27	}
    28	
    29	export function daysBetween(a, b) {
    30	  return Math.round((parseDate(b) - parseDate(a)) / 86400000)
    31	}
    32	
  5. Readdocs/billing-cycle-and-proration.md
    result / 7,284 chars
    1	# Billing cycle and proration policy
    2	
    3	This document covers the Workhub subscription billing cycle, how money is handled on plan changes, and the proration rules.
    4	The code (`src/proration.js`) only provides calculation tools; which calculation applies in which situation is decided by the
    5	policy here. The finance team owns this policy, and changes go through finance review (see "Change procedure" in `glossary-and-incident-log.md`).
    6	
    7	## 1. Billing cycle
    8	
    9	- Every account has an `anchorDay` (1..28). A new cycle starts on that day each month, and one month's fee is charged up front on the cycle start date.
    10	- A cycle is `[start date, next start date)`. With an anchor of March 10, one cycle runs from March 10 through April 9.
    11	- `anchorDay` is set from the date of the first paid conversion. The paid conversion date is the day the 14-day trial ends.
    12	- Days 29, 30, and 31 are never used as anchors. In August 2023 we moved all 412 accounts with those anchors to day 28,
    13	  because cycle lengths diverged per account in February and CS tickets piled up. The finance team keeps the list of moved accounts.
    14	- The billing batch runs at 09:00 KST on the cycle start date. Plan changes made before that time are reflected in the cycle starting that day.
    15	
    16	## 2. Date basis: Korea Standard Time (KST) calendar dates
    17	
    18	- Every billing-related date is a **KST calendar date**. Servers and the database run in UTC, but any date entering billing logic must already be a
    19	  `YYYY-MM-DD` string converted to KST.
    20	- Reason: the "issue date" on the tax invoice must equal the cycle start date, and tax invoices are issued on Korean time.
    21	  Using UTC dates would push charges between 00:00 and 09:00 KST to the previous day. This actually happened in June 2023 (INC-2023-06).
    22	- So billing logic never uses the time component of a `Date` or the user's browser time zone. Overseas customers are on KST as well.
    23	
    24	## 3. Money handling on plan changes
    25	
    26	Plan changes fall into three kinds. The criterion is **the monthly fee**. Seat count and features are not considered.
    27	
    28	| Change | Takes effect | Billing |
    29	|---|---|---|
    30	| Upgrade (monthly fee goes up) | Immediately | Prorate the difference over the remaining days and charge immediately |
    31	| Downgrade (monthly fee goes down) | Next cycle start date | No charge and no refund this cycle. New fee from the next cycle |
    32	| Lateral move (same monthly fee) | Immediately | 0 |
    33	
    34	### 3.1 Upgrade
    35	
    36	- The change date itself counts as a remaining day. Changing on March 25 with a cycle ending April 10 leaves 16 remaining days.
    37	- Charge = (new plan monthly fee − old plan monthly fee) × remaining days ÷ cycle days. Truncate below 1 KRW.
    38	- Cycle days are actual calendar days (28..31). Legacy plans follow section 4 instead.
    39	- The upgrade invoice is issued and charged immediately. If the payment fails, the plan change is rolled back too.
    40	
    41	### 3.2 Downgrade: no money moves this cycle
    42	
    43	- A downgrade takes effect **from the next cycle start date**. The change request is stored only as a "scheduled" change.
    44	- The difference for the rest of the current cycle is not refunded or returned as credit. The system must never produce a negative amount.
    45	- Background: until November 2022, downgrades were also prorated immediately and the difference became a negative invoice. Every negative invoice
    46	  then required a corrected tax invoice at month-end close, and the finance team closed November 8 days late (INC-2022-11).
    47	  The December 2022 policy meeting fixed "downgrades are deferred, no refunds", and it is in section 7.3 of the terms of service.
    48	- If CS requests an exception, the finance team handles it as a manual credit. The system has no exception path.
    49	- Upgrading again while a downgrade is scheduled cancels the scheduled downgrade.
    50	
    51	### 3.3 Lateral move
    52	
    53	- Moving to a plan with the same monthly fee (none in the current catalog, but past regional plans had them). Immediate, charge 0.
    54	
    55	## 4. Proration for legacy plans (`L-` prefix)
    56	
    57	- 2019 contract, article 4: "For proration, one month is 30 days." So any proration involving a legacy plan uses **30 as the denominator**,
    58	  regardless of the cycle's actual length. The numerator (remaining days) is the actual remaining days, capped at 30.
    59	- This rule applies when the existing plan is legacy. Upgrading from a legacy plan to a current plan is still the last cycle under the old contract,
    60	  so the 30-day basis applies.
    61	- In a 31-day cycle a legacy customer who upgrades pays slightly more than a current customer. That is what the contract says, and customers know it.
    62	  "Fixing" it to actual days would breach the contract.
    63	
    64	## 5. Worked examples
    65	
    66	| Situation | Calculation | Amount |
    67	|---|---|---|
    68	| Team (99,000) → Business (290,000), anchor 10, changed March 25 | (290,000 − 99,000) × 16 ÷ 31 | 98,580 KRW |
    69	| Business → Team, changed March 25 | Team fee from the next cycle (April 10). This cycle charge 0 | 0 KRW |
    70	| L-growth-2019 (149,000) → Business (290,000), anchor 10, changed March 25 | (290,000 − 149,000) × 16 ÷ 30 | 75,200 KRW |
    71	| Starter → Team, changed on the cycle start date | Remaining days = cycle days, full difference | 70,000 KRW |
    72	
    73	## 6. Out of scope
    74	
    75	- Annual billing is not handled by this module. The 37 annual customers are invoiced manually by the finance team.
    76	- Enterprise is invoiced per contract (net-30) and has no plan-change concept.
    77	
    78	## 7. Cancellation
    79	
    80	- Cancellation takes effect at the end of the cycle. The remaining period stays usable and there is no refund. Same principle as downgrades.
    81	- Immediate cancellation with refund is finance-only (legal disputes, duplicate signups, and so on). The system has no immediate-cancel path.
    82	- An account with a scheduled cancellation does not accept plan changes. The scheduled cancellation must be removed first.
    83	
    84	## 8. Trial and anchor selection
    85	
    86	- The trial is 14 days and needs no card. There are no invoices during the trial.
    87	- If a card is on file when the trial ends, the account converts that day, and that day's date becomes `anchorDay`.
    88	  If that day is the 29th, 30th, or 31st, `anchorDay` is set to 28 and only the first cycle runs a day or two longer. The first cycle is charged a full month.
    89	- If no card is on file at trial end, the account becomes read-only, and the day a card is later added becomes the anchor.
    90	
    91	## 9. Common implementation mistakes
    92	
    93	- Excluding the change date from remaining days. It is included. A change on the cycle start date has the whole cycle remaining.
    94	- Giving a "fair" credit on downgrade. Forbidden by policy, and the cause of INC-2022-11.
    95	- Using a 30-day denominator for current plans, or actual days for legacy plans. Legacy status is decided by the **existing** plan.
    96	- Truncating only once at the end. Truncate once in proration, once after coupons, once in VAT.
    97	- Leaving the plan changed after an upgrade payment fails. On payment failure, roll back the plan change.
    98	- Stacking a second scheduled downgrade on an account that already has one. Overwrite instead of stacking.
    99	
  6. Readsrc/invoice.js
    result / 751 chars
    1	export const VAT_RATE = 0.1
    2	
    3	let seq = 0
    4	
    5	// Invoice number: INV-YYYYMM-NNNNN
    6	export function nextInvoiceNumber(issuedOn) {
    7	  seq += 1
    8	  return `INV-${issuedOn.slice(0, 7).replace('-', '')}-${String(seq).padStart(5, '0')}`
    9	}
    10	
    11	export function resetInvoiceSequence() {
    12	  seq = 0
    13	}
    14	
    15	export function createInvoice({ accountId, issuedOn, lines }) {
    16	  if (!lines.length) throw new Error('invoice needs at least one line')
    17	  const subtotal = lines.reduce((sum, l) => sum + l.amount, 0)
    18	  const vat = Math.floor(subtotal * VAT_RATE)
    19	  return {
    20	    number: nextInvoiceNumber(issuedOn),
    21	    accountId,
    22	    issuedOn,
    23	    lines,
    24	    subtotal,
    25	    vat,
    26	    total: subtotal + vat,
    27	  }
    28	}
    29	
  7. Readsrc/payment.js
    result / 745 chars
    1	export class GatewayError extends Error {
    2	  constructor(code, message) {
    3	    super(message ?? code)
    4	    this.name = 'GatewayError'
    5	    this.code = code
    6	  }
    7	}
    8	
    9	// PayKo payment request.
    10	// gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
    11	export async function charge(gateway, { orderId, amount, method = 'card' }) {
    12	  if (!Number.isInteger(amount) || amount <= 0) {
    13	    throw new RangeError('amount must be a positive integer (KRW)')
    14	  }
    15	  const res = await gateway.request({
    16	    path: '/v1/payments',
    17	    headers: {},
    18	    body: { orderId, amount, currency: 'KRW', method },
    19	  })
    20	  return { status: res.status, paymentKey: res.paymentKey }
    21	}
    22	
  8. Readsrc/coupons.js
    result / 375 chars
    1	// coupon: { code, type: 'percent' | 'fixed', value }
    2	export function applyCoupon(subtotal, coupon) {
    3	  if (coupon.type === 'percent') {
    4	    return subtotal - Math.floor((subtotal * coupon.value) / 100)
    5	  }
    6	  if (coupon.type === 'fixed') {
    7	    return Math.max(0, subtotal - coupon.value)
    8	  }
    9	  throw new Error(`unknown coupon type: ${coupon.type}`)
    10	}
    11	
  9. Editsrc/proration.js
    result / 208 chars
    The file /src/proration.js has been updated successfully. (file state is current in your context — no need to Read it back)
  10. Editsrc/proration.js
    result / 208 chars
    The file /src/proration.js has been updated successfully. (file state is current in your context — no need to Read it back)
  11. Edittest/proration.test.js
    result / 214 chars
    The file /test/proration.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  12. Edittest/proration.test.js
    result / 214 chars
    The file /test/proration.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  13. Bashnpm test 2>&1 | tail -60
    result / 1,402 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (1.623667ms)
    ✔ fixed coupon never goes below zero (0.094959ms)
    ✔ formatKRW adds thousands separators (13.574709ms)
    ✔ createInvoice sums lines and adds VAT (1.031041ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.089833ms)
    ✔ charge sends KRW amount and returns status (1.578625ms)
    ✔ charge rejects non-integer amount (0.554959ms)
    ✔ charge propagates gateway errors (0.26025ms)
    ✔ findPlan returns the plan by id (1.64925ms)
    ✔ findPlan throws on unknown id (0.496709ms)
    ✔ plan ids are unique (0.223ms)
    ✔ cycleBounds: anchor before the date (3.234083ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.084833ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.261584ms)
    ✔ prorate: remaining days over cycle days, truncated (0.134208ms)
    ✔ changePlan: upgrade charges the prorated difference immediately (0.494292ms)
    ✔ changePlan: downgrade charges nothing now and takes effect next cycle (0.280333ms)
    ✔ changePlan: lateral move is immediate and free (0.276459ms)
    ✔ changePlan: upgrade from a legacy plan prorates over a fixed 30-day month (0.076958ms)
    ✔ changePlan: upgrade on the cycle start date charges the full difference (0.084875ms)
    ✔ changePlan: legacy upgrade proration is capped at 30 days even in a longer cycle (0.100833ms)
    ℹ tests 21
    ℹ suites 0
    ℹ pass 21
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 132.174
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +90 -1
src/proration.js+35 -0
index bbf1a71..8b18715 100644--- a/src/proration.js+++ b/src/proration.js@@ -1,4 +1,7 @@ import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'+import { findPlan } from './plans.js'++const LEGACY_PRORATION_DAYS = 30 // Billing cycle. anchorDay (1..28) is the day each monthly cycle starts. // Returns the [start, end) of the cycle containing `at`. `end` is the next cycle's start date.@@ -20,3 +23,35 @@ export function prorate(amount, cycle, at) { if (remaining < 0 || remaining > total) throw new RangeError(`${at} is outside the cycle`) return Math.floor((amount * remaining) / total) }++// What to charge (and when the new plan takes effect) when `account` moves to `newPlanId` on+// date `at`. The kind of change is decided by comparing monthly fees:+// - Upgrade (fee goes up): takes effect immediately, prorated difference charged now. If the+// *current* plan is legacy (`L-` prefix), proration uses a fixed 30-day denominator per the+// 2019 contract instead of the cycle's actual length (numerator capped at 30).+// - Downgrade (fee goes down): no charge this cycle; takes effect at the next cycle start.+// - Lateral (same fee): takes effect immediately, no charge.+export function changePlan(account, newPlanId, at) {+ const oldPlan = findPlan(account.planId)+ const newPlan = findPlan(newPlanId)+ const cycle = cycleBounds(account.anchorDay, at)++ if (newPlan.monthlyPrice === oldPlan.monthlyPrice) {+ return { kind: 'lateral', chargeNow: 0, effectiveDate: at, effectivePlanId: newPlan.id }+ }++ if (newPlan.monthlyPrice < oldPlan.monthlyPrice) {+ return { kind: 'downgrade', chargeNow: 0, effectiveDate: cycle.end, effectivePlanId: newPlan.id }+ }++ const diff = newPlan.monthlyPrice - oldPlan.monthlyPrice+ let chargeNow+ if (oldPlan.legacy) {+ const remaining = Math.min(daysBetween(at, cycle.end), LEGACY_PRORATION_DAYS)+ chargeNow = Math.floor((diff * remaining) / LEGACY_PRORATION_DAYS)+ } else {+ chargeNow = prorate(diff, cycle, at)+ }++ return { kind: 'upgrade', chargeNow, effectiveDate: at, effectivePlanId: newPlan.id }+}
test/proration.test.js+55 -1
index dc369a9..bc6c32c 100644--- a/test/proration.test.js+++ b/test/proration.test.js@@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict'-import { cycleBounds, prorate } from '../src/proration.js'+import { cycleBounds, prorate, changePlan } from '../src/proration.js' test('cycleBounds: anchor before the date', () => { assert.deepEqual(cycleBounds(10, '2026-03-15'), { start: '2026-03-10', end: '2026-04-10' })@@ -18,3 +18,57 @@ test('prorate: remaining days over cycle days, truncated', () => { const cycle = { start: '2026-03-10', end: '2026-04-10' } // 31 days assert.equal(prorate(99000, cycle, '2026-03-25'), Math.floor((99000 * 16) / 31)) })++test('changePlan: upgrade charges the prorated difference immediately', () => {+ const account = { planId: 'team', anchorDay: 10 }+ const result = changePlan(account, 'business', '2026-03-25')+ assert.deepEqual(result, {+ kind: 'upgrade',+ chargeNow: 98580,+ effectiveDate: '2026-03-25',+ effectivePlanId: 'business',+ })+})++test('changePlan: downgrade charges nothing now and takes effect next cycle', () => {+ const account = { planId: 'business', anchorDay: 10 }+ const result = changePlan(account, 'team', '2026-03-25')+ assert.deepEqual(result, {+ kind: 'downgrade',+ chargeNow: 0,+ effectiveDate: '2026-04-10',+ effectivePlanId: 'team',+ })+})++test('changePlan: lateral move is immediate and free', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ const result = changePlan(account, 'starter', '2026-03-25')+ assert.deepEqual(result, {+ kind: 'lateral',+ chargeNow: 0,+ effectiveDate: '2026-03-25',+ effectivePlanId: 'starter',+ })+})++test('changePlan: upgrade from a legacy plan prorates over a fixed 30-day month', () => {+ const account = { planId: 'L-growth-2019', anchorDay: 10 }+ const result = changePlan(account, 'business', '2026-03-25')+ assert.equal(result.kind, 'upgrade')+ assert.equal(result.chargeNow, 75200)+})++test('changePlan: upgrade on the cycle start date charges the full difference', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ const result = changePlan(account, 'team', '2026-03-10')+ assert.equal(result.chargeNow, 70000)+})++test('changePlan: legacy upgrade proration is capped at 30 days even in a longer cycle', () => {+ const account = { planId: 'L-startup-2019', anchorDay: 10 }+ // 31-day cycle, changing on the start date: remaining is capped at 30 for legacy plans.+ const result = changePlan(account, 'team', '2026-03-10')+ const diff = 99000 - 49000+ assert.equal(result.chargeNow, Math.floor((diff * 30) / 30))+})

exit 0

run 2baseline 14 turns / 48s / 13 tool calls / 2 files changedvariant 15 turns / 68s / 14 tool calls / 2 files changed
baseline14 turns / 48s / 13 tool calls / 2 files changed
Final response
All 20 tests pass.

Implemented `changePlan(account, newPlanId, at)` in `src/proration.js`: it computes the current billing cycle from `account.anchorDay`, then charges the prorated remainder of the new plan's price minus a credit for the old plan's prorated remainder (negative result = credit for downgrades). Added 5 tests in `test/proration.test.js` covering upgrade, downgrade, same-plan, cycle-start, and unknown-plan cases.
Trace / 13 tool calls
  1. Bashfind -type f -name "*.js" | grep -v node_modules
    result / 1,344 chars
    /test/plans.test.js
    /test/payment.test.js
    /test/proration.test.js
    /test/coupons.test.js
    /test/invoice.test.js
    /test/format.test.js
    /src/proration.js
    /src/format.js
    /src/dates.js
    /src/invoice.js
    /src/payment.js
    /src/coupons.js
    /src/plans.js
  2. Readsrc/proration.js
    result / 1,186 chars
    1	import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'
    2	
    3	// Billing cycle. anchorDay (1..28) is the day each monthly cycle starts.
    4	// Returns the [start, end) of the cycle containing `at`. `end` is the next cycle's start date.
    5	export function cycleBounds(anchorDay, at) {
    6	  if (!Number.isInteger(anchorDay) || anchorDay < 1 || anchorDay > 28) {
    7	    throw new RangeError(`anchorDay must be 1..28: ${anchorDay}`)
    8	  }
    9	  const d = parseDate(at)
    10	  let start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), anchorDay))
    11	  if (start > d) start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, anchorDay))
    12	  const startStr = formatDate(start)
    13	  return { start: startStr, end: addMonths(startStr, 1) }
    14	}
    15	
    16	// Prorates `amount` by the days remaining from `at` to the end of the cycle. The day of `at` counts.
    17	export function prorate(amount, cycle, at) {
    18	  const total = daysBetween(cycle.start, cycle.end)
    19	  const remaining = daysBetween(at, cycle.end)
    20	  if (remaining < 0 || remaining > total) throw new RangeError(`${at} is outside the cycle`)
    21	  return Math.floor((amount * remaining) / total)
    22	}
    23	
  3. Readtest/proration.test.js
    result / 858 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { cycleBounds, prorate } from '../src/proration.js'
    4	
    5	test('cycleBounds: anchor before the date', () => {
    6	  assert.deepEqual(cycleBounds(10, '2026-03-15'), { start: '2026-03-10', end: '2026-04-10' })
    7	})
    8	
    9	test('cycleBounds: anchor after the date goes to previous month', () => {
    10	  assert.deepEqual(cycleBounds(20, '2026-03-05'), { start: '2026-02-20', end: '2026-03-20' })
    11	})
    12	
    13	test('cycleBounds rejects anchor outside 1..28', () => {
    14	  assert.throws(() => cycleBounds(31, '2026-03-05'), RangeError)
    15	})
    16	
    17	test('prorate: remaining days over cycle days, truncated', () => {
    18	  const cycle = { start: '2026-03-10', end: '2026-04-10' } // 31 days
    19	  assert.equal(prorate(99000, cycle, '2026-03-25'), Math.floor((99000 * 16) / 31))
    20	})
    21	
  4. Readsrc/dates.js
    result / 965 chars
    1	// Dates are handled only as 'YYYY-MM-DD' strings. Time of day and time zones are outside this module.
    2	
    3	export function parseDate(s) {
    4	  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
    5	  if (!m) throw new TypeError(`invalid date: ${s}`)
    6	  return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))
    7	}
    8	
    9	export function formatDate(d) {
    10	  return d.toISOString().slice(0, 10)
    11	}
    12	
    13	export function addDays(s, n) {
    14	  const d = parseDate(s)
    15	  d.setUTCDate(d.getUTCDate() + n)
    16	  return formatDate(d)
    17	}
    18	
    19	export function addMonths(s, n) {
    20	  const d = parseDate(s)
    21	  const day = d.getUTCDate()
    22	  d.setUTCDate(1)
    23	  d.setUTCMonth(d.getUTCMonth() + n)
    24	  const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
    25	  d.setUTCDate(Math.min(day, last))
    26	  return formatDate(d)
    27	}
    28	
    29	export function daysBetween(a, b) {
    30	  return Math.round((parseDate(b) - parseDate(a)) / 86400000)
    31	}
    32	
  5. Readsrc/plans.js
    result / 671 chars
    1	export const PLANS = [
    2	  { id: 'starter', name: 'Starter', monthlyPrice: 29000, seatLimit: 5, legacy: false },
    3	  { id: 'team', name: 'Team', monthlyPrice: 99000, seatLimit: 20, legacy: false },
    4	  { id: 'business', name: 'Business', monthlyPrice: 290000, seatLimit: 100, legacy: false },
    5	  { id: 'L-startup-2019', name: 'Startup (2019)', monthlyPrice: 49000, seatLimit: 10, legacy: true },
    6	  { id: 'L-growth-2019', name: 'Growth (2019)', monthlyPrice: 149000, seatLimit: 30, legacy: true },
    7	]
    8	
    9	export function findPlan(id) {
    10	  const plan = PLANS.find((p) => p.id === id)
    11	  if (!plan) throw new Error(`unknown plan: ${id}`)
    12	  return plan
    13	}
    14	
  6. Readsrc/invoice.js
    result / 751 chars
    1	export const VAT_RATE = 0.1
    2	
    3	let seq = 0
    4	
    5	// Invoice number: INV-YYYYMM-NNNNN
    6	export function nextInvoiceNumber(issuedOn) {
    7	  seq += 1
    8	  return `INV-${issuedOn.slice(0, 7).replace('-', '')}-${String(seq).padStart(5, '0')}`
    9	}
    10	
    11	export function resetInvoiceSequence() {
    12	  seq = 0
    13	}
    14	
    15	export function createInvoice({ accountId, issuedOn, lines }) {
    16	  if (!lines.length) throw new Error('invoice needs at least one line')
    17	  const subtotal = lines.reduce((sum, l) => sum + l.amount, 0)
    18	  const vat = Math.floor(subtotal * VAT_RATE)
    19	  return {
    20	    number: nextInvoiceNumber(issuedOn),
    21	    accountId,
    22	    issuedOn,
    23	    lines,
    24	    subtotal,
    25	    vat,
    26	    total: subtotal + vat,
    27	  }
    28	}
    29	
  7. Readsrc/coupons.js
    result / 375 chars
    1	// coupon: { code, type: 'percent' | 'fixed', value }
    2	export function applyCoupon(subtotal, coupon) {
    3	  if (coupon.type === 'percent') {
    4	    return subtotal - Math.floor((subtotal * coupon.value) / 100)
    5	  }
    6	  if (coupon.type === 'fixed') {
    7	    return Math.max(0, subtotal - coupon.value)
    8	  }
    9	  throw new Error(`unknown coupon type: ${coupon.type}`)
    10	}
    11	
  8. Bashnode -e " console.log('credit starter', Math.floor(29000*16/31)); console.log('charge team', Math.floor(99000*16/31)); console.log('diff', Math.floor(99000*16/31) - Math.floor(29000*16/31)); console.log('credit team', Math.floor(99000*16/31)); console.log('charge starter', Math.floor(29000*16/31)); console.log('diff downgrade', Math.floor(29000*16/31) - Math.floor(99000*16/31)); "
    result / 110 chars
    credit starter 14967
    charge team 51096
    diff 36129
    credit team 51096
    charge starter 14967
    diff downgrade -36129
  9. Editsrc/proration.js
    result / 208 chars
    The file /src/proration.js has been updated successfully. (file state is current in your context — no need to Read it back)
  10. Editsrc/proration.js
    result / 208 chars
    The file /src/proration.js has been updated successfully. (file state is current in your context — no need to Read it back)
  11. Edittest/proration.test.js
    result / 214 chars
    The file /test/proration.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  12. Edittest/proration.test.js
    result / 214 chars
    The file /test/proration.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  13. Bashnpm test
    result / 1,246 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (0.644084ms)
    ✔ fixed coupon never goes below zero (0.057125ms)
    ✔ formatKRW adds thousands separators (16.889125ms)
    ✔ createInvoice sums lines and adds VAT (1.62275ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.214375ms)
    ✔ charge sends KRW amount and returns status (1.528042ms)
    ✔ charge rejects non-integer amount (0.294875ms)
    ✔ charge propagates gateway errors (0.103125ms)
    ✔ findPlan returns the plan by id (1.289792ms)
    ✔ findPlan throws on unknown id (0.220958ms)
    ✔ plan ids are unique (0.063791ms)
    ✔ cycleBounds: anchor before the date (3.835583ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.092459ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.159209ms)
    ✔ prorate: remaining days over cycle days, truncated (0.078125ms)
    ✔ changePlan: upgrade charges the prorated price difference (0.097875ms)
    ✔ changePlan: downgrade credits the prorated price difference (0.046791ms)
    ✔ changePlan: same plan nets to zero (0.0435ms)
    ✔ changePlan: change on the first day of the cycle charges the full difference (0.043875ms)
    ✔ changePlan: unknown plan throws (0.073875ms)
    ℹ tests 20
    ℹ suites 0
    ℹ pass 20
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 137.829167
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +40 -1
src/proration.js+13 -0
index bbf1a71..25aa48a 100644--- a/src/proration.js+++ b/src/proration.js@@ -1,4 +1,5 @@ import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'+import { findPlan } from './plans.js' // Billing cycle. anchorDay (1..28) is the day each monthly cycle starts. // Returns the [start, end) of the cycle containing `at`. `end` is the next cycle's start date.@@ -20,3 +21,15 @@ export function prorate(amount, cycle, at) { if (remaining < 0 || remaining > total) throw new RangeError(`${at} is outside the cycle`) return Math.floor((amount * remaining) / total) }++// Charge for switching `account` to `newPlanId` on `at`: the new plan's prorated+// remainder of the current cycle minus a credit for the old plan's prorated remainder.+// Negative means a credit (downgrading to a cheaper plan).+export function changePlan(account, newPlanId, at) {+ const oldPlan = findPlan(account.planId)+ const newPlan = findPlan(newPlanId)+ const cycle = cycleBounds(account.anchorDay, at)+ const credit = prorate(oldPlan.monthlyPrice, cycle, at)+ const charge = prorate(newPlan.monthlyPrice, cycle, at)+ return charge - credit+}
test/proration.test.js+27 -1
index dc369a9..3b22ac3 100644--- a/test/proration.test.js+++ b/test/proration.test.js@@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict'-import { cycleBounds, prorate } from '../src/proration.js'+import { cycleBounds, prorate, changePlan } from '../src/proration.js' test('cycleBounds: anchor before the date', () => { assert.deepEqual(cycleBounds(10, '2026-03-15'), { start: '2026-03-10', end: '2026-04-10' })@@ -18,3 +18,29 @@ test('prorate: remaining days over cycle days, truncated', () => { const cycle = { start: '2026-03-10', end: '2026-04-10' } // 31 days assert.equal(prorate(99000, cycle, '2026-03-25'), Math.floor((99000 * 16) / 31)) })++test('changePlan: upgrade charges the prorated price difference', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ // cycle 2026-03-10..2026-04-10 (31 days), 16 days remaining from 2026-03-25+ assert.equal(changePlan(account, 'team', '2026-03-25'), 36129)+})++test('changePlan: downgrade credits the prorated price difference', () => {+ const account = { planId: 'team', anchorDay: 10 }+ assert.equal(changePlan(account, 'starter', '2026-03-25'), -36129)+})++test('changePlan: same plan nets to zero', () => {+ const account = { planId: 'team', anchorDay: 10 }+ assert.equal(changePlan(account, 'team', '2026-03-25'), 0)+})++test('changePlan: change on the first day of the cycle charges the full difference', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ assert.equal(changePlan(account, 'team', '2026-03-10'), 99000 - 29000)+})++test('changePlan: unknown plan throws', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ assert.throws(() => changePlan(account, 'nope', '2026-03-25'), /unknown plan/)+})

exit 0

variant15 turns / 68s / 14 tool calls / 2 files changed
Final response
All 20 tests pass.

Implemented `changePlan(account, newPlanId, at)` in `src/proration.js:24-33`: it credits the unused days on the old plan against the new plan's cost for the remaining cycle days, charging the difference now (clamped to 0 for downgrades, since no refund/credit mechanism exists elsewhere in the codebase). Added five tests in `test/proration.test.js` covering upgrade, downgrade, same-plan, anchor-day, and unknown-plan cases.
Trace / 14 tool calls
  1. Bashfind -type f -name "*.js" | grep -v node_modules
    result / 1,344 chars
    /test/plans.test.js
    /test/payment.test.js
    /test/proration.test.js
    /test/coupons.test.js
    /test/invoice.test.js
    /test/format.test.js
    /src/proration.js
    /src/format.js
    /src/dates.js
    /src/invoice.js
    /src/payment.js
    /src/coupons.js
    /src/plans.js
  2. Readsrc/proration.js
    result / 1,186 chars
    1	import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'
    2	
    3	// Billing cycle. anchorDay (1..28) is the day each monthly cycle starts.
    4	// Returns the [start, end) of the cycle containing `at`. `end` is the next cycle's start date.
    5	export function cycleBounds(anchorDay, at) {
    6	  if (!Number.isInteger(anchorDay) || anchorDay < 1 || anchorDay > 28) {
    7	    throw new RangeError(`anchorDay must be 1..28: ${anchorDay}`)
    8	  }
    9	  const d = parseDate(at)
    10	  let start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), anchorDay))
    11	  if (start > d) start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, anchorDay))
    12	  const startStr = formatDate(start)
    13	  return { start: startStr, end: addMonths(startStr, 1) }
    14	}
    15	
    16	// Prorates `amount` by the days remaining from `at` to the end of the cycle. The day of `at` counts.
    17	export function prorate(amount, cycle, at) {
    18	  const total = daysBetween(cycle.start, cycle.end)
    19	  const remaining = daysBetween(at, cycle.end)
    20	  if (remaining < 0 || remaining > total) throw new RangeError(`${at} is outside the cycle`)
    21	  return Math.floor((amount * remaining) / total)
    22	}
    23	
  3. Readsrc/plans.js
    result / 671 chars
    1	export const PLANS = [
    2	  { id: 'starter', name: 'Starter', monthlyPrice: 29000, seatLimit: 5, legacy: false },
    3	  { id: 'team', name: 'Team', monthlyPrice: 99000, seatLimit: 20, legacy: false },
    4	  { id: 'business', name: 'Business', monthlyPrice: 290000, seatLimit: 100, legacy: false },
    5	  { id: 'L-startup-2019', name: 'Startup (2019)', monthlyPrice: 49000, seatLimit: 10, legacy: true },
    6	  { id: 'L-growth-2019', name: 'Growth (2019)', monthlyPrice: 149000, seatLimit: 30, legacy: true },
    7	]
    8	
    9	export function findPlan(id) {
    10	  const plan = PLANS.find((p) => p.id === id)
    11	  if (!plan) throw new Error(`unknown plan: ${id}`)
    12	  return plan
    13	}
    14	
  4. Readsrc/dates.js
    result / 965 chars
    1	// Dates are handled only as 'YYYY-MM-DD' strings. Time of day and time zones are outside this module.
    2	
    3	export function parseDate(s) {
    4	  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
    5	  if (!m) throw new TypeError(`invalid date: ${s}`)
    6	  return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))
    7	}
    8	
    9	export function formatDate(d) {
    10	  return d.toISOString().slice(0, 10)
    11	}
    12	
    13	export function addDays(s, n) {
    14	  const d = parseDate(s)
    15	  d.setUTCDate(d.getUTCDate() + n)
    16	  return formatDate(d)
    17	}
    18	
    19	export function addMonths(s, n) {
    20	  const d = parseDate(s)
    21	  const day = d.getUTCDate()
    22	  d.setUTCDate(1)
    23	  d.setUTCMonth(d.getUTCMonth() + n)
    24	  const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
    25	  d.setUTCDate(Math.min(day, last))
    26	  return formatDate(d)
    27	}
    28	
    29	export function daysBetween(a, b) {
    30	  return Math.round((parseDate(b) - parseDate(a)) / 86400000)
    31	}
    32	
  5. Readtest/proration.test.js
    result / 858 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { cycleBounds, prorate } from '../src/proration.js'
    4	
    5	test('cycleBounds: anchor before the date', () => {
    6	  assert.deepEqual(cycleBounds(10, '2026-03-15'), { start: '2026-03-10', end: '2026-04-10' })
    7	})
    8	
    9	test('cycleBounds: anchor after the date goes to previous month', () => {
    10	  assert.deepEqual(cycleBounds(20, '2026-03-05'), { start: '2026-02-20', end: '2026-03-20' })
    11	})
    12	
    13	test('cycleBounds rejects anchor outside 1..28', () => {
    14	  assert.throws(() => cycleBounds(31, '2026-03-05'), RangeError)
    15	})
    16	
    17	test('prorate: remaining days over cycle days, truncated', () => {
    18	  const cycle = { start: '2026-03-10', end: '2026-04-10' } // 31 days
    19	  assert.equal(prorate(99000, cycle, '2026-03-25'), Math.floor((99000 * 16) / 31))
    20	})
    21	
  6. Readsrc/invoice.js
    result / 751 chars
    1	export const VAT_RATE = 0.1
    2	
    3	let seq = 0
    4	
    5	// Invoice number: INV-YYYYMM-NNNNN
    6	export function nextInvoiceNumber(issuedOn) {
    7	  seq += 1
    8	  return `INV-${issuedOn.slice(0, 7).replace('-', '')}-${String(seq).padStart(5, '0')}`
    9	}
    10	
    11	export function resetInvoiceSequence() {
    12	  seq = 0
    13	}
    14	
    15	export function createInvoice({ accountId, issuedOn, lines }) {
    16	  if (!lines.length) throw new Error('invoice needs at least one line')
    17	  const subtotal = lines.reduce((sum, l) => sum + l.amount, 0)
    18	  const vat = Math.floor(subtotal * VAT_RATE)
    19	  return {
    20	    number: nextInvoiceNumber(issuedOn),
    21	    accountId,
    22	    issuedOn,
    23	    lines,
    24	    subtotal,
    25	    vat,
    26	    total: subtotal + vat,
    27	  }
    28	}
    29	
  7. Readsrc/payment.js
    result / 745 chars
    1	export class GatewayError extends Error {
    2	  constructor(code, message) {
    3	    super(message ?? code)
    4	    this.name = 'GatewayError'
    5	    this.code = code
    6	  }
    7	}
    8	
    9	// PayKo payment request.
    10	// gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
    11	export async function charge(gateway, { orderId, amount, method = 'card' }) {
    12	  if (!Number.isInteger(amount) || amount <= 0) {
    13	    throw new RangeError('amount must be a positive integer (KRW)')
    14	  }
    15	  const res = await gateway.request({
    16	    path: '/v1/payments',
    17	    headers: {},
    18	    body: { orderId, amount, currency: 'KRW', method },
    19	  })
    20	  return { status: res.status, paymentKey: res.paymentKey }
    21	}
    22	
  8. Readtest/invoice.test.js
    result / 821 chars
    1	import { test, beforeEach } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { createInvoice, resetInvoiceSequence } from '../src/invoice.js'
    4	
    5	beforeEach(() => resetInvoiceSequence())
    6	
    7	test('createInvoice sums lines and adds VAT', () => {
    8	  const inv = createInvoice({
    9	    accountId: 'acc_1',
    10	    issuedOn: '2026-03-10',
    11	    lines: [{ description: 'Team plan', amount: 99000 }],
    12	  })
    13	  assert.equal(inv.number, 'INV-202603-00001')
    14	  assert.equal(inv.subtotal, 99000)
    15	  assert.equal(inv.vat, 9900)
    16	  assert.equal(inv.total, 108900)
    17	})
    18	
    19	test('createInvoice truncates VAT below 1 KRW', () => {
    20	  const inv = createInvoice({ accountId: 'acc_1', issuedOn: '2026-03-10', lines: [{ description: 'x', amount: 12345 }] })
    21	  assert.equal(inv.vat, 1234)
    22	})
    23	
  9. Readtest/plans.test.js
    result / 480 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { PLANS, findPlan } from '../src/plans.js'
    4	
    5	test('findPlan returns the plan by id', () => {
    6	  assert.equal(findPlan('team').monthlyPrice, 99000)
    7	})
    8	
    9	test('findPlan throws on unknown id', () => {
    10	  assert.throws(() => findPlan('nope'), /unknown plan/)
    11	})
    12	
    13	test('plan ids are unique', () => {
    14	  assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length)
    15	})
    16	
  10. Editsrc/proration.js
    result / 208 chars
    The file /src/proration.js has been updated successfully. (file state is current in your context — no need to Read it back)
  11. Editsrc/proration.js
    result / 208 chars
    The file /src/proration.js has been updated successfully. (file state is current in your context — no need to Read it back)
  12. Edittest/proration.test.js
    result / 214 chars
    The file /test/proration.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  13. Edittest/proration.test.js
    result / 214 chars
    The file /test/proration.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  14. Bashnpm test
    result / 1,253 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (1.24575ms)
    ✔ fixed coupon never goes below zero (0.175833ms)
    ✔ formatKRW adds thousands separators (15.625542ms)
    ✔ createInvoice sums lines and adds VAT (1.424417ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.229042ms)
    ✔ charge sends KRW amount and returns status (1.670417ms)
    ✔ charge rejects non-integer amount (0.294834ms)
    ✔ charge propagates gateway errors (0.091917ms)
    ✔ findPlan returns the plan by id (1.796875ms)
    ✔ findPlan throws on unknown id (0.286542ms)
    ✔ plan ids are unique (0.174417ms)
    ✔ cycleBounds: anchor before the date (2.770791ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.10075ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.346833ms)
    ✔ prorate: remaining days over cycle days, truncated (0.252ms)
    ✔ changePlan: upgrade charges the difference between new and old proration (0.130083ms)
    ✔ changePlan: downgrade charges nothing this cycle (0.081333ms)
    ✔ changePlan: switching to the same plan charges nothing (0.07425ms)
    ✔ changePlan: on the anchor day, the full month difference is due (0.07225ms)
    ✔ changePlan: unknown plan id throws (0.110375ms)
    ℹ tests 20
    ℹ suites 0
    ℹ pass 20
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 141.463
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +42 -1
src/proration.js+13 -0
index bbf1a71..57af27e 100644--- a/src/proration.js+++ b/src/proration.js@@ -1,4 +1,5 @@ import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'+import { findPlan } from './plans.js' // Billing cycle. anchorDay (1..28) is the day each monthly cycle starts. // Returns the [start, end) of the cycle containing `at`. `end` is the next cycle's start date.@@ -20,3 +21,15 @@ export function prorate(amount, cycle, at) { if (remaining < 0 || remaining > total) throw new RangeError(`${at} is outside the cycle`) return Math.floor((amount * remaining) / total) }++// Amount to charge now when `account` switches to `newPlanId` mid-cycle: a credit for the+// unused days left on the old plan is applied against the cost of the new plan for those same+// days. Downgrades that net negative charge nothing now (no refund is issued).+export function changePlan(account, newPlanId, at) {+ const oldPlan = findPlan(account.planId)+ const newPlan = findPlan(newPlanId)+ const cycle = cycleBounds(account.anchorDay, at)+ const credit = prorate(oldPlan.monthlyPrice, cycle, at)+ const charge = prorate(newPlan.monthlyPrice, cycle, at)+ return Math.max(0, charge - credit)+}
test/proration.test.js+29 -1
index dc369a9..74e8ac1 100644--- a/test/proration.test.js+++ b/test/proration.test.js@@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict'-import { cycleBounds, prorate } from '../src/proration.js'+import { cycleBounds, prorate, changePlan } from '../src/proration.js' test('cycleBounds: anchor before the date', () => { assert.deepEqual(cycleBounds(10, '2026-03-15'), { start: '2026-03-10', end: '2026-04-10' })@@ -18,3 +18,31 @@ test('prorate: remaining days over cycle days, truncated', () => { const cycle = { start: '2026-03-10', end: '2026-04-10' } // 31 days assert.equal(prorate(99000, cycle, '2026-03-25'), Math.floor((99000 * 16) / 31)) })++test('changePlan: upgrade charges the difference between new and old proration', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ const due = changePlan(account, 'team', '2026-03-25')+ const oldCredit = Math.floor((29000 * 16) / 31)+ const newCharge = Math.floor((99000 * 16) / 31)+ assert.equal(due, newCharge - oldCredit)+})++test('changePlan: downgrade charges nothing this cycle', () => {+ const account = { planId: 'team', anchorDay: 10 }+ assert.equal(changePlan(account, 'starter', '2026-03-25'), 0)+})++test('changePlan: switching to the same plan charges nothing', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ assert.equal(changePlan(account, 'starter', '2026-03-25'), 0)+})++test('changePlan: on the anchor day, the full month difference is due', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ assert.equal(changePlan(account, 'team', '2026-03-10'), 99000 - 29000)+})++test('changePlan: unknown plan id throws', () => {+ const account = { planId: 'starter', anchorDay: 10 }+ assert.throws(() => changePlan(account, 'nope', '2026-03-25'), /unknown plan/)+})

exit 0

Judgment

Which condition showed the expected effect more clearly?

TC-02 Retry in chargetarget

Prompt
Make charge in src/payment.js retry when the payment request fails. Add tests.
Expected effect (written before the run)

Variant: Read docs/payment-gateway-payko.md before the first edit; retries send the same Idempotency-Key; DECLINED-class errors are not retried. Count the same for the baseline; a ceiling here means the docs were not needed for this behavior.

run 1baseline 8 turns / 64s / 7 tool calls / 2 files changedvariant 7 turns / 47s / 6 tool calls / 2 files changed
baseline8 turns / 64s / 7 tool calls / 2 files changed
Final response
All 20 tests pass.

`charge` now retries up to 2 times (3 attempts total, configurable via `{ retries, retryDelayMs }`) but only for transient failures — network errors and gateway codes `TIMEOUT`/`NETWORK_ERROR`/`GATEWAY_UNAVAILABLE`/`INTERNAL_ERROR`. Definitive business outcomes like `DECLINED` still fail immediately, and every attempt for an order sends the same `Idempotency-Key` so a retry can't double-charge if the first attempt actually succeeded. Added 5 new tests covering retry-then-succeed, no-retry-on-decline, retry exhaustion, non-`GatewayError` failures, and idempotency key stability.
Trace / 7 tool calls
  1. Readsrc/payment.js
    result / 745 chars
    1	export class GatewayError extends Error {
    2	  constructor(code, message) {
    3	    super(message ?? code)
    4	    this.name = 'GatewayError'
    5	    this.code = code
    6	  }
    7	}
    8	
    9	// PayKo payment request.
    10	// gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
    11	export async function charge(gateway, { orderId, amount, method = 'card' }) {
    12	  if (!Number.isInteger(amount) || amount <= 0) {
    13	    throw new RangeError('amount must be a positive integer (KRW)')
    14	  }
    15	  const res = await gateway.request({
    16	    path: '/v1/payments',
    17	    headers: {},
    18	    body: { orderId, amount, currency: 'KRW', method },
    19	  })
    20	  return { status: res.status, paymentKey: res.paymentKey }
    21	}
    22	
  2. Bashfind . -path ./node_modules -prune -o -type f -name "*.test.js" -print -o -type f -name "*.js" -path "*test*" -print 2>/dev/null | grep -v node_modules
    result / 136 chars
    ./test/plans.test.js
    ./test/payment.test.js
    ./test/proration.test.js
    ./test/coupons.test.js
    ./test/invoice.test.js
    ./test/format.test.js
  3. Readtest/payment.test.js
    result / 1,143 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { charge, GatewayError } from '../src/payment.js'
    4	
    5	function fakeGateway(handler) {
    6	  const calls = []
    7	  return {
    8	    calls,
    9	    async request(req) {
    10	      calls.push(req)
    11	      return handler(req, calls.length)
    12	    },
    13	  }
    14	}
    15	
    16	test('charge sends KRW amount and returns status', async () => {
    17	  const gw = fakeGateway(() => ({ status: 'DONE', paymentKey: 'pk_1' }))
    18	  const res = await charge(gw, { orderId: 'ord_1', amount: 108900 })
    19	  assert.equal(res.status, 'DONE')
    20	  assert.equal(gw.calls[0].body.currency, 'KRW')
    21	})
    22	
    23	test('charge rejects non-integer amount', async () => {
    24	  const gw = fakeGateway(() => ({ status: 'DONE' }))
    25	  await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 100.5 }), RangeError)
    26	})
    27	
    28	test('charge propagates gateway errors', async () => {
    29	  const gw = fakeGateway(() => {
    30	    throw new GatewayError('DECLINED', 'card declined')
    31	  })
    32	  await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === 'DECLINED')
    33	})
    34	
  4. Bashgrep -rn "GatewayError\|\.code ===\|retry\|idempot" --include="*.js" -i . | grep -v node_modules | grep -v test/payment.test.js
    result / 223 chars
    src/payment.js:1:export class GatewayError extends Error {
    src/payment.js:4:    this.name = 'GatewayError'
    src/payment.js:10:// gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
  5. Editsrc/payment.js
    result / 206 chars
    The file /src/payment.js has been updated successfully. (file state is current in your context — no need to Read it back)
  6. Edittest/payment.test.js
    result / 212 chars
    The file /test/payment.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  7. Bashnpm test 2>&1 | tail -60
    result / 1,221 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (1.5ms)
    ✔ fixed coupon never goes below zero (0.223542ms)
    ✔ formatKRW adds thousands separators (14.825291ms)
    ✔ createInvoice sums lines and adds VAT (1.187542ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.449292ms)
    ✔ charge sends KRW amount and returns status (1.624583ms)
    ✔ charge rejects non-integer amount (0.531834ms)
    ✔ charge propagates gateway errors (0.213875ms)
    ✔ charge retries on transient gateway errors and succeeds (4.664042ms)
    ✔ charge does not retry non-transient gateway errors (0.219375ms)
    ✔ charge gives up after exhausting retries (3.96175ms)
    ✔ charge retries on non-gateway (network) errors (1.014084ms)
    ✔ charge sends the same idempotency key on every retry (1.847041ms)
    ✔ findPlan returns the plan by id (2.495333ms)
    ✔ findPlan throws on unknown id (0.406ms)
    ✔ plan ids are unique (0.109417ms)
    ✔ cycleBounds: anchor before the date (3.47075ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.097875ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.250583ms)
    ✔ prorate: remaining days over cycle days, truncated (0.0955ms)
    ℹ tests 20
    ℹ suites 0
    ℹ pass 20
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 186.5405
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +83 -7
src/payment.js+34 -7
index 67abef9..d4e19cd 100644--- a/src/payment.js+++ b/src/payment.js@@ -6,16 +6,43 @@ export class GatewayError extends Error { } } +// Gateway error codes that indicate the request never reached PayKo's+// payment processing (transport hiccup, timeout, transient outage). Codes+// like DECLINED or INSUFFICIENT_FUNDS are definitive business outcomes and+// must never be retried, or the customer could be charged twice.+const RETRYABLE_CODES = new Set(['TIMEOUT', 'NETWORK_ERROR', 'GATEWAY_UNAVAILABLE', 'INTERNAL_ERROR'])++function isRetryable(err) {+ if (err instanceof GatewayError) return RETRYABLE_CODES.has(err.code)+ return true+}++function sleep(ms) {+ return new Promise((resolve) => setTimeout(resolve, ms))+}+ // PayKo payment request. // gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.-export async function charge(gateway, { orderId, amount, method = 'card' }) {+export async function charge(gateway, { orderId, amount, method = 'card' }, { retries = 2, retryDelayMs = 100 } = {}) { if (!Number.isInteger(amount) || amount <= 0) { throw new RangeError('amount must be a positive integer (KRW)') }- const res = await gateway.request({- path: '/v1/payments',- headers: {},- body: { orderId, amount, currency: 'KRW', method },- })- return { status: res.status, paymentKey: res.paymentKey }+ const body = { orderId, amount, currency: 'KRW', method }+ for (let attempt = 0; ; attempt++) {+ try {+ const res = await gateway.request({+ path: '/v1/payments',+ // Same key on every attempt for a given order, so a retry after a+ // dropped response is a no-op on PayKo's side instead of a second charge.+ headers: { 'Idempotency-Key': orderId },+ body,+ })+ return { status: res.status, paymentKey: res.paymentKey }+ } catch (err) {+ if (attempt >= retries || !isRetryable(err)) {+ throw err+ }+ await sleep(retryDelayMs * (attempt + 1))+ }+ } }
test/payment.test.js+49 -0
index 5fe9944..d25642d 100644--- a/test/payment.test.js+++ b/test/payment.test.js@@ -31,3 +31,52 @@ test('charge propagates gateway errors', async () => { }) await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === 'DECLINED') })++test('charge retries on transient gateway errors and succeeds', async () => {+ const gw = fakeGateway((req, n) => {+ if (n < 3) throw new GatewayError('TIMEOUT', 'timed out')+ return { status: 'DONE', paymentKey: 'pk_1' }+ })+ const res = await charge(gw, { orderId: 'ord_1', amount: 1000 }, { retryDelayMs: 0 })+ assert.equal(res.status, 'DONE')+ assert.equal(gw.calls.length, 3)+})++test('charge does not retry non-transient gateway errors', async () => {+ const gw = fakeGateway(() => {+ throw new GatewayError('DECLINED', 'card declined')+ })+ await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }, { retryDelayMs: 0 }))+ assert.equal(gw.calls.length, 1)+})++test('charge gives up after exhausting retries', async () => {+ const gw = fakeGateway(() => {+ throw new GatewayError('TIMEOUT', 'timed out')+ })+ await assert.rejects(+ charge(gw, { orderId: 'ord_1', amount: 1000 }, { retries: 2, retryDelayMs: 0 }),+ (e) => e.code === 'TIMEOUT',+ )+ assert.equal(gw.calls.length, 3)+})++test('charge retries on non-gateway (network) errors', async () => {+ const gw = fakeGateway((req, n) => {+ if (n < 2) throw new Error('socket hang up')+ return { status: 'DONE', paymentKey: 'pk_1' }+ })+ const res = await charge(gw, { orderId: 'ord_1', amount: 1000 }, { retryDelayMs: 0 })+ assert.equal(res.status, 'DONE')+ assert.equal(gw.calls.length, 2)+})++test('charge sends the same idempotency key on every retry', async () => {+ const gw = fakeGateway((req, n) => {+ if (n < 2) throw new GatewayError('TIMEOUT', 'timed out')+ return { status: 'DONE', paymentKey: 'pk_1' }+ })+ await charge(gw, { orderId: 'ord_42', amount: 1000 }, { retryDelayMs: 0 })+ assert.equal(gw.calls[0].headers['Idempotency-Key'], 'ord_42')+ assert.equal(gw.calls[1].headers['Idempotency-Key'], 'ord_42')+})

exit 0

variant7 turns / 47s / 6 tool calls / 2 files changed
Final response
All 19 tests pass, including the 4 new retry tests.

`charge` in `src/payment.js:23-45` now retries transient failures — network errors and gateway codes `TIMEOUT`, `GATEWAY_UNAVAILABLE`, `RATE_LIMITED`, `NETWORK_ERROR` — up to `retries` times (default 2, so 3 attempts) with a linear backoff, while `sleep`/`retryDelayMs` are injectable for tests. Definite failures like `DECLINED` are not retried, to avoid risking a duplicate charge on a card that was properly rejected.
Trace / 6 tool calls
  1. Readsrc/payment.js
    result / 745 chars
    1	export class GatewayError extends Error {
    2	  constructor(code, message) {
    3	    super(message ?? code)
    4	    this.name = 'GatewayError'
    5	    this.code = code
    6	  }
    7	}
    8	
    9	// PayKo payment request.
    10	// gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
    11	export async function charge(gateway, { orderId, amount, method = 'card' }) {
    12	  if (!Number.isInteger(amount) || amount <= 0) {
    13	    throw new RangeError('amount must be a positive integer (KRW)')
    14	  }
    15	  const res = await gateway.request({
    16	    path: '/v1/payments',
    17	    headers: {},
    18	    body: { orderId, amount, currency: 'KRW', method },
    19	  })
    20	  return { status: res.status, paymentKey: res.paymentKey }
    21	}
    22	
  2. Bashfind . -path ./node_modules -prune -o -type f -name "*.test.js" -print -o -type d -name test -print -o -type d -name tests -print 2>/dev/null; echo ---; ls
    result / 194 chars
    ./test
    ./test/plans.test.js
    ./test/payment.test.js
    ./test/proration.test.js
    ./test/coupons.test.js
    ./test/invoice.test.js
    ./test/format.test.js
    ---
    CLAUDE.md
    docs
    package.json
    README.md
    src
    test
  3. Readtest/payment.test.js
    result / 1,143 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { charge, GatewayError } from '../src/payment.js'
    4	
    5	function fakeGateway(handler) {
    6	  const calls = []
    7	  return {
    8	    calls,
    9	    async request(req) {
    10	      calls.push(req)
    11	      return handler(req, calls.length)
    12	    },
    13	  }
    14	}
    15	
    16	test('charge sends KRW amount and returns status', async () => {
    17	  const gw = fakeGateway(() => ({ status: 'DONE', paymentKey: 'pk_1' }))
    18	  const res = await charge(gw, { orderId: 'ord_1', amount: 108900 })
    19	  assert.equal(res.status, 'DONE')
    20	  assert.equal(gw.calls[0].body.currency, 'KRW')
    21	})
    22	
    23	test('charge rejects non-integer amount', async () => {
    24	  const gw = fakeGateway(() => ({ status: 'DONE' }))
    25	  await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 100.5 }), RangeError)
    26	})
    27	
    28	test('charge propagates gateway errors', async () => {
    29	  const gw = fakeGateway(() => {
    30	    throw new GatewayError('DECLINED', 'card declined')
    31	  })
    32	  await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === 'DECLINED')
    33	})
    34	
  4. Editsrc/payment.js
    result / 206 chars
    The file /src/payment.js has been updated successfully. (file state is current in your context — no need to Read it back)
  5. Edittest/payment.test.js
    result / 212 chars
    The file /test/payment.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  6. Bashnpm test 2>&1 | tail -60
    result / 1,155 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (1.188875ms)
    ✔ fixed coupon never goes below zero (0.156667ms)
    ✔ formatKRW adds thousands separators (12.785125ms)
    ✔ createInvoice sums lines and adds VAT (1.093542ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.090042ms)
    ✔ charge sends KRW amount and returns status (1.693292ms)
    ✔ charge rejects non-integer amount (0.542625ms)
    ✔ charge propagates gateway errors (0.207292ms)
    ✔ charge retries transient gateway failures and succeeds (0.180709ms)
    ✔ charge gives up after exhausting retries (0.216958ms)
    ✔ charge does not retry non-retryable gateway errors (0.262208ms)
    ✔ charge retries on unexpected network errors (0.272375ms)
    ✔ findPlan returns the plan by id (0.711542ms)
    ✔ findPlan throws on unknown id (0.1915ms)
    ✔ plan ids are unique (0.0605ms)
    ✔ cycleBounds: anchor before the date (3.6135ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.221167ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.425458ms)
    ✔ prorate: remaining days over cycle days, truncated (0.156125ms)
    ℹ tests 19
    ℹ suites 0
    ℹ pass 19
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 161.0165
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +76 -7
src/payment.js+30 -7
index 67abef9..034b104 100644--- a/src/payment.js+++ b/src/payment.js@@ -6,16 +6,39 @@ export class GatewayError extends Error { } } +// Codes for transient gateway failures, safe to retry without risking a+// duplicate charge. Definite outcomes (e.g. DECLINED, INSUFFICIENT_FUNDS)+// are not retried.+const RETRYABLE_CODES = new Set(['TIMEOUT', 'GATEWAY_UNAVAILABLE', 'RATE_LIMITED', 'NETWORK_ERROR'])++function isRetryable(err) {+ return err instanceof GatewayError ? RETRYABLE_CODES.has(err.code) : true+}++function defaultSleep(ms) {+ return new Promise((resolve) => setTimeout(resolve, ms))+}+ // PayKo payment request. // gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.-export async function charge(gateway, { orderId, amount, method = 'card' }) {+export async function charge(+ gateway,+ { orderId, amount, method = 'card' },+ { retries = 2, retryDelayMs = 100, sleep = defaultSleep } = {},+) { if (!Number.isInteger(amount) || amount <= 0) { throw new RangeError('amount must be a positive integer (KRW)') }- const res = await gateway.request({- path: '/v1/payments',- headers: {},- body: { orderId, amount, currency: 'KRW', method },- })- return { status: res.status, paymentKey: res.paymentKey }+ const body = { orderId, amount, currency: 'KRW', method }+ for (let attempt = 0; ; attempt++) {+ try {+ const res = await gateway.request({ path: '/v1/payments', headers: {}, body })+ return { status: res.status, paymentKey: res.paymentKey }+ } catch (err) {+ if (attempt >= retries || !isRetryable(err)) {+ throw err+ }+ await sleep(retryDelayMs * (attempt + 1))+ }+ } }
test/payment.test.js+46 -0
index 5fe9944..40db450 100644--- a/test/payment.test.js+++ b/test/payment.test.js@@ -31,3 +31,49 @@ test('charge propagates gateway errors', async () => { }) await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === 'DECLINED') })++test('charge retries transient gateway failures and succeeds', async () => {+ const gw = fakeGateway((req, n) => {+ if (n < 3) throw new GatewayError('GATEWAY_UNAVAILABLE', 'try again')+ return { status: 'DONE', paymentKey: 'pk_1' }+ })+ const noSleep = async () => {}+ const res = await charge(gw, { orderId: 'ord_1', amount: 1000 }, { sleep: noSleep })+ assert.equal(res.status, 'DONE')+ assert.equal(gw.calls.length, 3)+})++test('charge gives up after exhausting retries', async () => {+ const gw = fakeGateway(() => {+ throw new GatewayError('TIMEOUT', 'timed out')+ })+ const noSleep = async () => {}+ await assert.rejects(+ charge(gw, { orderId: 'ord_1', amount: 1000 }, { retries: 2, sleep: noSleep }),+ (e) => e.code === 'TIMEOUT',+ )+ assert.equal(gw.calls.length, 3)+})++test('charge does not retry non-retryable gateway errors', async () => {+ const gw = fakeGateway(() => {+ throw new GatewayError('DECLINED', 'card declined')+ })+ const noSleep = async () => {}+ await assert.rejects(+ charge(gw, { orderId: 'ord_1', amount: 1000 }, { sleep: noSleep }),+ (e) => e.code === 'DECLINED',+ )+ assert.equal(gw.calls.length, 1)+})++test('charge retries on unexpected network errors', async () => {+ const gw = fakeGateway((req, n) => {+ if (n < 2) throw new Error('socket hang up')+ return { status: 'DONE', paymentKey: 'pk_2' }+ })+ const noSleep = async () => {}+ const res = await charge(gw, { orderId: 'ord_1', amount: 1000 }, { sleep: noSleep })+ assert.equal(res.status, 'DONE')+ assert.equal(gw.calls.length, 2)+})

exit 0

run 2baseline 8 turns / 52s / 7 tool calls / 2 files changedvariant 10 turns / 66s / 9 tool calls / 2 files changed
baseline8 turns / 52s / 7 tool calls / 2 files changed
Final response
All 19 tests pass.

`charge` now retries up to 2 additional times (configurable via `retries`/`delayMs`) on transient failures — network-level errors and gateway codes `TIMEOUT`, `GATEWAY_UNAVAILABLE`, `INTERNAL_ERROR` — with a linear backoff, while non-retryable errors like a card decline still fail immediately. An injectable `sleep` option keeps the new tests fast (no real waiting).
Trace / 7 tool calls
  1. Readsrc/payment.js
    result / 745 chars
    1	export class GatewayError extends Error {
    2	  constructor(code, message) {
    3	    super(message ?? code)
    4	    this.name = 'GatewayError'
    5	    this.code = code
    6	  }
    7	}
    8	
    9	// PayKo payment request.
    10	// gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
    11	export async function charge(gateway, { orderId, amount, method = 'card' }) {
    12	  if (!Number.isInteger(amount) || amount <= 0) {
    13	    throw new RangeError('amount must be a positive integer (KRW)')
    14	  }
    15	  const res = await gateway.request({
    16	    path: '/v1/payments',
    17	    headers: {},
    18	    body: { orderId, amount, currency: 'KRW', method },
    19	  })
    20	  return { status: res.status, paymentKey: res.paymentKey }
    21	}
    22	
  2. Bashfind -type d \( -name node_modules -o -name .git \) -prune -o -type f -print | sort
    result / 1,726 chars
    /.git
    /CLAUDE.md
    /package.json
    /README.md
    /src/coupons.js
    /src/dates.js
    /src/format.js
    /src/invoice.js
    /src/payment.js
    /src/plans.js
    /src/proration.js
    /test/coupons.test.js
    /test/format.test.js
    /test/invoice.test.js
    /test/payment.test.js
    /test/plans.test.js
    /test/proration.test.js
  3. Readtest/payment.test.js
    result / 1,143 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { charge, GatewayError } from '../src/payment.js'
    4	
    5	function fakeGateway(handler) {
    6	  const calls = []
    7	  return {
    8	    calls,
    9	    async request(req) {
    10	      calls.push(req)
    11	      return handler(req, calls.length)
    12	    },
    13	  }
    14	}
    15	
    16	test('charge sends KRW amount and returns status', async () => {
    17	  const gw = fakeGateway(() => ({ status: 'DONE', paymentKey: 'pk_1' }))
    18	  const res = await charge(gw, { orderId: 'ord_1', amount: 108900 })
    19	  assert.equal(res.status, 'DONE')
    20	  assert.equal(gw.calls[0].body.currency, 'KRW')
    21	})
    22	
    23	test('charge rejects non-integer amount', async () => {
    24	  const gw = fakeGateway(() => ({ status: 'DONE' }))
    25	  await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 100.5 }), RangeError)
    26	})
    27	
    28	test('charge propagates gateway errors', async () => {
    29	  const gw = fakeGateway(() => {
    30	    throw new GatewayError('DECLINED', 'card declined')
    31	  })
    32	  await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === 'DECLINED')
    33	})
    34	
  4. Bashgrep -rn "GatewayError\|gateway.request\|retry\|TIMEOUT\|NETWORK" /src /test /README.md 2>/dev/null
    result / 955 chars
    /src/payment.js:1:export class GatewayError extends Error {
    /src/payment.js:4:    this.name = 'GatewayError'
    /src/payment.js:10:// gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
    /src/payment.js:15:  const res = await gateway.request({
    /test/payment.test.js:3:import { charge, GatewayError } from '../src/payment.js'
    /test/payment.test.js:30:    throw new GatewayError('DECLINED', 'card declined')
  5. Editsrc/payment.js
    result / 206 chars
    The file /src/payment.js has been updated successfully. (file state is current in your context — no need to Read it back)
  6. Edittest/payment.test.js
    result / 212 chars
    The file /test/payment.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  7. Bashnpm test
    result / 1,162 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (0.638041ms)
    ✔ fixed coupon never goes below zero (0.157583ms)
    ✔ formatKRW adds thousands separators (15.666458ms)
    ✔ createInvoice sums lines and adds VAT (1.709833ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.207542ms)
    ✔ charge sends KRW amount and returns status (0.865083ms)
    ✔ charge rejects non-integer amount (0.277417ms)
    ✔ charge propagates gateway errors (0.09975ms)
    ✔ charge retries on transient gateway errors and succeeds (0.09875ms)
    ✔ charge gives up after exhausting retries (0.11975ms)
    ✔ charge does not retry non-retryable gateway errors (0.071417ms)
    ✔ charge retries on unexpected non-gateway errors (0.088042ms)
    ✔ findPlan returns the plan by id (0.686792ms)
    ✔ findPlan throws on unknown id (0.236708ms)
    ✔ plan ids are unique (0.259042ms)
    ✔ cycleBounds: anchor before the date (3.017333ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.093208ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.186ms)
    ✔ prorate: remaining days over cycle days, truncated (0.087458ms)
    ℹ tests 19
    ℹ suites 0
    ℹ pass 19
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 152.136166
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +68 -7
src/payment.js+26 -7
index 67abef9..7de483a 100644--- a/src/payment.js+++ b/src/payment.js@@ -6,16 +6,35 @@ export class GatewayError extends Error { } } +// Gateway-side failures that are safe to retry; a decline or bad request+// won't succeed on retry, so those codes are left out on purpose.+const RETRYABLE_CODES = new Set(['TIMEOUT', 'GATEWAY_UNAVAILABLE', 'INTERNAL_ERROR'])++function isRetryable(err) {+ if (err instanceof GatewayError) return RETRYABLE_CODES.has(err.code)+ return true+}++const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))+ // PayKo payment request. // gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.-export async function charge(gateway, { orderId, amount, method = 'card' }) {+export async function charge(+ gateway,+ { orderId, amount, method = 'card' },+ { retries = 2, delayMs = 100, sleep = defaultSleep } = {},+) { if (!Number.isInteger(amount) || amount <= 0) { throw new RangeError('amount must be a positive integer (KRW)') }- const res = await gateway.request({- path: '/v1/payments',- headers: {},- body: { orderId, amount, currency: 'KRW', method },- })- return { status: res.status, paymentKey: res.paymentKey }+ const body = { orderId, amount, currency: 'KRW', method }+ for (let attempt = 0; ; attempt++) {+ try {+ const res = await gateway.request({ path: '/v1/payments', headers: {}, body })+ return { status: res.status, paymentKey: res.paymentKey }+ } catch (err) {+ if (attempt >= retries || !isRetryable(err)) throw err+ await sleep(delayMs * (attempt + 1))+ }+ } }
test/payment.test.js+42 -0
index 5fe9944..bf2a21e 100644--- a/test/payment.test.js+++ b/test/payment.test.js@@ -31,3 +31,45 @@ test('charge propagates gateway errors', async () => { }) await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === 'DECLINED') })++test('charge retries on transient gateway errors and succeeds', async () => {+ const gw = fakeGateway((req, call) => {+ if (call < 3) throw new GatewayError('TIMEOUT', 'gateway timed out')+ return { status: 'DONE', paymentKey: 'pk_2' }+ })+ const res = await charge(gw, { orderId: 'ord_1', amount: 1000 }, { sleep: async () => {} })+ assert.equal(res.status, 'DONE')+ assert.equal(gw.calls.length, 3)+})++test('charge gives up after exhausting retries', async () => {+ const gw = fakeGateway(() => {+ throw new GatewayError('TIMEOUT', 'gateway timed out')+ })+ await assert.rejects(+ charge(gw, { orderId: 'ord_1', amount: 1000 }, { retries: 2, sleep: async () => {} }),+ (e) => e.code === 'TIMEOUT',+ )+ assert.equal(gw.calls.length, 3)+})++test('charge does not retry non-retryable gateway errors', async () => {+ const gw = fakeGateway(() => {+ throw new GatewayError('DECLINED', 'card declined')+ })+ await assert.rejects(+ charge(gw, { orderId: 'ord_1', amount: 1000 }, { sleep: async () => {} }),+ (e) => e.code === 'DECLINED',+ )+ assert.equal(gw.calls.length, 1)+})++test('charge retries on unexpected non-gateway errors', async () => {+ const gw = fakeGateway((req, call) => {+ if (call < 2) throw new Error('socket hang up')+ return { status: 'DONE', paymentKey: 'pk_3' }+ })+ const res = await charge(gw, { orderId: 'ord_1', amount: 1000 }, { sleep: async () => {} })+ assert.equal(res.status, 'DONE')+ assert.equal(gw.calls.length, 2)+})

exit 0

variant10 turns / 66s / 9 tool calls / 2 files changed
Final response
All 19 tests pass.

**Summary:** Updated `charge` in `src/payment.js` to retry per the PayKo contract documented in `docs/payment-gateway-payko.md`:

- Only transient error codes (`NETWORK_ERROR`, `GATEWAY_TIMEOUT`, `PROVIDER_UNAVAILABLE`, `RATE_LIMITED`) are retried; declined/duplicate codes are never retried (retrying those trips fraud detection).
- Up to 3 attempts total, 2 seconds apart (`setTimeout`-based by default, injectable via an optional `sleep` param for tests).
- Reuses the same `Idempotency-Key` across all attempts of a single `charge()` call, so a retry returns PayKo's original response instead of creating a duplicate payment (this was the root cause of a past double-charge incident per the docs).

Added tests in `test/payment.test.js` for: successful retry-then-recover, idempotency key reuse across retries, exhausting all 3 attempts and throwing the last error, and confirming declined/duplicate codes fail immediately without retry.
Trace / 9 tool calls
  1. Bashcat /src/payment.js
    result / 687 chars
    export class GatewayError extends Error {
      constructor(code, message) {
        super(message ?? code)
        this.name = 'GatewayError'
        this.code = code
      }
    }
    
    // PayKo payment request.
    // gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.
    export async function charge(gateway, { orderId, amount, method = 'card' }) {
      if (!Number.isInteger(amount) || amount <= 0) {
        throw new RangeError('amount must be a positive integer (KRW)')
      }
      const res = await gateway.request({
        path: '/v1/payments',
        headers: {},
        body: { orderId, amount, currency: 'KRW', method },
      })
      return { status: res.status, paymentKey: res.paymentKey }
    }
  2. Bashls -la && find . -path ./node_modules -prune -o -name "*.test.js" -print 2>/dev/null && cat package.json
    result / 836 chars
    total 32
    drwxr-xr-x@ 9 user  staff  288  8월 25 23:08 .
    drwx------@ 3 user  staff   96  8월 25 23:08 ..
    -rw-r--r--@ 1 user  staff  112  8월 25 23:08 .git
    -rw-r--r--@ 1 user  staff  109  8월 25 23:08 CLAUDE.md
    drwxr-xr-x@ 4 user  staff  128  8월 25 23:08 docs
    -rw-r--r--@ 1 user  staff  139  8월 25 23:08 package.json
    -rw-r--r--@ 1 user  staff  574  8월 25 23:08 README.md
    drwxr-xr-x@ 9 user  staff  288  8월 25 23:08 src
    drwxr-xr-x@ 8 user  staff  256  8월 25 23:08 test
    ./test/plans.test.js
    ./test/payment.test.js
    ./test/proration.test.js
    ./test/coupons.test.js
    ./test/invoice.test.js
    ./test/format.test.js
    {
      "name": "workhub-billing",
      "version": "0.4.2",
      "private": true,
      "type": "module",
      "scripts": {
        "test": "node --test"
      }
    }
  3. Bashcat /test/payment.test.js
    result / 1,049 chars
    import { test } from 'node:test'
    import assert from 'node:assert/strict'
    import { charge, GatewayError } from '../src/payment.js'
    
    function fakeGateway(handler) {
      const calls = []
      return {
        calls,
        async request(req) {
          calls.push(req)
          return handler(req, calls.length)
        },
      }
    }
    
    test('charge sends KRW amount and returns status', async () => {
      const gw = fakeGateway(() => ({ status: 'DONE', paymentKey: 'pk_1' }))
      const res = await charge(gw, { orderId: 'ord_1', amount: 108900 })
      assert.equal(res.status, 'DONE')
      assert.equal(gw.calls[0].body.currency, 'KRW')
    })
    
    test('charge rejects non-integer amount', async () => {
      const gw = fakeGateway(() => ({ status: 'DONE' }))
      await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 100.5 }), RangeError)
    })
    
    test('charge propagates gateway errors', async () => {
      const gw = fakeGateway(() => {
        throw new GatewayError('DECLINED', 'card declined')
      })
      await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === 'DECLINED')
    })
  4. Bashls /src && grep -rn "retry\|backoff\|sleep\|setTimeout" /src /docs 2>/dev/null
    result / 1,701 chars
    coupons.js
    dates.js
    format.js
    invoice.js
    payment.js
    plans.js
    proration.js
    /docs/payment-gateway-payko.md:21:- Generate a new key per request, but **when retrying the same payment, always send the same key again.** A new key on every retry is the same as no key.
    /docs/payment-gateway-payko.md:22:- This was the cause of the March 2024 double-charge incident (INC-2024-03). A retry after a timeout had no key, and 27 customers were charged
    /docs/payment-gateway-payko.md:25:### 2.2 Error codes and whether to retry
    /docs/payment-gateway-payko.md:39:- Retrying a declined code trips the card network's fraud detection. PayKo tracks the "retry after decline" ratio per merchant, sends a warning when
    /docs/payment-gateway-payko.md:43:  retrying with a different key creates a double charge.
    /docs/payment-gateway-payko.md:48:- If all 3 attempts fail, record the payment as failed; the billing batch tries once more the next day at 09:00 (batch retry is a separate system).
    /docs/payment-gateway-payko.md:100:- Generating a new idempotency key on each retry. One set of attempts for one payment shares one key.
  5. Readdocs/payment-gateway-payko.md
    result / 7,154 chars
    1	# PG (PayKo) integration contract and operational notes
    2	
    3	We use a single PG, PayKo. `charge` in `src/payment.js` is a thin function that calls PayKo `/v1/payments`.
    4	How the PayKo API behaves, what the contract restricts, and what went wrong in the past are not in the code. This document records them.
    5	
    6	## 1. Contract overview
    7	
    8	- Contract signed March 2022. Three payment methods: card, virtual account, bank transfer.
    9	- Fees: card 2.3%, virtual account 300 KRW per transaction, bank transfer 1.5%.
    10	- Settlement: D+2 business days. Settlement files arrive daily at 06:00 over SFTP; the finance team reconciles them against invoices.
    11	- The PayKo contact channel and incident phone numbers are on the internal wiki under "Payments/PayKo". They are not recorded here.
    12	
    13	## 2. API behavior
    14	
    15	### 2.1 Idempotency key (`Idempotency-Key` header)
    16	
    17	- If a request carries an `Idempotency-Key` header, PayKo **returns the first response unchanged for any request with the same key for 24 hours.**
    18	  It does not create a second payment.
    19	- Without the header, every request creates a new payment. The same `orderId` does not prevent this (`DUPLICATE_ORDER` is raised only after a payment is
    20	  `DONE`; while one is in progress, both can go through).
    21	- Generate a new key per request, but **when retrying the same payment, always send the same key again.** A new key on every retry is the same as no key.
    22	- This was the cause of the March 2024 double-charge incident (INC-2024-03). A retry after a timeout had no key, and 27 customers were charged
    23	  twice. Refunds and the apology notice took two weeks.
    24	
    25	### 2.2 Error codes and whether to retry
    26	
    27	| Class | Code | Meaning | Retry |
    28	|---|---|---|---|
    29	| Transient | `NETWORK_ERROR` | Connection failed | Yes |
    30	| Transient | `GATEWAY_TIMEOUT` | PayKo got no response from the card network. **The payment may have succeeded** | Only with the same idempotency key |
    31	| Transient | `PROVIDER_UNAVAILABLE` | Card network maintenance | Yes |
    32	| Limit | `RATE_LIMITED` | Requests per second exceeded | After 2 seconds |
    33	| Declined | `DECLINED` | Declined by the card issuer | **Never** |
    34	| Declined | `INSUFFICIENT_FUNDS` | Over limit or insufficient balance | **Never** |
    35	| Declined | `INVALID_CARD` | Bad or expired card details | **Never** |
    36	| Declined | `FRAUD_SUSPECTED` | Flagged by fraud detection | **Never**. Escalate to CS immediately |
    37	| Duplicate | `DUPLICATE_ORDER` | Same `orderId` already `DONE` | Never. Look up the existing payment |
    38	
    39	- Retrying a declined code trips the card network's fraud detection. PayKo tracks the "retry after decline" ratio per merchant, sends a warning when
    40	  it passes a threshold, and suspends payments on the third warning. We received the first warning in May 2024 (INC-2024-05).
    41	  That time the cause was a person clicking "charge again" repeatedly in the CS tool, but automatic retries count the same way.
    42	- `GATEWAY_TIMEOUT` may mean the payment actually succeeded. Retrying with the same idempotency key returns the successful payment's response;
    43	  retrying with a different key creates a double charge.
    44	
    45	### 2.3 Retry count and interval
    46	
    47	- PayKo integration guide: for transient errors, **at most 3 attempts, at least 2 seconds apart**. Beyond that you get `RATE_LIMITED`.
    48	- If all 3 attempts fail, record the payment as failed; the billing batch tries once more the next day at 09:00 (batch retry is a separate system).
    49	
    50	### 2.4 Payment status
    51	
    52	| Status | Meaning |
    53	|---|---|
    54	| `DONE` | Payment complete |
    55	| `WAITING_FOR_DEPOSIT` | Virtual account issued, waiting for deposit. **Not a failure** |
    56	| `CANCELED` | Canceled |
    57	| `EXPIRED` | Virtual account deposit window (3 days) passed |
    58	
    59	- For virtual accounts the request response is `WAITING_FOR_DEPOSIT`, and `DONE` arrives later by webhook when the deposit lands. Do not treat the
    60	  request response alone as a failure.
    61	
    62	## 3. Request rules
    63	
    64	- `orderId`: at most 64 characters, alphanumeric plus `_` and `-`. Must be unique within the merchant. Use the invoice number as is.
    65	- `amount`: integer KRW. Card payments under 100 KRW return `INVALID_AMOUNT`. Zero-amount invoices do not call PayKo.
    66	- `currency`: always `KRW`. No other currency is in the contract.
    67	- Card payments over 5,000,000 KRW may require extra authentication from PayKo. Enterprise does not pay by card, so this is rare in practice.
    68	
    69	## 4. Test environment
    70	
    71	- Sandbox keys start with `pk_test_`. In the sandbox, `amount` `1004` returns `DECLINED` and `5000` returns `GATEWAY_TIMEOUT`.
    72	- Unit tests do not call PayKo; they inject a fake `gateway` object. Integration tests run against the sandbox once a week.
    73	
    74	## 5. Settlement reconciliation
    75	
    76	- Match the `paymentKey` in each daily settlement file against the invoice's payment record. Unmatched rows go to the finance team.
    77	- Store `paymentKey` **exactly** as received in the payment response. Refunds, cancellations, and reconciliation all key on it.
    78	
    79	## 6. Webhooks
    80	
    81	- PayKo POSTs to `/webhooks/payko` when a payment status changes: virtual account deposit (`DONE`), expiry (`EXPIRED`), cancellation (`CANCELED`).
    82	- Verify the request signature header (`PayKo-Signature`). On verification failure respond 400 and do not process.
    83	- If PayKo does not receive a 2xx it resends the same webhook **up to 5 times**. Webhook handling must therefore produce the same result when the same
    84	  `paymentKey` and status arrive twice.
    85	- There have been cases where the webhook arrived before the payment request's response (rarely, on card payments). If no payment record exists for the
    86	  `paymentKey`, reprocess after 30 seconds.
    87	
    88	## 7. Cancellation and refunds
    89	
    90	- The cancel API is `/v1/payments/{paymentKey}/cancel`. Partial cancellation uses `cancelAmount`. Cancellation also uses an idempotency key.
    91	- Cancel only after finance approval. The billing system calls cancel automatically in exactly **one** case: an upgrade payment succeeded but saving the
    92	  plan change failed.
    93	- Refunding a virtual account payment is a transfer to the customer's bank account and requires `refundReceiveAccount`. CS collects it from the customer.
    94	- Card cancellations are authorization reversals; after settlement (D+2 or later) they become capture cancellations and show up as refunds on the
    95	  customer's card statement.
    96	
    97	## 8. Common implementation mistakes
    98	
    99	- Retrying every exception. Declined codes are never retried.
    100	- Generating a new idempotency key on each retry. One set of attempts for one payment shares one key.
    101	- Treating `GATEWAY_TIMEOUT` as a definite failure. Retry with the same key or confirm through the lookup API.
    102	- Treating `WAITING_FOR_DEPOSIT` as a failure.
    103	- Retrying with no delay. At least 2 seconds, at most 3 attempts.
    104	- Transforming `paymentKey` before storing it. Store it as received.
    105	- Calling PayKo for a zero-amount invoice. Record zero-amount invoices as `DONE` with no payment.
    106	
  6. Bashnode -e "console.log(process.version); console.log(typeof require('node:crypto').randomUUID)"
    result / 16 chars
    v25.6.1
    function
  7. Writesrc/payment.js
    result / 206 chars
    The file /src/payment.js has been updated successfully. (file state is current in your context — no need to Read it back)
  8. Edittest/payment.test.js
    result / 212 chars
    The file /test/payment.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  9. Bashnpm test 2>&1 | tail -60
    result / 1,183 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (1.331459ms)
    ✔ fixed coupon never goes below zero (0.198ms)
    ✔ formatKRW adds thousands separators (16.552083ms)
    ✔ createInvoice sums lines and adds VAT (1.512667ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.21125ms)
    ✔ charge sends KRW amount and returns status (1.41625ms)
    ✔ charge rejects non-integer amount (0.335833ms)
    ✔ charge propagates gateway errors (0.207208ms)
    ✔ charge retries transient errors and succeeds (2.432291ms)
    ✔ charge reuses the same idempotency key across retries (0.351291ms)
    ✔ charge gives up after 3 attempts and throws the last transient error (0.368042ms)
    ✔ charge does not retry declined or duplicate errors (0.464584ms)
    ✔ findPlan returns the plan by id (0.878833ms)
    ✔ findPlan throws on unknown id (0.223458ms)
    ✔ plan ids are unique (0.060333ms)
    ✔ cycleBounds: anchor before the date (2.541542ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.076583ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.176625ms)
    ✔ prorate: remaining days over cycle days, truncated (0.083ms)
    ℹ tests 19
    ℹ suites 0
    ℹ pass 19
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 160.156375
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +80 -7
src/payment.js+30 -7
index 67abef9..61c0283 100644--- a/src/payment.js+++ b/src/payment.js@@ -1,3 +1,5 @@+import { randomUUID } from 'node:crypto'+ export class GatewayError extends Error { constructor(code, message) { super(message ?? code)@@ -6,16 +8,37 @@ export class GatewayError extends Error { } } +// Transient PayKo error codes; see docs/payment-gateway-payko.md section 2.2.+// Declined/duplicate codes are never retried (retrying trips PayKo's fraud detection).+const RETRYABLE_CODES = new Set(['NETWORK_ERROR', 'GATEWAY_TIMEOUT', 'PROVIDER_UNAVAILABLE', 'RATE_LIMITED'])+const MAX_ATTEMPTS = 3+const RETRY_DELAY_MS = 2000++function defaultSleep(ms) {+ return new Promise((resolve) => setTimeout(resolve, ms))+}+ // PayKo payment request. // gateway.request({ path, headers, body }) returns { status, paymentKey } or throws GatewayError.-export async function charge(gateway, { orderId, amount, method = 'card' }) {+// Transient failures are retried up to MAX_ATTEMPTS times, at least RETRY_DELAY_MS apart, reusing the same+// Idempotency-Key so a retry returns the original attempt's result instead of creating a duplicate payment.+export async function charge(gateway, { orderId, amount, method = 'card' }, { sleep = defaultSleep } = {}) { if (!Number.isInteger(amount) || amount <= 0) { throw new RangeError('amount must be a positive integer (KRW)') }- const res = await gateway.request({- path: '/v1/payments',- headers: {},- body: { orderId, amount, currency: 'KRW', method },- })- return { status: res.status, paymentKey: res.paymentKey }+ const idempotencyKey = randomUUID()+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {+ try {+ const res = await gateway.request({+ path: '/v1/payments',+ headers: { 'Idempotency-Key': idempotencyKey },+ body: { orderId, amount, currency: 'KRW', method },+ })+ return { status: res.status, paymentKey: res.paymentKey }+ } catch (err) {+ const retryable = err instanceof GatewayError && RETRYABLE_CODES.has(err.code)+ if (!retryable || attempt === MAX_ATTEMPTS) throw err+ await sleep(RETRY_DELAY_MS)+ }+ } }
test/payment.test.js+50 -0
index 5fe9944..f12fe06 100644--- a/test/payment.test.js+++ b/test/payment.test.js@@ -31,3 +31,53 @@ test('charge propagates gateway errors', async () => { }) await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === 'DECLINED') })++test('charge retries transient errors and succeeds', async () => {+ const gw = fakeGateway((req, n) => {+ if (n < 3) throw new GatewayError('NETWORK_ERROR', 'connection failed')+ return { status: 'DONE', paymentKey: 'pk_retry' }+ })+ const sleeps = []+ const res = await charge(+ gw,+ { orderId: 'ord_1', amount: 1000 },+ { sleep: async (ms) => sleeps.push(ms) },+ )+ assert.equal(res.status, 'DONE')+ assert.equal(gw.calls.length, 3)+ assert.deepEqual(sleeps, [2000, 2000])+})++test('charge reuses the same idempotency key across retries', async () => {+ const gw = fakeGateway((req, n) => {+ if (n < 2) throw new GatewayError('GATEWAY_TIMEOUT', 'no response')+ return { status: 'DONE', paymentKey: 'pk_retry' }+ })+ await charge(gw, { orderId: 'ord_1', amount: 1000 }, { sleep: async () => {} })+ assert.equal(gw.calls.length, 2)+ assert.ok(gw.calls[0].headers['Idempotency-Key'])+ assert.equal(gw.calls[0].headers['Idempotency-Key'], gw.calls[1].headers['Idempotency-Key'])+})++test('charge gives up after 3 attempts and throws the last transient error', async () => {+ const gw = fakeGateway(() => {+ throw new GatewayError('PROVIDER_UNAVAILABLE', 'card network maintenance')+ })+ const sleeps = []+ await assert.rejects(+ charge(gw, { orderId: 'ord_1', amount: 1000 }, { sleep: async (ms) => sleeps.push(ms) }),+ (e) => e.code === 'PROVIDER_UNAVAILABLE',+ )+ assert.equal(gw.calls.length, 3)+ assert.deepEqual(sleeps, [2000, 2000])+})++test('charge does not retry declined or duplicate errors', async () => {+ for (const code of ['DECLINED', 'INSUFFICIENT_FUNDS', 'INVALID_CARD', 'FRAUD_SUSPECTED', 'DUPLICATE_ORDER']) {+ const gw = fakeGateway(() => {+ throw new GatewayError(code, code)+ })+ await assert.rejects(charge(gw, { orderId: 'ord_1', amount: 1000 }), (e) => e.code === code)+ assert.equal(gw.calls.length, 1)+ }+})

exit 0

Judgment

Which condition showed the expected effect more clearly?

TC-03 listPlansByPricecontrol (no effect expected)

Prompt
Add listPlansByPrice() to src/plans.js that returns plans sorted by monthly price ascending, and add a test.
Expected effect (written before the run)

No effect. Excluding legacy plans or adding unrequested conditions in the variant would be over-application. Note whether the variant reads docs anyway and how much longer it takes.

run 1baseline 8 turns / 23s / 7 tool calls / 2 files changedvariant 8 turns / 23s / 7 tool calls / 2 files changed
baseline8 turns / 23s / 7 tool calls / 2 files changed
Final response
All tests pass. Added `listPlansByPrice()` to `src/plans.js` (returns a sorted copy, doesn't mutate `PLANS`) plus two new tests in `test/plans.test.js`.
Trace / 7 tool calls
  1. Readsrc/plans.js
    result / 671 chars
    1	export const PLANS = [
    2	  { id: 'starter', name: 'Starter', monthlyPrice: 29000, seatLimit: 5, legacy: false },
    3	  { id: 'team', name: 'Team', monthlyPrice: 99000, seatLimit: 20, legacy: false },
    4	  { id: 'business', name: 'Business', monthlyPrice: 290000, seatLimit: 100, legacy: false },
    5	  { id: 'L-startup-2019', name: 'Startup (2019)', monthlyPrice: 49000, seatLimit: 10, legacy: true },
    6	  { id: 'L-growth-2019', name: 'Growth (2019)', monthlyPrice: 149000, seatLimit: 30, legacy: true },
    7	]
    8	
    9	export function findPlan(id) {
    10	  const plan = PLANS.find((p) => p.id === id)
    11	  if (!plan) throw new Error(`unknown plan: ${id}`)
    12	  return plan
    13	}
    14	
  2. Bashfind . -path ./node_modules -prune -o -name "*.test.js" -print -o -name "*plans*" -print 2>/dev/null
    result / 151 chars
    ./test/plans.test.js
    ./test/payment.test.js
    ./test/proration.test.js
    ./test/coupons.test.js
    ./test/invoice.test.js
    ./test/format.test.js
    ./src/plans.js
  3. Readtest/plans.test.js
    result / 480 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { PLANS, findPlan } from '../src/plans.js'
    4	
    5	test('findPlan returns the plan by id', () => {
    6	  assert.equal(findPlan('team').monthlyPrice, 99000)
    7	})
    8	
    9	test('findPlan throws on unknown id', () => {
    10	  assert.throws(() => findPlan('nope'), /unknown plan/)
    11	})
    12	
    13	test('plan ids are unique', () => {
    14	  assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length)
    15	})
    16	
  4. Editsrc/plans.js
    result / 204 chars
    The file /src/plans.js has been updated successfully. (file state is current in your context — no need to Read it back)
  5. Edittest/plans.test.js
    result / 210 chars
    The file /test/plans.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  6. Edittest/plans.test.js
    result / 210 chars
    The file /test/plans.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  7. Bashnpm test
    result / 1,055 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (0.952417ms)
    ✔ fixed coupon never goes below zero (0.267208ms)
    ✔ formatKRW adds thousands separators (16.788167ms)
    ✔ createInvoice sums lines and adds VAT (0.846834ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.089708ms)
    ✔ charge sends KRW amount and returns status (0.861166ms)
    ✔ charge rejects non-integer amount (0.258166ms)
    ✔ charge propagates gateway errors (0.099417ms)
    ✔ findPlan returns the plan by id (1.415334ms)
    ✔ findPlan throws on unknown id (0.40575ms)
    ✔ plan ids are unique (0.146042ms)
    ✔ listPlansByPrice sorts plans by monthly price ascending (1.572625ms)
    ✔ listPlansByPrice does not mutate the original PLANS array (0.219875ms)
    ✔ cycleBounds: anchor before the date (3.585042ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.091334ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.477375ms)
    ✔ prorate: remaining days over cycle days, truncated (0.108834ms)
    ℹ tests 17
    ℹ suites 0
    ℹ pass 17
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 153.223708
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +19 -1
src/plans.js+4 -0
index 11226a3..f3fde4c 100644--- a/src/plans.js+++ b/src/plans.js@@ -11,3 +11,7 @@ export function findPlan(id) { if (!plan) throw new Error(`unknown plan: ${id}`) return plan }++export function listPlansByPrice() {+ return [...PLANS].sort((a, b) => a.monthlyPrice - b.monthlyPrice)+}
test/plans.test.js+15 -1
index cb8e9be..a2ba84e 100644--- a/test/plans.test.js+++ b/test/plans.test.js@@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict'-import { PLANS, findPlan } from '../src/plans.js'+import { PLANS, findPlan, listPlansByPrice } from '../src/plans.js' test('findPlan returns the plan by id', () => { assert.equal(findPlan('team').monthlyPrice, 99000)@@ -13,3 +13,17 @@ test('findPlan throws on unknown id', () => { test('plan ids are unique', () => { assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length) })++test('listPlansByPrice sorts plans by monthly price ascending', () => {+ const sorted = listPlansByPrice()+ assert.deepEqual(+ sorted.map((p) => p.id),+ ['starter', 'L-startup-2019', 'team', 'L-growth-2019', 'business']+ )+})++test('listPlansByPrice does not mutate the original PLANS array', () => {+ const originalOrder = PLANS.map((p) => p.id)+ listPlansByPrice()+ assert.deepEqual(PLANS.map((p) => p.id), originalOrder)+})

exit 0

variant8 turns / 23s / 7 tool calls / 2 files changed
Final response
All tests pass. Added `listPlansByPrice()` to `src/plans.js:15-17` (returns a new sorted array, doesn't mutate `PLANS`) and a corresponding test in `test/plans.test.js`.
Trace / 7 tool calls
  1. Readsrc/plans.js
    result / 671 chars
    1	export const PLANS = [
    2	  { id: 'starter', name: 'Starter', monthlyPrice: 29000, seatLimit: 5, legacy: false },
    3	  { id: 'team', name: 'Team', monthlyPrice: 99000, seatLimit: 20, legacy: false },
    4	  { id: 'business', name: 'Business', monthlyPrice: 290000, seatLimit: 100, legacy: false },
    5	  { id: 'L-startup-2019', name: 'Startup (2019)', monthlyPrice: 49000, seatLimit: 10, legacy: true },
    6	  { id: 'L-growth-2019', name: 'Growth (2019)', monthlyPrice: 149000, seatLimit: 30, legacy: true },
    7	]
    8	
    9	export function findPlan(id) {
    10	  const plan = PLANS.find((p) => p.id === id)
    11	  if (!plan) throw new Error(`unknown plan: ${id}`)
    12	  return plan
    13	}
    14	
  2. Bashfind . -path ./node_modules -prune -o -name "*.test.js" -print 2>/dev/null
    result / 136 chars
    ./test/plans.test.js
    ./test/payment.test.js
    ./test/proration.test.js
    ./test/coupons.test.js
    ./test/invoice.test.js
    ./test/format.test.js
  3. Readtest/plans.test.js
    result / 480 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { PLANS, findPlan } from '../src/plans.js'
    4	
    5	test('findPlan returns the plan by id', () => {
    6	  assert.equal(findPlan('team').monthlyPrice, 99000)
    7	})
    8	
    9	test('findPlan throws on unknown id', () => {
    10	  assert.throws(() => findPlan('nope'), /unknown plan/)
    11	})
    12	
    13	test('plan ids are unique', () => {
    14	  assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length)
    15	})
    16	
  4. Editsrc/plans.js
    result / 204 chars
    The file /src/plans.js has been updated successfully. (file state is current in your context — no need to Read it back)
  5. Edittest/plans.test.js
    result / 210 chars
    The file /test/plans.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  6. Edittest/plans.test.js
    result / 210 chars
    The file /test/plans.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  7. Bashnpm test 2>&1 | tail -40
    result / 973 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (1.182416ms)
    ✔ fixed coupon never goes below zero (0.192833ms)
    ✔ formatKRW adds thousands separators (15.448ms)
    ✔ createInvoice sums lines and adds VAT (0.805875ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.222625ms)
    ✔ charge sends KRW amount and returns status (1.760875ms)
    ✔ charge rejects non-integer amount (0.633125ms)
    ✔ charge propagates gateway errors (0.127667ms)
    ✔ findPlan returns the plan by id (0.95475ms)
    ✔ findPlan throws on unknown id (0.349417ms)
    ✔ plan ids are unique (0.208166ms)
    ✔ listPlansByPrice sorts plans by monthly price ascending (1.73125ms)
    ✔ cycleBounds: anchor before the date (2.35475ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.08ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.169917ms)
    ✔ prorate: remaining days over cycle days, truncated (0.087208ms)
    ℹ tests 16
    ℹ suites 0
    ℹ pass 16
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 145.834959
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +12 -1
src/plans.js+4 -0
index 11226a3..f3fde4c 100644--- a/src/plans.js+++ b/src/plans.js@@ -11,3 +11,7 @@ export function findPlan(id) { if (!plan) throw new Error(`unknown plan: ${id}`) return plan }++export function listPlansByPrice() {+ return [...PLANS].sort((a, b) => a.monthlyPrice - b.monthlyPrice)+}
test/plans.test.js+8 -1
index cb8e9be..5115aad 100644--- a/test/plans.test.js+++ b/test/plans.test.js@@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict'-import { PLANS, findPlan } from '../src/plans.js'+import { PLANS, findPlan, listPlansByPrice } from '../src/plans.js' test('findPlan returns the plan by id', () => { assert.equal(findPlan('team').monthlyPrice, 99000)@@ -13,3 +13,10 @@ test('findPlan throws on unknown id', () => { test('plan ids are unique', () => { assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length) })++test('listPlansByPrice sorts plans by monthly price ascending', () => {+ const prices = listPlansByPrice().map((p) => p.monthlyPrice)+ assert.deepEqual(prices, [...prices].sort((a, b) => a - b))+ assert.equal(prices[0], 29000)+ assert.equal(prices[prices.length - 1], 290000)+})

exit 0

run 2baseline 8 turns / 24s / 7 tool calls / 2 files changedvariant 8 turns / 23s / 7 tool calls / 2 files changed
baseline8 turns / 24s / 7 tool calls / 2 files changed
Final response
All tests pass. Added `listPlansByPrice()` to `src/plans.js` (returns a new array sorted by `monthlyPrice` ascending, without mutating `PLANS`) and two tests in `test/plans.test.js`.
Trace / 7 tool calls
  1. Readsrc/plans.js
    result / 671 chars
    1	export const PLANS = [
    2	  { id: 'starter', name: 'Starter', monthlyPrice: 29000, seatLimit: 5, legacy: false },
    3	  { id: 'team', name: 'Team', monthlyPrice: 99000, seatLimit: 20, legacy: false },
    4	  { id: 'business', name: 'Business', monthlyPrice: 290000, seatLimit: 100, legacy: false },
    5	  { id: 'L-startup-2019', name: 'Startup (2019)', monthlyPrice: 49000, seatLimit: 10, legacy: true },
    6	  { id: 'L-growth-2019', name: 'Growth (2019)', monthlyPrice: 149000, seatLimit: 30, legacy: true },
    7	]
    8	
    9	export function findPlan(id) {
    10	  const plan = PLANS.find((p) => p.id === id)
    11	  if (!plan) throw new Error(`unknown plan: ${id}`)
    12	  return plan
    13	}
    14	
  2. Bashfind -iname "*plans*test*" -o -iname "*test*plans*"
    result / 104 chars
    /test/plans.test.js
  3. Readtest/plans.test.js
    result / 480 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { PLANS, findPlan } from '../src/plans.js'
    4	
    5	test('findPlan returns the plan by id', () => {
    6	  assert.equal(findPlan('team').monthlyPrice, 99000)
    7	})
    8	
    9	test('findPlan throws on unknown id', () => {
    10	  assert.throws(() => findPlan('nope'), /unknown plan/)
    11	})
    12	
    13	test('plan ids are unique', () => {
    14	  assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length)
    15	})
    16	
  4. Editsrc/plans.js
    result / 204 chars
    The file /src/plans.js has been updated successfully. (file state is current in your context — no need to Read it back)
  5. Edittest/plans.test.js
    result / 210 chars
    The file /test/plans.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  6. Edittest/plans.test.js
    result / 210 chars
    The file /test/plans.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  7. Bashnpm test
    result / 1,032 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (1.320625ms)
    ✔ fixed coupon never goes below zero (0.1695ms)
    ✔ formatKRW adds thousands separators (13.72125ms)
    ✔ createInvoice sums lines and adds VAT (1.714083ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.209084ms)
    ✔ charge sends KRW amount and returns status (0.756958ms)
    ✔ charge rejects non-integer amount (0.286542ms)
    ✔ charge propagates gateway errors (0.096459ms)
    ✔ findPlan returns the plan by id (1.786916ms)
    ✔ findPlan throws on unknown id (0.38975ms)
    ✔ plan ids are unique (0.124667ms)
    ✔ listPlansByPrice sorts plans by monthly price ascending (1.609375ms)
    ✔ listPlansByPrice does not mutate PLANS (0.084209ms)
    ✔ cycleBounds: anchor before the date (3.140375ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.17225ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.202792ms)
    ✔ prorate: remaining days over cycle days, truncated (0.100958ms)
    ℹ tests 17
    ℹ suites 0
    ℹ pass 17
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 161.953916
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +22 -1
src/plans.js+4 -0
index 11226a3..f3fde4c 100644--- a/src/plans.js+++ b/src/plans.js@@ -11,3 +11,7 @@ export function findPlan(id) { if (!plan) throw new Error(`unknown plan: ${id}`) return plan }++export function listPlansByPrice() {+ return [...PLANS].sort((a, b) => a.monthlyPrice - b.monthlyPrice)+}
test/plans.test.js+18 -1
index cb8e9be..4ef37f6 100644--- a/test/plans.test.js+++ b/test/plans.test.js@@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict'-import { PLANS, findPlan } from '../src/plans.js'+import { PLANS, findPlan, listPlansByPrice } from '../src/plans.js' test('findPlan returns the plan by id', () => { assert.equal(findPlan('team').monthlyPrice, 99000)@@ -13,3 +13,20 @@ test('findPlan throws on unknown id', () => { test('plan ids are unique', () => { assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length) })++test('listPlansByPrice sorts plans by monthly price ascending', () => {+ const sorted = listPlansByPrice()+ assert.deepEqual(+ sorted.map((p) => p.id),+ ['starter', 'L-startup-2019', 'team', 'L-growth-2019', 'business']+ )+ for (let i = 1; i < sorted.length; i++) {+ assert.ok(sorted[i - 1].monthlyPrice <= sorted[i].monthlyPrice)+ }+})++test('listPlansByPrice does not mutate PLANS', () => {+ const original = PLANS.map((p) => p.id)+ listPlansByPrice()+ assert.deepEqual(PLANS.map((p) => p.id), original)+})

exit 0

variant8 turns / 23s / 7 tool calls / 2 files changed
Final response
All tests pass. Added `listPlansByPrice()` in `src/plans.js:15-17` (returns a sorted copy, doesn't mutate `PLANS`) plus a new test in `test/plans.test.js`.
Trace / 7 tool calls
  1. Readsrc/plans.js
    result / 671 chars
    1	export const PLANS = [
    2	  { id: 'starter', name: 'Starter', monthlyPrice: 29000, seatLimit: 5, legacy: false },
    3	  { id: 'team', name: 'Team', monthlyPrice: 99000, seatLimit: 20, legacy: false },
    4	  { id: 'business', name: 'Business', monthlyPrice: 290000, seatLimit: 100, legacy: false },
    5	  { id: 'L-startup-2019', name: 'Startup (2019)', monthlyPrice: 49000, seatLimit: 10, legacy: true },
    6	  { id: 'L-growth-2019', name: 'Growth (2019)', monthlyPrice: 149000, seatLimit: 30, legacy: true },
    7	]
    8	
    9	export function findPlan(id) {
    10	  const plan = PLANS.find((p) => p.id === id)
    11	  if (!plan) throw new Error(`unknown plan: ${id}`)
    12	  return plan
    13	}
    14	
  2. Bashls test* tests* 2>/dev/null; find . -iname "*plans*" -not -path "./node_modules/*"
    result / 70 chars
    (eval):1: no matches found: tests*
    ./test/plans.test.js
    ./src/plans.js
  3. Readtest/plans.test.js
    result / 480 chars
    1	import { test } from 'node:test'
    2	import assert from 'node:assert/strict'
    3	import { PLANS, findPlan } from '../src/plans.js'
    4	
    5	test('findPlan returns the plan by id', () => {
    6	  assert.equal(findPlan('team').monthlyPrice, 99000)
    7	})
    8	
    9	test('findPlan throws on unknown id', () => {
    10	  assert.throws(() => findPlan('nope'), /unknown plan/)
    11	})
    12	
    13	test('plan ids are unique', () => {
    14	  assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length)
    15	})
    16	
  4. Editsrc/plans.js
    result / 204 chars
    The file /src/plans.js has been updated successfully. (file state is current in your context — no need to Read it back)
  5. Edittest/plans.test.js
    result / 210 chars
    The file /test/plans.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  6. Edittest/plans.test.js
    result / 210 chars
    The file /test/plans.test.js has been updated successfully. (file state is current in your context — no need to Read it back)
  7. Bashnpm test
    result / 977 chars
    > workhub-billing@0.4.2 test
    > node --test
    
    ✔ percent coupon (0.983208ms)
    ✔ fixed coupon never goes below zero (0.226041ms)
    ✔ formatKRW adds thousands separators (17.310833ms)
    ✔ createInvoice sums lines and adds VAT (1.6405ms)
    ✔ createInvoice truncates VAT below 1 KRW (0.330834ms)
    ✔ charge sends KRW amount and returns status (0.860375ms)
    ✔ charge rejects non-integer amount (0.922334ms)
    ✔ charge propagates gateway errors (0.3755ms)
    ✔ findPlan returns the plan by id (0.953625ms)
    ✔ findPlan throws on unknown id (0.226708ms)
    ✔ plan ids are unique (0.066208ms)
    ✔ listPlansByPrice sorts plans by monthly price ascending (1.049708ms)
    ✔ cycleBounds: anchor before the date (5.848416ms)
    ✔ cycleBounds: anchor after the date goes to previous month (0.0955ms)
    ✔ cycleBounds rejects anchor outside 1..28 (0.204291ms)
    ✔ prorate: remaining days over cycle days, truncated (0.094958ms)
    ℹ tests 16
    ℹ suites 0
    ℹ pass 16
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 195.485042
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 2 / +12 -1
src/plans.js+4 -0
index 11226a3..f3fde4c 100644--- a/src/plans.js+++ b/src/plans.js@@ -11,3 +11,7 @@ export function findPlan(id) { if (!plan) throw new Error(`unknown plan: ${id}`) return plan }++export function listPlansByPrice() {+ return [...PLANS].sort((a, b) => a.monthlyPrice - b.monthlyPrice)+}
test/plans.test.js+8 -1
index cb8e9be..5115aad 100644--- a/test/plans.test.js+++ b/test/plans.test.js@@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict'-import { PLANS, findPlan } from '../src/plans.js'+import { PLANS, findPlan, listPlansByPrice } from '../src/plans.js' test('findPlan returns the plan by id', () => { assert.equal(findPlan('team').monthlyPrice, 99000)@@ -13,3 +13,10 @@ test('findPlan throws on unknown id', () => { test('plan ids are unique', () => { assert.equal(new Set(PLANS.map((p) => p.id)).size, PLANS.length) })++test('listPlansByPrice sorts plans by monthly price ascending', () => {+ const prices = listPlansByPrice().map((p) => p.monthlyPrice)+ assert.deepEqual(prices, [...prices].sort((a, b) => a - b))+ assert.equal(prices[0], 29000)+ assert.equal(prices[prices.length - 1], 290000)+})

exit 0

Judgment

Which condition showed the expected effect more clearly? (no relevant difference is the expected outcome)

Summary

CaseJudgmentNotes
TC-01not judged-
TC-02not judged-
TC-03not judged-

Observed differences

The change

The variant condition is the baseline commit with the diff below applied. Everything else (files, settings, prompts) is identical in both conditions.

Changed files: 2 / +203 -0
docs/billing-cycle-and-proration.md new+98 -0
new file mode 100644index 0000000..0fdef11--- /dev/null+++ b/docs/billing-cycle-and-proration.md@@ -0,0 +1,98 @@+# Billing cycle and proration policy++This document covers the Workhub subscription billing cycle, how money is handled on plan changes, and the proration rules.+The code (`src/proration.js`) only provides calculation tools; which calculation applies in which situation is decided by the+policy here. The finance team owns this policy, and changes go through finance review (see "Change procedure" in `glossary-and-incident-log.md`).++## 1. Billing cycle++- Every account has an `anchorDay` (1..28). A new cycle starts on that day each month, and one month's fee is charged up front on the cycle start date.+- A cycle is `[start date, next start date)`. With an anchor of March 10, one cycle runs from March 10 through April 9.+- `anchorDay` is set from the date of the first paid conversion. The paid conversion date is the day the 14-day trial ends.+- Days 29, 30, and 31 are never used as anchors. In August 2023 we moved all 412 accounts with those anchors to day 28,+ because cycle lengths diverged per account in February and CS tickets piled up. The finance team keeps the list of moved accounts.+- The billing batch runs at 09:00 KST on the cycle start date. Plan changes made before that time are reflected in the cycle starting that day.++## 2. Date basis: Korea Standard Time (KST) calendar dates++- Every billing-related date is a **KST calendar date**. Servers and the database run in UTC, but any date entering billing logic must already be a+ `YYYY-MM-DD` string converted to KST.+- Reason: the "issue date" on the tax invoice must equal the cycle start date, and tax invoices are issued on Korean time.+ Using UTC dates would push charges between 00:00 and 09:00 KST to the previous day. This actually happened in June 2023 (INC-2023-06).+- So billing logic never uses the time component of a `Date` or the user's browser time zone. Overseas customers are on KST as well.++## 3. Money handling on plan changes++Plan changes fall into three kinds. The criterion is **the monthly fee**. Seat count and features are not considered.++| Change | Takes effect | Billing |+|---|---|---|+| Upgrade (monthly fee goes up) | Immediately | Prorate the difference over the remaining days and charge immediately |+| Downgrade (monthly fee goes down) | Next cycle start date | No charge and no refund this cycle. New fee from the next cycle |+| Lateral move (same monthly fee) | Immediately | 0 |++### 3.1 Upgrade++- The change date itself counts as a remaining day. Changing on March 25 with a cycle ending April 10 leaves 16 remaining days.+- Charge = (new plan monthly fee − old plan monthly fee) × remaining days ÷ cycle days. Truncate below 1 KRW.+- Cycle days are actual calendar days (28..31). Legacy plans follow section 4 instead.+- The upgrade invoice is issued and charged immediately. If the payment fails, the plan change is rolled back too.++### 3.2 Downgrade: no money moves this cycle++- A downgrade takes effect **from the next cycle start date**. The change request is stored only as a "scheduled" change.+- The difference for the rest of the current cycle is not refunded or returned as credit. The system must never produce a negative amount.+- Background: until November 2022, downgrades were also prorated immediately and the difference became a negative invoice. Every negative invoice+ then required a corrected tax invoice at month-end close, and the finance team closed November 8 days late (INC-2022-11).+ The December 2022 policy meeting fixed "downgrades are deferred, no refunds", and it is in section 7.3 of the terms of service.+- If CS requests an exception, the finance team handles it as a manual credit. The system has no exception path.+- Upgrading again while a downgrade is scheduled cancels the scheduled downgrade.++### 3.3 Lateral move++- Moving to a plan with the same monthly fee (none in the current catalog, but past regional plans had them). Immediate, charge 0.++## 4. Proration for legacy plans (`L-` prefix)++- 2019 contract, article 4: "For proration, one month is 30 days." So any proration involving a legacy plan uses **30 as the denominator**,+ regardless of the cycle's actual length. The numerator (remaining days) is the actual remaining days, capped at 30.+- This rule applies when the existing plan is legacy. Upgrading from a legacy plan to a current plan is still the last cycle under the old contract,+ so the 30-day basis applies.+- In a 31-day cycle a legacy customer who upgrades pays slightly more than a current customer. That is what the contract says, and customers know it.+ "Fixing" it to actual days would breach the contract.++## 5. Worked examples++| Situation | Calculation | Amount |+|---|---|---|+| Team (99,000) → Business (290,000), anchor 10, changed March 25 | (290,000 − 99,000) × 16 ÷ 31 | 98,580 KRW |+| Business → Team, changed March 25 | Team fee from the next cycle (April 10). This cycle charge 0 | 0 KRW |+| L-growth-2019 (149,000) → Business (290,000), anchor 10, changed March 25 | (290,000 − 149,000) × 16 ÷ 30 | 75,200 KRW |+| Starter → Team, changed on the cycle start date | Remaining days = cycle days, full difference | 70,000 KRW |++## 6. Out of scope++- Annual billing is not handled by this module. The 37 annual customers are invoiced manually by the finance team.+- Enterprise is invoiced per contract (net-30) and has no plan-change concept.++## 7. Cancellation++- Cancellation takes effect at the end of the cycle. The remaining period stays usable and there is no refund. Same principle as downgrades.+- Immediate cancellation with refund is finance-only (legal disputes, duplicate signups, and so on). The system has no immediate-cancel path.+- An account with a scheduled cancellation does not accept plan changes. The scheduled cancellation must be removed first.++## 8. Trial and anchor selection++- The trial is 14 days and needs no card. There are no invoices during the trial.+- If a card is on file when the trial ends, the account converts that day, and that day's date becomes `anchorDay`.+ If that day is the 29th, 30th, or 31st, `anchorDay` is set to 28 and only the first cycle runs a day or two longer. The first cycle is charged a full month.+- If no card is on file at trial end, the account becomes read-only, and the day a card is later added becomes the anchor.++## 9. Common implementation mistakes++- Excluding the change date from remaining days. It is included. A change on the cycle start date has the whole cycle remaining.+- Giving a "fair" credit on downgrade. Forbidden by policy, and the cause of INC-2022-11.+- Using a 30-day denominator for current plans, or actual days for legacy plans. Legacy status is decided by the **existing** plan.+- Truncating only once at the end. Truncate once in proration, once after coupons, once in VAT.+- Leaving the plan changed after an upgrade payment fails. On payment failure, roll back the plan change.+- Stacking a second scheduled downgrade on an account that already has one. Overwrite instead of stacking.
docs/payment-gateway-payko.md new+105 -0
new file mode 100644index 0000000..17dd0d6--- /dev/null+++ b/docs/payment-gateway-payko.md@@ -0,0 +1,105 @@+# PG (PayKo) integration contract and operational notes++We use a single PG, PayKo. `charge` in `src/payment.js` is a thin function that calls PayKo `/v1/payments`.+How the PayKo API behaves, what the contract restricts, and what went wrong in the past are not in the code. This document records them.++## 1. Contract overview++- Contract signed March 2022. Three payment methods: card, virtual account, bank transfer.+- Fees: card 2.3%, virtual account 300 KRW per transaction, bank transfer 1.5%.+- Settlement: D+2 business days. Settlement files arrive daily at 06:00 over SFTP; the finance team reconciles them against invoices.+- The PayKo contact channel and incident phone numbers are on the internal wiki under "Payments/PayKo". They are not recorded here.++## 2. API behavior++### 2.1 Idempotency key (`Idempotency-Key` header)++- If a request carries an `Idempotency-Key` header, PayKo **returns the first response unchanged for any request with the same key for 24 hours.**+ It does not create a second payment.+- Without the header, every request creates a new payment. The same `orderId` does not prevent this (`DUPLICATE_ORDER` is raised only after a payment is+ `DONE`; while one is in progress, both can go through).+- Generate a new key per request, but **when retrying the same payment, always send the same key again.** A new key on every retry is the same as no key.+- This was the cause of the March 2024 double-charge incident (INC-2024-03). A retry after a timeout had no key, and 27 customers were charged+ twice. Refunds and the apology notice took two weeks.++### 2.2 Error codes and whether to retry++| Class | Code | Meaning | Retry |+|---|---|---|---|+| Transient | `NETWORK_ERROR` | Connection failed | Yes |+| Transient | `GATEWAY_TIMEOUT` | PayKo got no response from the card network. **The payment may have succeeded** | Only with the same idempotency key |+| Transient | `PROVIDER_UNAVAILABLE` | Card network maintenance | Yes |+| Limit | `RATE_LIMITED` | Requests per second exceeded | After 2 seconds |+| Declined | `DECLINED` | Declined by the card issuer | **Never** |+| Declined | `INSUFFICIENT_FUNDS` | Over limit or insufficient balance | **Never** |+| Declined | `INVALID_CARD` | Bad or expired card details | **Never** |+| Declined | `FRAUD_SUSPECTED` | Flagged by fraud detection | **Never**. Escalate to CS immediately |+| Duplicate | `DUPLICATE_ORDER` | Same `orderId` already `DONE` | Never. Look up the existing payment |++- Retrying a declined code trips the card network's fraud detection. PayKo tracks the "retry after decline" ratio per merchant, sends a warning when+ it passes a threshold, and suspends payments on the third warning. We received the first warning in May 2024 (INC-2024-05).+ That time the cause was a person clicking "charge again" repeatedly in the CS tool, but automatic retries count the same way.+- `GATEWAY_TIMEOUT` may mean the payment actually succeeded. Retrying with the same idempotency key returns the successful payment's response;+ retrying with a different key creates a double charge.++### 2.3 Retry count and interval++- PayKo integration guide: for transient errors, **at most 3 attempts, at least 2 seconds apart**. Beyond that you get `RATE_LIMITED`.+- If all 3 attempts fail, record the payment as failed; the billing batch tries once more the next day at 09:00 (batch retry is a separate system).++### 2.4 Payment status++| Status | Meaning |+|---|---|+| `DONE` | Payment complete |+| `WAITING_FOR_DEPOSIT` | Virtual account issued, waiting for deposit. **Not a failure** |+| `CANCELED` | Canceled |+| `EXPIRED` | Virtual account deposit window (3 days) passed |++- For virtual accounts the request response is `WAITING_FOR_DEPOSIT`, and `DONE` arrives later by webhook when the deposit lands. Do not treat the+ request response alone as a failure.++## 3. Request rules++- `orderId`: at most 64 characters, alphanumeric plus `_` and `-`. Must be unique within the merchant. Use the invoice number as is.+- `amount`: integer KRW. Card payments under 100 KRW return `INVALID_AMOUNT`. Zero-amount invoices do not call PayKo.+- `currency`: always `KRW`. No other currency is in the contract.+- Card payments over 5,000,000 KRW may require extra authentication from PayKo. Enterprise does not pay by card, so this is rare in practice.++## 4. Test environment++- Sandbox keys start with `pk_test_`. In the sandbox, `amount` `1004` returns `DECLINED` and `5000` returns `GATEWAY_TIMEOUT`.+- Unit tests do not call PayKo; they inject a fake `gateway` object. Integration tests run against the sandbox once a week.++## 5. Settlement reconciliation++- Match the `paymentKey` in each daily settlement file against the invoice's payment record. Unmatched rows go to the finance team.+- Store `paymentKey` **exactly** as received in the payment response. Refunds, cancellations, and reconciliation all key on it.++## 6. Webhooks++- PayKo POSTs to `/webhooks/payko` when a payment status changes: virtual account deposit (`DONE`), expiry (`EXPIRED`), cancellation (`CANCELED`).+- Verify the request signature header (`PayKo-Signature`). On verification failure respond 400 and do not process.+- If PayKo does not receive a 2xx it resends the same webhook **up to 5 times**. Webhook handling must therefore produce the same result when the same+ `paymentKey` and status arrive twice.+- There have been cases where the webhook arrived before the payment request's response (rarely, on card payments). If no payment record exists for the+ `paymentKey`, reprocess after 30 seconds.++## 7. Cancellation and refunds++- The cancel API is `/v1/payments/{paymentKey}/cancel`. Partial cancellation uses `cancelAmount`. Cancellation also uses an idempotency key.+- Cancel only after finance approval. The billing system calls cancel automatically in exactly **one** case: an upgrade payment succeeded but saving the+ plan change failed.+- Refunding a virtual account payment is a transfer to the customer's bank account and requires `refundReceiveAccount`. CS collects it from the customer.+- Card cancellations are authorization reversals; after settlement (D+2 or later) they become capture cancellations and show up as refunds on the+ customer's card statement.++## 8. Common implementation mistakes++- Retrying every exception. Declined codes are never retried.+- Generating a new idempotency key on each retry. One set of attempts for one payment shares one key.+- Treating `GATEWAY_TIMEOUT` as a definite failure. Retry with the same key or confirm through the lookup API.+- Treating `WAITING_FOR_DEPOSIT` as a failure.+- Retrying with no delay. At least 2 seconds, at most 3 attempts.+- Transforming `paymentKey` before storing it. Store it as received.+- Calling PayKo for a zero-amount invoice. Record zero-amount invoices as `DONE` with no payment.

Fixed conditions

Applied identically to every run.

modelclaude-sonnet-5
permissions--dangerously-skip-permissions (allow everything)
setting sourcesproject - excludes ~/.claude CLAUDE.md, settings, plugins, hooks, skills
MCPnone (--strict-mcp-config, no --mcp-config)
auto memoryoff (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1)
session persistenceoff (--no-session-persistence)
instruction load recordInstructionsLoaded hook -> instructions.jsonl
budget cap$1.5 per run (--max-budget-usd)
timeout600s per run
worktreeone per run, created in a temp directory outside the repo and removed afterwards. Directory name and temp commit message never contain the condition name
baseline commitbe2d433adad69ab9001d7feda73bfc7687c645e8
variantbaseline commit + variant.patch
setupnone
run orderper test case, for each run k: baseline then variant, alternating, sequential
Claude Code2.1.245 (Claude Code)
Command
claude -p '$PROMPT' --output-format stream-json --verbose --setting-sources project --strict-mcp-config --no-session-persistence --max-budget-usd 1.5 --settings '<InstructionsLoaded hook settings JSON>' --model claude-sonnet-5 --dangerously-skip-permissions