fluent-korean

abdiff experiment report / baseline commit 77032d3fd301

Overview

HypothesisWith the fluent-korean output style applied, Korean write-ups end sentences with full predicates instead of noun fragments, drop fewer particles, and avoid em dashes; the content stays the same.
Run time2026-08-25T23:01:22+09:00 -> 2026-08-25T23:11:41+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.31
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 Gap analysis of charge()target

Prompt
src/payment.js의 charge를 읽고, docs/payment-gateway-payko.md 기준으로 빠진 처리를 정리해서 보고해줘. 코드는 고치지 마.
Expected effect (written before the run)

Variant: fewer lines ending in noun fragments (e.g. "판단 불가", "전파만 함"), more sentences ending in -다/-니다, no em dashes. Same list of missing behaviors on both sides.

run 1baseline 3 turns / 24s / 2 tool calls / 0 files changedvariant 3 turns / 30s / 2 tool calls / 0 files changed
baseline3 turns / 24s / 2 tool calls / 0 files changed
Final response
`src/payment.js`의 `charge`를 문서 기준으로 검토한 결과입니다. **코드는 수정하지 않았습니다.**

## 현재 코드가 하는 일
- `amount`가 양의 정수인지만 검증
- `/v1/payments`에 `orderId`, `amount`, `currency: 'KRW'`, `method`를 담아 요청 1회 호출
- 응답에서 `status`, `paymentKey`만 뽑아 반환

## 문서 기준으로 빠진 처리

**1. 멱등키(`Idempotency-Key`) 미전송 — 가장 심각**
- `headers: {}`로 비어 있음. 문서 2.1에 따르면 이 헤더가 없으면 재시도 시 이중 결제가 발생하며, 실제로 INC-2024-03(고객 27곳 이중 결제) 원인이 이것.
- 재시도 시 "같은 결제 시도는 같은 키" 원칙을 지키려면 `charge` 호출 단위로 키를 생성/전달하는 구조가 필요한데 현재는 아예 개념이 없음.

**2. 재시도 로직 전무**
- `NETWORK_ERROR`, `GATEWAY_TIMEOUT`, `PROVIDER_UNAVAILABLE`, `RATE_LIMITED`에 대한 재시도(최대 3회, 2초 이상 간격)가 없음. 현재는 `gateway.request`가 던지면 그대로 위로 전파될 뿐.
- `GATEWAY_TIMEOUT`을 실패로 확정하지 않고 같은 멱등키로 재확인해야 하는데, 그런 처리가 없음.

**3. 오류 코드별 재시도 가능 여부 구분 없음**
- `DECLINED`, `INSUFFICIENT_FUNDS`, `INVALID_CARD`, `FRAUD_SUSPECTED`, `DUPLICATE_ORDER` 등 "재시도 금지" 코드와 일시 오류 코드를 구분하지 않음. `GatewayError`를 그냥 던지기만 해서, 호출부가 실수로 거절 건을 재시도하면 문서 2.2가 경고하는 "거절 후 재시도 비율 초과 → 결제 정지" 위험(INC-2024-05)에 그대로 노출됨.

**4. 0원 청구서 처리 없음**
- 문서 3, 8: 0원이면 PayKo를 호출하지 않고 결제 없이 `DONE`으로 기록해야 함. 현재는 `amount <= 0`이면 그냥 `RangeError`를 던져서 0원 케이스를 정상 흐름으로 처리할 수 없음.

**5. `amount` 하한(카드 100원 미만) 검증 없음**
- 문서 3: 카드 결제 100원 미만은 `INVALID_AMOUNT`. 클라이언트 쪽에서 걸러주지 않고 PayKo 응답에만 의존.

**6. `orderId` 형식 검증 없음**
- 최대 64자, 영숫자/`_`/`-`만 허용이라는 제약을 검증하지 않고 그대로 전달.

**7. `WAITING_FOR_DEPOSIT`(가상계좌) 상태를 구분해 다루지 않음**
- 함수 자체는 `status`를 그대로 반환하니 값 손실은 없지만, 이 상태를 실패로 처리하지 말라는 문서 2.4/8의 주의사항을 호출부가 인지할 만한 장치(타입, 주석, 문서화)가 코드에 전혀 없음.

## 요약
가장 시급한 것은 **멱등키 부재**(실사고 이력 있음)와 **재시도 시 거절 코드 재시도 금지 로직 부재**입니다. 그 다음으로 0원 청구서 처리와 오류 코드 분류(재시도 가능/불가)를 추가하는 것이 우선순위로 보입니다.
Trace / 2 tool calls
  1. Readsrc/payment.js
    result / 730 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 결제 요청.
    10	// gateway.request({ path, headers, body })는 { status, paymentKey }를 돌려주거나 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. Readdocs/payment-gateway-payko.md
    result / 4,097 chars
    1	# PG(PayKo) 연동 계약과 운영 주의점
    2	
    3	결제는 PayKo 한 곳을 쓴다. `src/payment.js`의 `charge`는 PayKo `/v1/payments`를 부르는 얇은 함수다.
    4	PayKo API의 동작 방식, 계약상 제약, 과거에 겪은 문제는 코드에 없다. 이 문서가 그것을 적는다.
    5	
    6	## 1. 계약 개요
    7	
    8	- 계약: 2022년 3월 체결. 카드, 가상계좌, 계좌이체 세 가지 수단.
    9	- 수수료: 카드 2.3%, 가상계좌 건당 300원, 계좌이체 1.5%.
    10	- 정산: D+2 영업일. 정산 내역은 매일 06:00에 SFTP로 받고, 재무팀이 청구서와 대사한다.
    11	- PayKo 담당자 채널과 장애 연락처는 사내 위키 "결제/PayKo"에 있다. 이 문서에는 적지 않는다.
    12	
    13	## 2. API 동작 특성
    14	
    15	### 2.1 멱등키(`Idempotency-Key` 헤더)
    16	
    17	- 요청에 `Idempotency-Key` 헤더가 있으면 PayKo는 **24시간 동안 같은 키의 요청에 첫 번째 응답을 그대로 돌려준다.**
    18	  결제를 두 번 만들지 않는다.
    19	- 헤더가 없으면 요청마다 새 결제가 만들어진다. `orderId`가 같아도 막아주지 않는다(`DUPLICATE_ORDER`는 결제가
    20	  `DONE`된 뒤에만 난다. 진행 중 상태에서는 두 건이 모두 통과할 수 있다).
    21	- 키는 요청마다 새로 만들되, **같은 결제를 다시 시도할 때는 반드시 같은 키를 다시 보낸다.** 재시도마다 새 키를 만들면
    22	  멱등키가 없는 것과 같다.
    23	- 2024년 3월 이중 결제 사고(INC-2024-03)의 원인이 바로 이것이다. 타임아웃 뒤 재시도했는데 키가 없어서 고객 27곳이
    24	  두 번 결제됐다. 환불과 사과 공지에 2주가 걸렸다.
    25	
    26	### 2.2 오류 코드와 재시도 가능 여부
    27	
    28	| 분류 | 코드 | 의미 | 재시도 |
    29	|---|---|---|---|
    30	| 일시 오류 | `NETWORK_ERROR` | 연결 실패 | 가능 |
    31	| 일시 오류 | `GATEWAY_TIMEOUT` | PayKo가 카드사 응답을 못 받음. **결제가 성공했을 수도 있다** | 같은 멱등키로만 가능 |
    32	| 일시 오류 | `PROVIDER_UNAVAILABLE` | 카드사 점검 | 가능 |
    33	| 한도 | `RATE_LIMITED` | 초당 요청 한도 초과 | 2초 뒤 가능 |
    34	| 거절 | `DECLINED` | 카드사 거절 | **금지** |
    35	| 거절 | `INSUFFICIENT_FUNDS` | 한도 초과, 잔액 부족 | **금지** |
    36	| 거절 | `INVALID_CARD` | 카드 정보 오류, 만료 | **금지** |
    37	| 거절 | `FRAUD_SUSPECTED` | 이상거래 탐지 | **금지**. 즉시 CS 에스컬레이션 |
    38	| 중복 | `DUPLICATE_ORDER` | 같은 `orderId`로 이미 `DONE` | 금지. 기존 결제를 조회한다 |
    39	
    40	- 거절 코드에 재시도하면 카드사 이상거래 탐지에 걸린다. PayKo는 가맹점 단위로 "거절 후 재시도" 비율을 보고,
    41	  기준을 넘으면 경고를 보내며 세 번째 경고에서 결제를 정지한다. 2024년 5월에 첫 경고를 받았다(INC-2024-05).
    42	  당시 원인은 CS 도구에서 사람이 "다시 결제"를 반복 클릭한 것이었지만, 자동 재시도도 같은 기준으로 집계된다.
    43	- `GATEWAY_TIMEOUT`은 결제가 실제로는 성공했을 수 있다. 같은 멱등키로 재시도하면 성공한 결제의 응답이 돌아오고,
    44	  다른 키로 재시도하면 이중 결제가 된다.
    45	
    46	### 2.3 재시도 횟수와 간격
    47	
    48	- PayKo 연동 가이드: 일시 오류는 **최대 3회, 간격 2초 이상**. 그 이상은 `RATE_LIMITED`로 막힌다.
    49	- 3회 모두 실패하면 결제 실패로 기록하고, 청구 배치는 다음 날 09:00에 한 번 더 시도한다(배치 재시도는 별도 시스템).
    50	
    51	### 2.4 결제 상태
    52	
    53	| 상태 | 의미 |
    54	|---|---|
    55	| `DONE` | 결제 완료 |
    56	| `WAITING_FOR_DEPOSIT` | 가상계좌 발급됨, 입금 대기. **실패가 아니다** |
    57	| `CANCELED` | 취소됨 |
    58	| `EXPIRED` | 가상계좌 입금 기한(3일) 경과 |
    59	
    60	- 가상계좌는 요청 응답이 `WAITING_FOR_DEPOSIT`으로 오고, 입금되면 웹훅으로 `DONE`이 온다. 요청 응답만 보고
    61	  실패 처리하면 안 된다.
    62	
    63	## 3. 요청 규칙
    64	
    65	- `orderId`: 최대 64자, 영숫자와 `_`, `-`. 가맹점 안에서 유일해야 한다. 청구서 번호를 그대로 쓴다.
    66	- `amount`: KRW 정수. 카드 결제는 100원 미만이면 `INVALID_AMOUNT`. 0원 청구서는 PayKo를 부르지 않는다.
    67	- `currency`: 항상 `KRW`. 다른 통화는 계약에 없다.
    68	- 카드 결제 500만 원 초과 건은 PayKo가 추가 인증을 요구할 수 있다. Enterprise는 카드 결제를 안 하므로 실무에서는 드물다.
    69	
    70	## 4. 테스트 환경
    71	
    72	- 샌드박스 키는 `pk_test_`로 시작한다. 샌드박스에서는 `amount`가 `1004`이면 `DECLINED`, `5000`이면 `GATEWAY_TIMEOUT`을 돌려준다.
    73	- 단위 테스트에서는 PayKo를 부르지 않고 `gateway` 객체를 가짜로 넣는다. 통합 테스트는 주 1회 샌드박스로 돈다.
    74	
    75	## 5. 정산 대사
    76	
    77	- 매일 받는 정산 파일의 `paymentKey`를 청구서의 결제 기록과 맞춘다. 맞지 않는 건은 재무팀이 확인한다.
    78	- `paymentKey`는 결제 응답에서 받은 값을 **그대로** 저장한다. 환불, 취소, 대사 모두 이 키로 한다.
    79	
    80	## 6. 웹훅
    81	
    82	- PayKo는 결제 상태가 바뀔 때 `/webhooks/payko`로 POST한다. 가상계좌 입금(`DONE`), 만료(`EXPIRED`), 취소(`CANCELED`)가 대상이다.
    83	- 요청 헤더의 서명(`PayKo-Signature`)을 검증한다. 검증 실패는 400으로 응답하고 처리하지 않는다.
    84	- PayKo는 2xx를 못 받으면 같은 웹훅을 **최대 5번** 다시 보낸다. 그래서 웹훅 처리는 같은 `paymentKey`와 상태가 두 번 와도
    85	  결과가 같아야 한다.
    86	- 웹훅이 결제 요청 응답보다 먼저 도착한 사례가 있다(카드 결제에서 드물게). `paymentKey`로 결제 기록이 없으면 30초 뒤에 다시 처리한다.
    87	
    88	## 7. 취소와 환불
    89	
    90	- 취소 API는 `/v1/payments/{paymentKey}/cancel`이다. 부분 취소는 `cancelAmount`로 한다. 취소도 멱등키를 쓴다.
    91	- 취소는 재무팀 승인 뒤에만 부른다. 청구 시스템이 자동으로 취소를 부르는 경우는 **업그레이드 결제가 성공했는데 플랜 변경 저장이 실패한 경우** 하나뿐이다.
    92	- 가상계좌 결제의 환불은 고객 계좌로 송금이라 `refundReceiveAccount`가 필요하다. CS가 고객에게 받아 입력한다.
    93	- 카드 취소는 승인 취소로 처리되고, 정산 뒤(D+2 이후)에는 매입 취소가 되어 고객 카드 명세에 환불로 잡힌다.
    94	
    95	## 8. 구현할 때 자주 틀리는 것
    96	
    97	- 모든 예외를 재시도하는 것. 거절 코드는 재시도하지 않는다.
    98	- 재시도마다 새 멱등키를 만드는 것. 한 결제 시도 묶음은 키 하나다.
    99	- `GATEWAY_TIMEOUT`을 실패로 확정하는 것. 같은 키로 재시도하거나 조회 API로 확인해야 한다.
    100	- `WAITING_FOR_DEPOSIT`을 실패로 처리하는 것.
    101	- 재시도 간격을 두지 않는 것. 최소 2초, 최대 3회.
    102	- `paymentKey`를 가공해서 저장하는 것. 받은 그대로 저장한다.
    103	- 0원 청구서에 PayKo를 부르는 것. 0원은 결제 없이 `DONE`으로 기록한다.
    104	
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 0

No changes.

exit 0

variant3 turns / 30s / 2 tool calls / 0 files changed
Final response
`src/payment.js`의 `charge`를 문서 기준으로 대조한 결과, 다음 항목들이 빠져 있습니다.

**1. 멱등키(`Idempotency-Key`) 미전송 — 가장 심각**
`headers: {}`로 고정되어 있어 멱등키를 전혀 보내지 않습니다. 문서 2.1절에 따르면 헤더가 없으면 `orderId`가 같아도 막아주지 않으며, 타임아웃 후 재시도 시 이중 결제가 발생합니다(실제로 INC-2024-03 사고 원인). 재시도 묶음마다 동일한 키를 생성해 담아 보내는 로직이 필요합니다.

**2. 재시도 로직 자체가 없음**
문서 2.2~2.3절이 요구하는 다음 구분이 `charge`에는 전혀 구현되어 있지 않습니다.
- `NETWORK_ERROR`/`GATEWAY_TIMEOUT`/`PROVIDER_UNAVAILABLE`: 최대 3회, 2초 이상 간격으로 재시도
- `RATE_LIMITED`: 2초 뒤 재시도
- `DECLINED`/`INSUFFICIENT_FUNDS`/`INVALID_CARD`/`FRAUD_SUSPECTED`/`DUPLICATE_ORDER`: 재시도 금지

현재는 `gateway.request`가 실패하면 `GatewayError`가 그대로 위로 던져질 뿐이라, 호출부가 무분별하게 재시도하면 거절 코드까지 재시도되어 가맹점 정지 위험(INC-2024-05)이 있습니다.

**3. `GATEWAY_TIMEOUT` 후속 처리 없음**
문서 2.2절에 따르면 이 오류는 결제가 이미 성공했을 수 있어 같은 멱등키로 재시도하거나 조회 API로 확인해야 하는데, 이런 처리가 없습니다.

**4. 0원 청구서 처리 없음**
문서 3절과 8절은 "0원 청구서는 PayKo를 부르지 않고 결제 없이 `DONE`으로 기록한다"고 명시하지만, 현재 코드는 `amount <= 0`이면 무조건 `RangeError`를 던져 0원 케이스를 정상 흐름으로 처리하지 못합니다.

**5. `orderId` 형식 검증 없음**
문서 3절의 규칙(최대 64자, 영숫자와 `_`, `-`만 허용)에 대한 사전 검증이 없어, 잘못된 `orderId`가 그대로 PayKo에 전달됩니다.

**6. `DUPLICATE_ORDER` 발생 시 기존 결제 조회 로직 없음**
문서 표에 따르면 이 코드가 나오면 재시도 대신 기존 결제를 조회해야 하는데, `charge` 함수 및 호출부 어디에도 이런 흐름이 없습니다(다만 이 부분은 `charge` 밖에서 처리할 여지도 있습니다).

**참고(코드 밖 영역이라 `charge` 책임은 아닐 수 있음)**
- `WAITING_FOR_DEPOSIT` 상태를 실패로 취급하지 않는 처리는 `charge`가 상태를 그대로 반환하므로 호출부의 책임입니다.
- `paymentKey`를 가공 없이 그대로 저장하는 것도 호출부 책임이며, `charge`는 이미 원본 값을 그대로 반환하고 있어 문제없습니다.

가장 시급한 순서는 **멱등키 전송 → 재시도/거절 구분 로직 → 0원 처리 → `orderId` 검증** 순으로 보입니다.
Trace / 2 tool calls
  1. Readsrc/payment.js
    result / 730 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 결제 요청.
    10	// gateway.request({ path, headers, body })는 { status, paymentKey }를 돌려주거나 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. Readdocs/payment-gateway-payko.md
    result / 4,097 chars
    1	# PG(PayKo) 연동 계약과 운영 주의점
    2	
    3	결제는 PayKo 한 곳을 쓴다. `src/payment.js`의 `charge`는 PayKo `/v1/payments`를 부르는 얇은 함수다.
    4	PayKo API의 동작 방식, 계약상 제약, 과거에 겪은 문제는 코드에 없다. 이 문서가 그것을 적는다.
    5	
    6	## 1. 계약 개요
    7	
    8	- 계약: 2022년 3월 체결. 카드, 가상계좌, 계좌이체 세 가지 수단.
    9	- 수수료: 카드 2.3%, 가상계좌 건당 300원, 계좌이체 1.5%.
    10	- 정산: D+2 영업일. 정산 내역은 매일 06:00에 SFTP로 받고, 재무팀이 청구서와 대사한다.
    11	- PayKo 담당자 채널과 장애 연락처는 사내 위키 "결제/PayKo"에 있다. 이 문서에는 적지 않는다.
    12	
    13	## 2. API 동작 특성
    14	
    15	### 2.1 멱등키(`Idempotency-Key` 헤더)
    16	
    17	- 요청에 `Idempotency-Key` 헤더가 있으면 PayKo는 **24시간 동안 같은 키의 요청에 첫 번째 응답을 그대로 돌려준다.**
    18	  결제를 두 번 만들지 않는다.
    19	- 헤더가 없으면 요청마다 새 결제가 만들어진다. `orderId`가 같아도 막아주지 않는다(`DUPLICATE_ORDER`는 결제가
    20	  `DONE`된 뒤에만 난다. 진행 중 상태에서는 두 건이 모두 통과할 수 있다).
    21	- 키는 요청마다 새로 만들되, **같은 결제를 다시 시도할 때는 반드시 같은 키를 다시 보낸다.** 재시도마다 새 키를 만들면
    22	  멱등키가 없는 것과 같다.
    23	- 2024년 3월 이중 결제 사고(INC-2024-03)의 원인이 바로 이것이다. 타임아웃 뒤 재시도했는데 키가 없어서 고객 27곳이
    24	  두 번 결제됐다. 환불과 사과 공지에 2주가 걸렸다.
    25	
    26	### 2.2 오류 코드와 재시도 가능 여부
    27	
    28	| 분류 | 코드 | 의미 | 재시도 |
    29	|---|---|---|---|
    30	| 일시 오류 | `NETWORK_ERROR` | 연결 실패 | 가능 |
    31	| 일시 오류 | `GATEWAY_TIMEOUT` | PayKo가 카드사 응답을 못 받음. **결제가 성공했을 수도 있다** | 같은 멱등키로만 가능 |
    32	| 일시 오류 | `PROVIDER_UNAVAILABLE` | 카드사 점검 | 가능 |
    33	| 한도 | `RATE_LIMITED` | 초당 요청 한도 초과 | 2초 뒤 가능 |
    34	| 거절 | `DECLINED` | 카드사 거절 | **금지** |
    35	| 거절 | `INSUFFICIENT_FUNDS` | 한도 초과, 잔액 부족 | **금지** |
    36	| 거절 | `INVALID_CARD` | 카드 정보 오류, 만료 | **금지** |
    37	| 거절 | `FRAUD_SUSPECTED` | 이상거래 탐지 | **금지**. 즉시 CS 에스컬레이션 |
    38	| 중복 | `DUPLICATE_ORDER` | 같은 `orderId`로 이미 `DONE` | 금지. 기존 결제를 조회한다 |
    39	
    40	- 거절 코드에 재시도하면 카드사 이상거래 탐지에 걸린다. PayKo는 가맹점 단위로 "거절 후 재시도" 비율을 보고,
    41	  기준을 넘으면 경고를 보내며 세 번째 경고에서 결제를 정지한다. 2024년 5월에 첫 경고를 받았다(INC-2024-05).
    42	  당시 원인은 CS 도구에서 사람이 "다시 결제"를 반복 클릭한 것이었지만, 자동 재시도도 같은 기준으로 집계된다.
    43	- `GATEWAY_TIMEOUT`은 결제가 실제로는 성공했을 수 있다. 같은 멱등키로 재시도하면 성공한 결제의 응답이 돌아오고,
    44	  다른 키로 재시도하면 이중 결제가 된다.
    45	
    46	### 2.3 재시도 횟수와 간격
    47	
    48	- PayKo 연동 가이드: 일시 오류는 **최대 3회, 간격 2초 이상**. 그 이상은 `RATE_LIMITED`로 막힌다.
    49	- 3회 모두 실패하면 결제 실패로 기록하고, 청구 배치는 다음 날 09:00에 한 번 더 시도한다(배치 재시도는 별도 시스템).
    50	
    51	### 2.4 결제 상태
    52	
    53	| 상태 | 의미 |
    54	|---|---|
    55	| `DONE` | 결제 완료 |
    56	| `WAITING_FOR_DEPOSIT` | 가상계좌 발급됨, 입금 대기. **실패가 아니다** |
    57	| `CANCELED` | 취소됨 |
    58	| `EXPIRED` | 가상계좌 입금 기한(3일) 경과 |
    59	
    60	- 가상계좌는 요청 응답이 `WAITING_FOR_DEPOSIT`으로 오고, 입금되면 웹훅으로 `DONE`이 온다. 요청 응답만 보고
    61	  실패 처리하면 안 된다.
    62	
    63	## 3. 요청 규칙
    64	
    65	- `orderId`: 최대 64자, 영숫자와 `_`, `-`. 가맹점 안에서 유일해야 한다. 청구서 번호를 그대로 쓴다.
    66	- `amount`: KRW 정수. 카드 결제는 100원 미만이면 `INVALID_AMOUNT`. 0원 청구서는 PayKo를 부르지 않는다.
    67	- `currency`: 항상 `KRW`. 다른 통화는 계약에 없다.
    68	- 카드 결제 500만 원 초과 건은 PayKo가 추가 인증을 요구할 수 있다. Enterprise는 카드 결제를 안 하므로 실무에서는 드물다.
    69	
    70	## 4. 테스트 환경
    71	
    72	- 샌드박스 키는 `pk_test_`로 시작한다. 샌드박스에서는 `amount`가 `1004`이면 `DECLINED`, `5000`이면 `GATEWAY_TIMEOUT`을 돌려준다.
    73	- 단위 테스트에서는 PayKo를 부르지 않고 `gateway` 객체를 가짜로 넣는다. 통합 테스트는 주 1회 샌드박스로 돈다.
    74	
    75	## 5. 정산 대사
    76	
    77	- 매일 받는 정산 파일의 `paymentKey`를 청구서의 결제 기록과 맞춘다. 맞지 않는 건은 재무팀이 확인한다.
    78	- `paymentKey`는 결제 응답에서 받은 값을 **그대로** 저장한다. 환불, 취소, 대사 모두 이 키로 한다.
    79	
    80	## 6. 웹훅
    81	
    82	- PayKo는 결제 상태가 바뀔 때 `/webhooks/payko`로 POST한다. 가상계좌 입금(`DONE`), 만료(`EXPIRED`), 취소(`CANCELED`)가 대상이다.
    83	- 요청 헤더의 서명(`PayKo-Signature`)을 검증한다. 검증 실패는 400으로 응답하고 처리하지 않는다.
    84	- PayKo는 2xx를 못 받으면 같은 웹훅을 **최대 5번** 다시 보낸다. 그래서 웹훅 처리는 같은 `paymentKey`와 상태가 두 번 와도
    85	  결과가 같아야 한다.
    86	- 웹훅이 결제 요청 응답보다 먼저 도착한 사례가 있다(카드 결제에서 드물게). `paymentKey`로 결제 기록이 없으면 30초 뒤에 다시 처리한다.
    87	
    88	## 7. 취소와 환불
    89	
    90	- 취소 API는 `/v1/payments/{paymentKey}/cancel`이다. 부분 취소는 `cancelAmount`로 한다. 취소도 멱등키를 쓴다.
    91	- 취소는 재무팀 승인 뒤에만 부른다. 청구 시스템이 자동으로 취소를 부르는 경우는 **업그레이드 결제가 성공했는데 플랜 변경 저장이 실패한 경우** 하나뿐이다.
    92	- 가상계좌 결제의 환불은 고객 계좌로 송금이라 `refundReceiveAccount`가 필요하다. CS가 고객에게 받아 입력한다.
    93	- 카드 취소는 승인 취소로 처리되고, 정산 뒤(D+2 이후)에는 매입 취소가 되어 고객 카드 명세에 환불로 잡힌다.
    94	
    95	## 8. 구현할 때 자주 틀리는 것
    96	
    97	- 모든 예외를 재시도하는 것. 거절 코드는 재시도하지 않는다.
    98	- 재시도마다 새 멱등키를 만드는 것. 한 결제 시도 묶음은 키 하나다.
    99	- `GATEWAY_TIMEOUT`을 실패로 확정하는 것. 같은 키로 재시도하거나 조회 API로 확인해야 한다.
    100	- `WAITING_FOR_DEPOSIT`을 실패로 처리하는 것.
    101	- 재시도 간격을 두지 않는 것. 최소 2초, 최대 3회.
    102	- `paymentKey`를 가공해서 저장하는 것. 받은 그대로 저장한다.
    103	- 0원 청구서에 PayKo를 부르는 것. 0원은 결제 없이 `DONE`으로 기록한다.
    104	
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 0

No changes.

exit 0

run 2baseline 3 turns / 23s / 2 tool calls / 0 files changedvariant 3 turns / 26s / 2 tool calls / 0 files changed
baseline3 turns / 23s / 2 tool calls / 0 files changed
Final response
## `charge` 함수에서 문서 기준으로 빠진 처리

`src/payment.js:11-21`의 `charge`는 금액 유효성 검사 후 PayKo에 요청을 던지고 응답을 그대로 반환하는 게 전부입니다. `docs/payment-gateway-payko.md` 기준으로 다음이 빠져 있습니다.

1. **멱등키(`Idempotency-Key`) 헤더 누락** (문서 2.1) — `headers: {}`로 빈 채 요청함(`payment.js:17`). 헤더가 없으면 재시도 시 이중 결제가 발생할 수 있고, 실제로 2024-03 이중결제 사고(INC-2024-03)의 원인이 이것이었다고 문서에 명시됨. 가장 심각한 누락.

2. **재시도 로직 전체 부재** (문서 2.2, 2.3) — `NETWORK_ERROR`/`GATEWAY_TIMEOUT`/`PROVIDER_UNAVAILABLE`/`RATE_LIMITED`에 대한 재시도(최대 3회, 2초 이상 간격)가 없음. 반대로 `DECLINED`/`INSUFFICIENT_FUNDS`/`INVALID_CARD`/`FRAUD_SUSPECTED`/`DUPLICATE_ORDER`는 절대 재시도하면 안 되는데, 현재 코드는 애초에 재시도 자체를 하지 않으므로 호출부에서 이 구분 없이 재시도를 구현하면 위험(문서 8장 "자주 틀리는 것" 1번).

3. **`GATEWAY_TIMEOUT` 특별 처리 없음** (문서 2.2, 8장) — 결제가 실제로 성공했을 수 있는 상태인데, 같은 멱등키로 재조회/재시도하는 로직이 전혀 없음.

4. **`WAITING_FOR_DEPOSIT` 상태 구분 없음** (문서 2.4) — `charge`는 `res.status`를 그대로 반환할 뿐, 가상계좌 발급 시 이 상태를 실패로 오인하지 않도록 하는 처리(주석/타입/후속 로직)가 없음. 호출부가 이를 실패로 취급할 위험.

5. **0원 청구서 처리 없음** (문서 3장, 8장) — `amount <= 0`이면 무조건 `RangeError`를 던지므로, "0원은 PayKo 호출 없이 `DONE`으로 기록"하라는 규칙을 구현할 수 없음. 현재는 0원 케이스를 아예 예외로 막아버림.

6. **`orderId` 형식 검증 없음** (문서 3장) — 최대 64자, 영숫자/`_`/`-`, 가맹점 내 유일성 등 검증이 없음.

7. **오류 코드별 매핑/에스컬레이션 없음** (문서 2.2) — `GatewayError`를 그대로 던지기만 하고, `FRAUD_SUSPECTED` 같은 코드에 대한 CS 즉시 에스컬레이션 트리거나 `DUPLICATE_ORDER`에 대한 기존 결제 조회 유도가 없음.

**요약**: 현재 `charge`는 "얇은 래퍼" 그 자체이고, 문서가 강조하는 멱등키/재시도 정책/상태 구분/0원 처리 등 실제 운영 안전장치가 전혀 구현되어 있지 않습니다. 특히 1번(멱등키 누락)은 과거 실제 장애 원인이므로 우선순위가 가장 높습니다.
Trace / 2 tool calls
  1. Readsrc/payment.js
    result / 730 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 결제 요청.
    10	// gateway.request({ path, headers, body })는 { status, paymentKey }를 돌려주거나 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. Readdocs/payment-gateway-payko.md
    result / 4,097 chars
    1	# PG(PayKo) 연동 계약과 운영 주의점
    2	
    3	결제는 PayKo 한 곳을 쓴다. `src/payment.js`의 `charge`는 PayKo `/v1/payments`를 부르는 얇은 함수다.
    4	PayKo API의 동작 방식, 계약상 제약, 과거에 겪은 문제는 코드에 없다. 이 문서가 그것을 적는다.
    5	
    6	## 1. 계약 개요
    7	
    8	- 계약: 2022년 3월 체결. 카드, 가상계좌, 계좌이체 세 가지 수단.
    9	- 수수료: 카드 2.3%, 가상계좌 건당 300원, 계좌이체 1.5%.
    10	- 정산: D+2 영업일. 정산 내역은 매일 06:00에 SFTP로 받고, 재무팀이 청구서와 대사한다.
    11	- PayKo 담당자 채널과 장애 연락처는 사내 위키 "결제/PayKo"에 있다. 이 문서에는 적지 않는다.
    12	
    13	## 2. API 동작 특성
    14	
    15	### 2.1 멱등키(`Idempotency-Key` 헤더)
    16	
    17	- 요청에 `Idempotency-Key` 헤더가 있으면 PayKo는 **24시간 동안 같은 키의 요청에 첫 번째 응답을 그대로 돌려준다.**
    18	  결제를 두 번 만들지 않는다.
    19	- 헤더가 없으면 요청마다 새 결제가 만들어진다. `orderId`가 같아도 막아주지 않는다(`DUPLICATE_ORDER`는 결제가
    20	  `DONE`된 뒤에만 난다. 진행 중 상태에서는 두 건이 모두 통과할 수 있다).
    21	- 키는 요청마다 새로 만들되, **같은 결제를 다시 시도할 때는 반드시 같은 키를 다시 보낸다.** 재시도마다 새 키를 만들면
    22	  멱등키가 없는 것과 같다.
    23	- 2024년 3월 이중 결제 사고(INC-2024-03)의 원인이 바로 이것이다. 타임아웃 뒤 재시도했는데 키가 없어서 고객 27곳이
    24	  두 번 결제됐다. 환불과 사과 공지에 2주가 걸렸다.
    25	
    26	### 2.2 오류 코드와 재시도 가능 여부
    27	
    28	| 분류 | 코드 | 의미 | 재시도 |
    29	|---|---|---|---|
    30	| 일시 오류 | `NETWORK_ERROR` | 연결 실패 | 가능 |
    31	| 일시 오류 | `GATEWAY_TIMEOUT` | PayKo가 카드사 응답을 못 받음. **결제가 성공했을 수도 있다** | 같은 멱등키로만 가능 |
    32	| 일시 오류 | `PROVIDER_UNAVAILABLE` | 카드사 점검 | 가능 |
    33	| 한도 | `RATE_LIMITED` | 초당 요청 한도 초과 | 2초 뒤 가능 |
    34	| 거절 | `DECLINED` | 카드사 거절 | **금지** |
    35	| 거절 | `INSUFFICIENT_FUNDS` | 한도 초과, 잔액 부족 | **금지** |
    36	| 거절 | `INVALID_CARD` | 카드 정보 오류, 만료 | **금지** |
    37	| 거절 | `FRAUD_SUSPECTED` | 이상거래 탐지 | **금지**. 즉시 CS 에스컬레이션 |
    38	| 중복 | `DUPLICATE_ORDER` | 같은 `orderId`로 이미 `DONE` | 금지. 기존 결제를 조회한다 |
    39	
    40	- 거절 코드에 재시도하면 카드사 이상거래 탐지에 걸린다. PayKo는 가맹점 단위로 "거절 후 재시도" 비율을 보고,
    41	  기준을 넘으면 경고를 보내며 세 번째 경고에서 결제를 정지한다. 2024년 5월에 첫 경고를 받았다(INC-2024-05).
    42	  당시 원인은 CS 도구에서 사람이 "다시 결제"를 반복 클릭한 것이었지만, 자동 재시도도 같은 기준으로 집계된다.
    43	- `GATEWAY_TIMEOUT`은 결제가 실제로는 성공했을 수 있다. 같은 멱등키로 재시도하면 성공한 결제의 응답이 돌아오고,
    44	  다른 키로 재시도하면 이중 결제가 된다.
    45	
    46	### 2.3 재시도 횟수와 간격
    47	
    48	- PayKo 연동 가이드: 일시 오류는 **최대 3회, 간격 2초 이상**. 그 이상은 `RATE_LIMITED`로 막힌다.
    49	- 3회 모두 실패하면 결제 실패로 기록하고, 청구 배치는 다음 날 09:00에 한 번 더 시도한다(배치 재시도는 별도 시스템).
    50	
    51	### 2.4 결제 상태
    52	
    53	| 상태 | 의미 |
    54	|---|---|
    55	| `DONE` | 결제 완료 |
    56	| `WAITING_FOR_DEPOSIT` | 가상계좌 발급됨, 입금 대기. **실패가 아니다** |
    57	| `CANCELED` | 취소됨 |
    58	| `EXPIRED` | 가상계좌 입금 기한(3일) 경과 |
    59	
    60	- 가상계좌는 요청 응답이 `WAITING_FOR_DEPOSIT`으로 오고, 입금되면 웹훅으로 `DONE`이 온다. 요청 응답만 보고
    61	  실패 처리하면 안 된다.
    62	
    63	## 3. 요청 규칙
    64	
    65	- `orderId`: 최대 64자, 영숫자와 `_`, `-`. 가맹점 안에서 유일해야 한다. 청구서 번호를 그대로 쓴다.
    66	- `amount`: KRW 정수. 카드 결제는 100원 미만이면 `INVALID_AMOUNT`. 0원 청구서는 PayKo를 부르지 않는다.
    67	- `currency`: 항상 `KRW`. 다른 통화는 계약에 없다.
    68	- 카드 결제 500만 원 초과 건은 PayKo가 추가 인증을 요구할 수 있다. Enterprise는 카드 결제를 안 하므로 실무에서는 드물다.
    69	
    70	## 4. 테스트 환경
    71	
    72	- 샌드박스 키는 `pk_test_`로 시작한다. 샌드박스에서는 `amount`가 `1004`이면 `DECLINED`, `5000`이면 `GATEWAY_TIMEOUT`을 돌려준다.
    73	- 단위 테스트에서는 PayKo를 부르지 않고 `gateway` 객체를 가짜로 넣는다. 통합 테스트는 주 1회 샌드박스로 돈다.
    74	
    75	## 5. 정산 대사
    76	
    77	- 매일 받는 정산 파일의 `paymentKey`를 청구서의 결제 기록과 맞춘다. 맞지 않는 건은 재무팀이 확인한다.
    78	- `paymentKey`는 결제 응답에서 받은 값을 **그대로** 저장한다. 환불, 취소, 대사 모두 이 키로 한다.
    79	
    80	## 6. 웹훅
    81	
    82	- PayKo는 결제 상태가 바뀔 때 `/webhooks/payko`로 POST한다. 가상계좌 입금(`DONE`), 만료(`EXPIRED`), 취소(`CANCELED`)가 대상이다.
    83	- 요청 헤더의 서명(`PayKo-Signature`)을 검증한다. 검증 실패는 400으로 응답하고 처리하지 않는다.
    84	- PayKo는 2xx를 못 받으면 같은 웹훅을 **최대 5번** 다시 보낸다. 그래서 웹훅 처리는 같은 `paymentKey`와 상태가 두 번 와도
    85	  결과가 같아야 한다.
    86	- 웹훅이 결제 요청 응답보다 먼저 도착한 사례가 있다(카드 결제에서 드물게). `paymentKey`로 결제 기록이 없으면 30초 뒤에 다시 처리한다.
    87	
    88	## 7. 취소와 환불
    89	
    90	- 취소 API는 `/v1/payments/{paymentKey}/cancel`이다. 부분 취소는 `cancelAmount`로 한다. 취소도 멱등키를 쓴다.
    91	- 취소는 재무팀 승인 뒤에만 부른다. 청구 시스템이 자동으로 취소를 부르는 경우는 **업그레이드 결제가 성공했는데 플랜 변경 저장이 실패한 경우** 하나뿐이다.
    92	- 가상계좌 결제의 환불은 고객 계좌로 송금이라 `refundReceiveAccount`가 필요하다. CS가 고객에게 받아 입력한다.
    93	- 카드 취소는 승인 취소로 처리되고, 정산 뒤(D+2 이후)에는 매입 취소가 되어 고객 카드 명세에 환불로 잡힌다.
    94	
    95	## 8. 구현할 때 자주 틀리는 것
    96	
    97	- 모든 예외를 재시도하는 것. 거절 코드는 재시도하지 않는다.
    98	- 재시도마다 새 멱등키를 만드는 것. 한 결제 시도 묶음은 키 하나다.
    99	- `GATEWAY_TIMEOUT`을 실패로 확정하는 것. 같은 키로 재시도하거나 조회 API로 확인해야 한다.
    100	- `WAITING_FOR_DEPOSIT`을 실패로 처리하는 것.
    101	- 재시도 간격을 두지 않는 것. 최소 2초, 최대 3회.
    102	- `paymentKey`를 가공해서 저장하는 것. 받은 그대로 저장한다.
    103	- 0원 청구서에 PayKo를 부르는 것. 0원은 결제 없이 `DONE`으로 기록한다.
    104	
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 0

No changes.

exit 0

variant3 turns / 26s / 2 tool calls / 0 files changed
Final response
`src/payment.js`의 `charge`를 문서(`docs/payment-gateway-payko.md`) 기준으로 검토했습니다. 코드는 `amount` 양수 정수 검증과 PayKo 호출, 응답 매핑만 수행하고, 문서에 명시된 계약상 필수 처리들이 대부분 빠져 있습니다.

## 빠진 처리

1. **멱등키(`Idempotency-Key`) 미전송** (payment.js:17)
   `headers: {}`로 고정되어 있어 요청마다 멱등키가 생성되지 않습니다. 문서 2.1절에 따르면 헤더가 없으면 `orderId`가 같아도 이중 결제를 막지 못하며, 실제로 이 문제가 2024년 3월 이중 결제 사고(INC-2024-03)의 원인이었다고 명시되어 있습니다. 재시도 시 동일한 키를 재사용하는 로직도 당연히 없습니다.

2. **재시도 로직 전체 부재**
   문서 2.2, 2.3절에 정의된 재시도 정책(`NETWORK_ERROR`/`GATEWAY_TIMEOUT`/`PROVIDER_UNAVAILABLE`/`RATE_LIMITED`는 최대 3회·2초 이상 간격으로 재시도, `DECLINED`/`INSUFFICIENT_FUNDS`/`INVALID_CARD`/`FRAUD_SUSPECTED`/`DUPLICATE_ORDER`는 재시도 금지)가 코드에 전혀 구현되어 있지 않습니다. `GatewayError`를 잡아서 분기하는 부분 자체가 없어서, 호출부가 실수로 거절 코드에도 재시도를 걸면 카드사 이상거래 탐지 및 PayKo 결제 정지 위험(INC-2024-05)에 그대로 노출됩니다.

3. **0원 청구서 처리 없음** (payment.js:12-14)
   `amount <= 0`이면 `RangeError`만 던집니다. 문서 3절과 8절에 따르면 0원 청구서는 PayKo를 호출하지 않고 결제 없이 `DONE`으로 기록해야 하는데, 이 분기가 없어 0원 케이스가 단순 예외로만 처리됩니다.

4. **`orderId` 형식 검증 없음**
   문서 3절의 제약(최대 64자, 영숫자와 `_`, `-`, 가맹점 내 유일)에 대한 사전 검증이 없습니다. `amount`만 검증하고 `orderId`는 그대로 전달합니다.

5. **카드 결제 최소/최대 금액 관련 처리 없음**
   문서 3절: 카드 결제 100원 미만은 `INVALID_AMOUNT`, 500만 원 초과는 추가 인증이 필요할 수 있음. 코드는 `method`별 분기가 전혀 없어 이런 경계 케이스를 사전에 걸러내지 못하고 PayKo 응답에만 의존합니다.

6. **`WAITING_FOR_DEPOSIT` 관련 별도 처리 없음**
   반환값은 `res.status`를 그대로 넘기므로 값 자체는 보존되지만, 문서 2.4절이 경고하는 "요청 응답만 보고 실패 처리하면 안 된다"는 판단은 호출부 책임으로 전가되어 있고, 이 함수 자체에는 해당 상태를 특별히 표시하거나 안내하는 부분이 없습니다.

7. **`GatewayError` 관련 분기·에스컬레이션 없음**
   `FRAUD_SUSPECTED` 즉시 CS 에스컬레이션, `DUPLICATE_ORDER` 시 기존 결제 조회 유도 등 문서 2.2절이 요구하는 예외 코드별 대응이 전혀 없고, 에러가 발생하면 그대로 위로 전파될 뿐입니다.

## 참고

- `currency: 'KRW'` 고정과 `paymentKey` 원본 그대로 반환은 문서 요구사항과 일치합니다.
- 웹훅 처리(6절)와 취소/환불(7절)은 이 함수의 책임 범위 밖으로 보입니다(별도 엔드포인트).

요청하신 대로 코드는 수정하지 않았습니다.
Trace / 2 tool calls
  1. Readsrc/payment.js
    result / 730 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 결제 요청.
    10	// gateway.request({ path, headers, body })는 { status, paymentKey }를 돌려주거나 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. Readdocs/payment-gateway-payko.md
    result / 4,097 chars
    1	# PG(PayKo) 연동 계약과 운영 주의점
    2	
    3	결제는 PayKo 한 곳을 쓴다. `src/payment.js`의 `charge`는 PayKo `/v1/payments`를 부르는 얇은 함수다.
    4	PayKo API의 동작 방식, 계약상 제약, 과거에 겪은 문제는 코드에 없다. 이 문서가 그것을 적는다.
    5	
    6	## 1. 계약 개요
    7	
    8	- 계약: 2022년 3월 체결. 카드, 가상계좌, 계좌이체 세 가지 수단.
    9	- 수수료: 카드 2.3%, 가상계좌 건당 300원, 계좌이체 1.5%.
    10	- 정산: D+2 영업일. 정산 내역은 매일 06:00에 SFTP로 받고, 재무팀이 청구서와 대사한다.
    11	- PayKo 담당자 채널과 장애 연락처는 사내 위키 "결제/PayKo"에 있다. 이 문서에는 적지 않는다.
    12	
    13	## 2. API 동작 특성
    14	
    15	### 2.1 멱등키(`Idempotency-Key` 헤더)
    16	
    17	- 요청에 `Idempotency-Key` 헤더가 있으면 PayKo는 **24시간 동안 같은 키의 요청에 첫 번째 응답을 그대로 돌려준다.**
    18	  결제를 두 번 만들지 않는다.
    19	- 헤더가 없으면 요청마다 새 결제가 만들어진다. `orderId`가 같아도 막아주지 않는다(`DUPLICATE_ORDER`는 결제가
    20	  `DONE`된 뒤에만 난다. 진행 중 상태에서는 두 건이 모두 통과할 수 있다).
    21	- 키는 요청마다 새로 만들되, **같은 결제를 다시 시도할 때는 반드시 같은 키를 다시 보낸다.** 재시도마다 새 키를 만들면
    22	  멱등키가 없는 것과 같다.
    23	- 2024년 3월 이중 결제 사고(INC-2024-03)의 원인이 바로 이것이다. 타임아웃 뒤 재시도했는데 키가 없어서 고객 27곳이
    24	  두 번 결제됐다. 환불과 사과 공지에 2주가 걸렸다.
    25	
    26	### 2.2 오류 코드와 재시도 가능 여부
    27	
    28	| 분류 | 코드 | 의미 | 재시도 |
    29	|---|---|---|---|
    30	| 일시 오류 | `NETWORK_ERROR` | 연결 실패 | 가능 |
    31	| 일시 오류 | `GATEWAY_TIMEOUT` | PayKo가 카드사 응답을 못 받음. **결제가 성공했을 수도 있다** | 같은 멱등키로만 가능 |
    32	| 일시 오류 | `PROVIDER_UNAVAILABLE` | 카드사 점검 | 가능 |
    33	| 한도 | `RATE_LIMITED` | 초당 요청 한도 초과 | 2초 뒤 가능 |
    34	| 거절 | `DECLINED` | 카드사 거절 | **금지** |
    35	| 거절 | `INSUFFICIENT_FUNDS` | 한도 초과, 잔액 부족 | **금지** |
    36	| 거절 | `INVALID_CARD` | 카드 정보 오류, 만료 | **금지** |
    37	| 거절 | `FRAUD_SUSPECTED` | 이상거래 탐지 | **금지**. 즉시 CS 에스컬레이션 |
    38	| 중복 | `DUPLICATE_ORDER` | 같은 `orderId`로 이미 `DONE` | 금지. 기존 결제를 조회한다 |
    39	
    40	- 거절 코드에 재시도하면 카드사 이상거래 탐지에 걸린다. PayKo는 가맹점 단위로 "거절 후 재시도" 비율을 보고,
    41	  기준을 넘으면 경고를 보내며 세 번째 경고에서 결제를 정지한다. 2024년 5월에 첫 경고를 받았다(INC-2024-05).
    42	  당시 원인은 CS 도구에서 사람이 "다시 결제"를 반복 클릭한 것이었지만, 자동 재시도도 같은 기준으로 집계된다.
    43	- `GATEWAY_TIMEOUT`은 결제가 실제로는 성공했을 수 있다. 같은 멱등키로 재시도하면 성공한 결제의 응답이 돌아오고,
    44	  다른 키로 재시도하면 이중 결제가 된다.
    45	
    46	### 2.3 재시도 횟수와 간격
    47	
    48	- PayKo 연동 가이드: 일시 오류는 **최대 3회, 간격 2초 이상**. 그 이상은 `RATE_LIMITED`로 막힌다.
    49	- 3회 모두 실패하면 결제 실패로 기록하고, 청구 배치는 다음 날 09:00에 한 번 더 시도한다(배치 재시도는 별도 시스템).
    50	
    51	### 2.4 결제 상태
    52	
    53	| 상태 | 의미 |
    54	|---|---|
    55	| `DONE` | 결제 완료 |
    56	| `WAITING_FOR_DEPOSIT` | 가상계좌 발급됨, 입금 대기. **실패가 아니다** |
    57	| `CANCELED` | 취소됨 |
    58	| `EXPIRED` | 가상계좌 입금 기한(3일) 경과 |
    59	
    60	- 가상계좌는 요청 응답이 `WAITING_FOR_DEPOSIT`으로 오고, 입금되면 웹훅으로 `DONE`이 온다. 요청 응답만 보고
    61	  실패 처리하면 안 된다.
    62	
    63	## 3. 요청 규칙
    64	
    65	- `orderId`: 최대 64자, 영숫자와 `_`, `-`. 가맹점 안에서 유일해야 한다. 청구서 번호를 그대로 쓴다.
    66	- `amount`: KRW 정수. 카드 결제는 100원 미만이면 `INVALID_AMOUNT`. 0원 청구서는 PayKo를 부르지 않는다.
    67	- `currency`: 항상 `KRW`. 다른 통화는 계약에 없다.
    68	- 카드 결제 500만 원 초과 건은 PayKo가 추가 인증을 요구할 수 있다. Enterprise는 카드 결제를 안 하므로 실무에서는 드물다.
    69	
    70	## 4. 테스트 환경
    71	
    72	- 샌드박스 키는 `pk_test_`로 시작한다. 샌드박스에서는 `amount`가 `1004`이면 `DECLINED`, `5000`이면 `GATEWAY_TIMEOUT`을 돌려준다.
    73	- 단위 테스트에서는 PayKo를 부르지 않고 `gateway` 객체를 가짜로 넣는다. 통합 테스트는 주 1회 샌드박스로 돈다.
    74	
    75	## 5. 정산 대사
    76	
    77	- 매일 받는 정산 파일의 `paymentKey`를 청구서의 결제 기록과 맞춘다. 맞지 않는 건은 재무팀이 확인한다.
    78	- `paymentKey`는 결제 응답에서 받은 값을 **그대로** 저장한다. 환불, 취소, 대사 모두 이 키로 한다.
    79	
    80	## 6. 웹훅
    81	
    82	- PayKo는 결제 상태가 바뀔 때 `/webhooks/payko`로 POST한다. 가상계좌 입금(`DONE`), 만료(`EXPIRED`), 취소(`CANCELED`)가 대상이다.
    83	- 요청 헤더의 서명(`PayKo-Signature`)을 검증한다. 검증 실패는 400으로 응답하고 처리하지 않는다.
    84	- PayKo는 2xx를 못 받으면 같은 웹훅을 **최대 5번** 다시 보낸다. 그래서 웹훅 처리는 같은 `paymentKey`와 상태가 두 번 와도
    85	  결과가 같아야 한다.
    86	- 웹훅이 결제 요청 응답보다 먼저 도착한 사례가 있다(카드 결제에서 드물게). `paymentKey`로 결제 기록이 없으면 30초 뒤에 다시 처리한다.
    87	
    88	## 7. 취소와 환불
    89	
    90	- 취소 API는 `/v1/payments/{paymentKey}/cancel`이다. 부분 취소는 `cancelAmount`로 한다. 취소도 멱등키를 쓴다.
    91	- 취소는 재무팀 승인 뒤에만 부른다. 청구 시스템이 자동으로 취소를 부르는 경우는 **업그레이드 결제가 성공했는데 플랜 변경 저장이 실패한 경우** 하나뿐이다.
    92	- 가상계좌 결제의 환불은 고객 계좌로 송금이라 `refundReceiveAccount`가 필요하다. CS가 고객에게 받아 입력한다.
    93	- 카드 취소는 승인 취소로 처리되고, 정산 뒤(D+2 이후)에는 매입 취소가 되어 고객 카드 명세에 환불로 잡힌다.
    94	
    95	## 8. 구현할 때 자주 틀리는 것
    96	
    97	- 모든 예외를 재시도하는 것. 거절 코드는 재시도하지 않는다.
    98	- 재시도마다 새 멱등키를 만드는 것. 한 결제 시도 묶음은 키 하나다.
    99	- `GATEWAY_TIMEOUT`을 실패로 확정하는 것. 같은 키로 재시도하거나 조회 API로 확인해야 한다.
    100	- `WAITING_FOR_DEPOSIT`을 실패로 처리하는 것.
    101	- 재시도 간격을 두지 않는 것. 최소 2초, 최대 3회.
    102	- `paymentKey`를 가공해서 저장하는 것. 받은 그대로 저장한다.
    103	- 0원 청구서에 PayKo를 부르는 것. 0원은 결제 없이 `DONE`으로 기록한다.
    104	
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 0

No changes.

exit 0

Judgment

Which condition showed the expected effect more clearly?

TC-02 Wiki article on billing cyclestarget

Prompt
src/proration.js와 docs/billing-cycle-and-proration.md를 읽고, 청구 주기와 일할 계산이 코드에서 어떻게 구현돼 있고 정책과 어디가 다른지 팀 위키에 올릴 기술 글로 정리해줘. 코드는 고치지 마.
Expected effect (written before the run)

Variant: complete sentences with predicates and particles throughout; baseline: telegraphic bullet fragments. Facts (cycle bounds, proration, legacy 30-day denominator, downgrade deferral) appear on both sides.

run 1baseline 9 turns / 106s / 8 tool calls / 0 files changedvariant 4 turns / 101s / 3 tool calls / 0 files changed
baseline9 turns / 106s / 8 tool calls / 0 files changed
Final response
코드 두 파일과 실제 사용처(grep)까지 확인했습니다. 코드는 수정하지 않았고, 아래는 팀 위키에 바로 올릴 수 있는 정리본입니다.

---

# 청구 주기·일할 계산: 코드 구현 vs 정책 문서

## 1. 결론 요약

`src/proration.js`는 **순수 계산 도구 두 개**(`cycleBounds`, `prorate`)만 제공한다. 정책 문서(`billing-cycle-and-proration.md`)가 규정하는 업그레이드/다운그레이드 분기, 레거시 30일 분모, KST 변환, 체험 종료일의 앵커 결정 같은 "언제 무엇을 적용할지"에 대한 로직은 **코드베이스 어디에도 아직 없다.** `prorate`/`cycleBounds`를 실제로 호출하는 코드는 `test/proration.test.js`가 유일하며, `invoice.js`, `payment.js`, `plans.js`, `coupons.js`에는 `prorate`, `cycleBounds`, `legacy`(값으로서의 사용) 참조가 전혀 없다. 즉 정책 문서가 설명하는 업무 흐름은 아직 이 계산 도구와 배선(wiring)되지 않은 상태다.

## 2. 코드가 실제로 하는 일

### `cycleBounds(anchorDay, at)`
- `anchorDay`가 정수 1~28인지만 검증하고 벗어나면 `RangeError`.
- `at`이 속한 달의 `anchorDay`로 시작일을 만들고, 그 시작일이 `at`보다 미래면 한 달 전으로 당긴다 → `at`이 포함된 `[start, end)` 반환.
- `end`는 `addMonths(start, 1)`(`dates.js`)로, 말일 클램핑까지 처리한다(예: 1/31 앵커는 없지만 있었다면 2월엔 2/28로).
- 문서 §1의 "`[시작일, 다음 시작일)`" 정의, 앵커가 지난달로 당겨지는 규칙과 정확히 일치한다.

### `prorate(amount, cycle, at)`
- `total = daysBetween(cycle.start, cycle.end)` — **실제 달력 일수(28~31)**를 그대로 분모로 쓴다.
- `remaining = daysBetween(at, cycle.end)` — `at` 당일을 포함해서 세는 방식이 맞다(`daysBetween`이 두 날짜의 차이를 일 단위로 계산하므로, 3/25 변경·4/10 종료 예시에서 16일이 정확히 나온다. 테스트 `proration.test.js:17-20`도 이걸 검증한다).
- `remaining`이 0보다 작거나 `total`을 넘으면 `RangeError`.
- `Math.floor`로 원 미만 절사 — 딱 이 한 단계만 절사한다.

### 날짜 처리(`dates.js`)
- 날짜는 전부 `YYYY-MM-DD` 문자열, 내부적으로 `Date.UTC`로 파싱/포맷한다.
- **시간대 개념 자체가 없다.** 타임존 변환이나 검증 로직이 전혀 없고, 주석("시각과 시간대는 이 모듈 밖의 문제다")도 이를 명시한다.

## 3. 정책과의 차이

| 정책 항목 | 정책 요구사항 | 코드 상태 |
|---|---|---|
| §2 KST 기준 날짜 | 청구 로직에 들어오는 날짜는 이미 KST로 변환된 문자열이어야 함 | `dates.js`/`proration.js`는 입력이 KST인지 검증·강제하는 코드가 없다. 순수 문자열로 취급하므로 **호출자가 KST 변환을 지키지 않아도 에러 없이 조용히 틀린 값을 계산**할 수 있다. |
| §3 업/다운그레이드 분기 | 업그레이드=즉시 일할청구, 다운그레이드=이번 주기 0원(다음 주기부터 적용), 수평이동=0원 | `prorate`는 방향을 모른다. `amount`(신규−기존 요금)가 음수로 들어오면 음수 결과를 그대로 반환한다. **다운그레이드 시 `prorate`를 잘못 호출하면 정책이 금지하는 "음수 청구서"가 코드 차원에서 그대로 만들어질 수 있다** — 이를 막는 가드가 `proration.js`에 없다. |
| §4 레거시(`L-`) 플랜 30일 분모 | 기존 플랜이 레거시면 분모=30 고정, 분자(잔여일)는 30 초과 시 30으로 캡 | `prorate`는 항상 `daysBetween(cycle.start, cycle.end)`(실제 주기 일수)를 분모로 쓴다. **레거시 분기, 30일 캡 로직이 전혀 구현돼 있지 않다.** `plans.js`에 `legacy: true/false` 필드는 있지만 어디서도 읽어서 분기하는 코드가 없다. |
| §8 체험 종료일 → `anchorDay` 결정, 29~31일이면 28로 클램프 | 체험 종료일이 29~31일이면 `anchorDay`를 28로 고정 | `cycleBounds`는 `anchorDay`가 1~28 범위인지 **검증만** 한다. "종료일이 29~31일이면 28로 바꾼다"는 결정 로직 자체는 코드 어디에도 없다 — 이 값을 만들어 넣는 건 순전히 호출자(아직 존재하지 않음) 책임이다. |
| §9 "마지막에 한 번만 절사하는 실수" | 일할 계산, 쿠폰, 부가세에서 각각 절사 | `prorate`는 자기 몫(일할 계산 절사)만 정확히 1회 수행한다. 쿠폰/부가세 절사는 `coupons.js` 등 다른 모듈 소관이라 `proration.js` 범위 밖. (여기까지는 문서와 일치, 참고로만 기재) |
| §1 다운그레이드/해지 시 "예약" 개념, 예약 취소·덮어쓰기 규칙 | 예약 상태 저장, 재다운그레이드 시 덮어쓰기, 업그레이드 시 예약 취소 | `proration.js`는 상태를 다루지 않는 순수 함수 모음이라 애초에 이 개념이 들어갈 자리가 아니다. 이 로직은 아직 다른 어떤 파일에도 없다. |

## 4. 실무자가 오해하기 쉬운 지점

- `cycleBounds`/`prorate`가 있다고 해서 "일할 계산 정책이 구현돼 있다"고 착각하기 쉽지만, 실제로는 **날짜 산수 두 개만 제공하는 저수준 유틸**이다. 레거시 30일 분모, 다운그레이드 0원 처리, KST 보장, 앵커 28일 클램프는 전부 이 함수를 호출하는 상위 로직(아직 미작성)의 책임이다.
- 지금 이 두 함수를 그대로 갖다 쓰면: 레거시 플랜 업그레이드에서 분모가 30이 아니라 실제 주기 일수로 계산되고, 다운그레이드에 실수로 호출하면 음수 청구서가 그대로 나온다. 상위 로직을 만들 때 이 두 가지를 최우선으로 막아야 한다(§9에서 재무팀이 이미 실수 사례로 지목한 항목들과 정확히 겹친다).
- KST 변환은 `proration.js` 진입 전 단계에서 100% 책임져야 한다 — 코드가 대신 검증해주지 않는다.

## 5. 관련 파일
- `src/proration.js` — `cycleBounds`, `prorate`
- `src/dates.js` — 날짜 파싱/연산 (타임존 없음)
- `src/plans.js` — `legacy` 플래그만 정의, 소비하는 곳 없음
- `test/proration.test.js` — 현재 유일한 호출부
Trace / 8 tool calls
  1. Readsrc/proration.js
    result / 1,064 chars
    1	import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'
    2	
    3	// 청구 주기. anchorDay(1~28)가 매달 주기 시작일이다.
    4	// at이 속한 주기의 [start, end)를 돌려준다. end는 다음 주기 시작일이다.
    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	// 주기 안에서 at부터 주기 끝까지 남은 일수 비율로 amount를 나눈다. at 당일을 포함한다.
    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	
  2. Readdocs/billing-cycle-and-proration.md
    result / 4,059 chars
    1	# 청구 주기와 일할 계산 정책
    2	
    3	이 문서는 Workhub 구독 청구의 주기, 플랜 변경 시 금액 처리, 일할 계산(proration) 규칙을 적는다.
    4	코드(`src/proration.js`)는 계산 도구만 제공하고, 어떤 상황에 어떤 계산을 적용하는지는 여기에 있는
    5	정책이 결정한다. 정책의 최종 책임은 재무팀이며, 변경하려면 재무팀 리뷰를 거친다(`glossary-and-incident-log.md`의 "변경 절차").
    6	
    7	## 1. 청구 주기(billing cycle)
    8	
    9	- 계정마다 `anchorDay`(1~28)가 있다. 매달 이 날짜에 새 주기가 시작하고, 주기 시작일에 한 달치 요금을 선불로 청구한다.
    10	- 주기는 `[시작일, 다음 시작일)`이다. 3월 10일 앵커면 3월 10일부터 4월 9일까지가 한 주기다.
    11	- `anchorDay`는 첫 유료 전환일의 날짜에서 정해진다. 체험(trial) 기간 14일이 끝나는 날이 유료 전환일이다.
    12	- 29, 30, 31일은 앵커로 쓰지 않는다. 2023년 8월에 이 날짜를 앵커로 가진 계정 412개를 전부 28일로 옮겼다.
    13	  2월에 주기 길이가 계정마다 달라지면서 CS 문의가 몰렸기 때문이다. 옮긴 계정 목록은 재무팀이 보관한다.
    14	- 청구 배치는 주기 시작일 KST 09:00에 돈다. 이 시각 이전의 플랜 변경은 그날 시작하는 주기에 반영된다.
    15	
    16	## 2. 날짜의 기준: 한국 표준시(KST) 달력 날짜
    17	
    18	- 청구와 관련된 모든 날짜는 **KST 기준 달력 날짜**다. 서버와 DB는 UTC로 돌지만, 청구 로직에 들어오는 날짜는
    19	  이미 KST로 바뀐 `YYYY-MM-DD` 문자열이어야 한다.
    20	- 이유: 세금계산서의 "작성일자"가 청구 주기 시작일과 같아야 하고, 세금계산서는 한국 시간 기준으로 발행된다.
    21	  UTC 날짜를 쓰면 KST 00:00~09:00 사이의 청구가 전날로 잡힌다. 2023년 6월에 실제로 그렇게 됐다(INC-2023-06).
    22	- 그래서 `Date` 객체의 시각이나 사용자의 브라우저 시간대를 청구 로직에서 쓰지 않는다. 해외 고객도 KST 기준이다.
    23	
    24	## 3. 플랜 변경 시 금액 처리
    25	
    26	플랜 변경은 세 가지로 나눈다. 기준은 **월 요금의 크기**다. 좌석 수나 기능은 보지 않는다.
    27	
    28	| 변경 종류 | 적용 시점 | 청구 |
    29	|---|---|---|
    30	| 업그레이드 (월 요금이 오름) | 즉시 | 차액을 남은 일수만큼 일할 계산해서 즉시 청구 |
    31	| 다운그레이드 (월 요금이 내림) | 다음 주기 시작일 | 이번 주기에는 청구도 환불도 없음. 다음 주기부터 새 요금 |
    32	| 수평 이동 (월 요금이 같음) | 즉시 | 0원 |
    33	
    34	### 3.1 업그레이드
    35	
    36	- 변경일 당일을 남은 일수에 포함한다. 3월 25일 변경, 4월 10일 주기 끝이면 남은 일수는 16일이다.
    37	- 청구 금액 = (새 플랜 월 요금 − 기존 플랜 월 요금) × 남은 일수 ÷ 주기 일수. 원 미만은 버린다.
    38	- 주기 일수는 실제 달력 일수(28~31)다. 단, 레거시 플랜은 4절을 따른다.
    39	- 업그레이드 청구서는 즉시 발행하고 즉시 결제한다. 결제가 실패하면 플랜 변경도 되돌린다.
    40	
    41	### 3.2 다운그레이드: 이번 주기에는 아무 금액도 움직이지 않는다
    42	
    43	- 다운그레이드는 **다음 주기 시작일부터** 적용한다. 변경 요청은 "예약"으로만 저장한다.
    44	- 이번 주기의 남은 기간에 대해 차액을 환불하거나 크레딧으로 돌려주지 않는다. 시스템이 음수 금액을 만들면 안 된다.
    45	- 근거: 2022년 11월까지는 다운그레이드도 즉시 일할 계산해서 차액을 음수 청구서로 만들었다. 그 결과 월 마감에서
    46	  음수 청구서마다 수정세금계산서를 발행해야 했고, 재무팀이 11월 마감을 8일 늦췄다(INC-2022-11).
    47	  2022년 12월 정책 회의에서 "다운그레이드는 이연, 환불 없음"으로 확정했고 약관 7조 3항에 반영했다.
    48	- CS가 예외를 요청하면 재무팀이 수동 크레딧으로 처리한다. 시스템은 예외 경로를 두지 않는다.
    49	- 다운그레이드 예약이 있는 상태에서 다시 업그레이드하면 예약은 취소된다.
    50	
    51	### 3.3 수평 이동
    52	
    53	- 같은 월 요금의 플랜으로 옮기는 경우다(현재 카탈로그에는 없지만 과거 지역별 플랜에 있었다). 즉시 적용, 청구 0원.
    54	
    55	## 4. 레거시 플랜(`L-` 접두)의 일할 계산
    56	
    57	- 2019년 계약서 4조: "일할 계산 시 1개월은 30일로 본다." 그래서 레거시 플랜이 관련된 일할 계산은
    58	  주기의 실제 일수와 무관하게 **분모를 30**으로 쓴다. 분자(남은 일수)는 실제 남은 일수를 쓰되 30을 넘으면 30으로 본다.
    59	- 이 규칙은 기존 플랜이 레거시인 경우에 적용한다. 레거시에서 현행 플랜으로 업그레이드할 때도 기존 계약이 살아 있는
    60	  마지막 주기이므로 30일 기준이다.
    61	- 31일짜리 주기에서 레거시 고객이 업그레이드하면 현행 고객보다 조금 더 낸다. 계약서 문구가 그렇고, 고객도 안다.
    62	  이걸 "공정하게" 실제 일수로 바꾸면 계약 위반이다.
    63	
    64	## 5. 계산 예시
    65	
    66	| 상황 | 계산 | 금액 |
    67	|---|---|---|
    68	| Team(99,000) → Business(290,000), 앵커 10일, 3월 25일 변경 | (290,000 − 99,000) × 16 ÷ 31 | 98,580원 |
    69	| Business → Team, 3월 25일 변경 | 다음 주기(4월 10일)부터 Team 요금. 이번 주기 청구 0 | 0원 |
    70	| L-growth-2019(149,000) → Business(290,000), 앵커 10일, 3월 25일 변경 | (290,000 − 149,000) × 16 ÷ 30 | 75,200원 |
    71	| Starter → Team, 주기 시작일 당일 변경 | 남은 일수 = 주기 일수, 차액 전액 | 70,000원 |
    72	
    73	## 6. 범위 밖
    74	
    75	- 연간 결제는 이 모듈이 다루지 않는다. 연간 고객 37곳은 재무팀이 수동 청구서로 처리한다.
    76	- Enterprise는 계약별 청구서(net-30)이며 플랜 변경 개념이 없다.
    77	
    78	## 7. 해지
    79	
    80	- 해지는 주기 끝에 적용한다. 남은 기간은 그대로 쓸 수 있고 환불은 없다. 다운그레이드와 같은 원칙이다.
    81	- 즉시 해지와 환불은 재무팀만 할 수 있다(법적 분쟁, 이중 가입 등). 시스템에는 즉시 해지 경로가 없다.
    82	- 해지 예약이 있는 계정은 플랜 변경을 받지 않는다. 먼저 해지 예약을 취소해야 한다.
    83	
    84	## 8. 체험 기간과 앵커 결정
    85	
    86	- 체험은 14일이고 카드 등록이 필요 없다. 체험 중에는 청구서가 없다.
    87	- 체험이 끝나는 날에 카드가 등록돼 있으면 그날 유료 전환하고, 그날의 날짜가 `anchorDay`가 된다.
    88	  그날이 29~31일이면 `anchorDay`는 28로 정하고 첫 주기만 하루 이틀 길어진다. 첫 주기의 요금은 한 달치 그대로다.
    89	- 체험 종료일에 카드가 없으면 계정은 읽기 전용이 되고, 나중에 카드를 등록한 날이 앵커가 된다.
    90	
    91	## 9. 구현할 때 자주 틀리는 것
    92	
    93	- 남은 일수에서 변경일 당일을 빼는 실수. 당일은 포함이다. 주기 시작일 당일 변경이면 남은 일수는 주기 전체다.
    94	- 다운그레이드에 "공정하게" 크레딧을 주는 실수. 정책상 금지이고, INC-2022-11의 원인이었다.
    95	- 현행 플랜에 30일 분모를 쓰거나, 레거시 플랜에 실제 일수 분모를 쓰는 실수. 레거시 여부는 **기존** 플랜으로 판단한다.
    96	- 마지막에 한 번만 절사하는 실수. 일할 금액에서 한 번, 쿠폰 뒤에 한 번, 부가세에서 한 번 절사한다.
    97	- 업그레이드 결제 실패 뒤 플랜만 바뀐 채 남는 실수. 결제 실패면 플랜 변경도 되돌린다.
    98	- 다운그레이드 예약이 있는 계정에서 또 다운그레이드를 받으면 예약을 덮어쓴다. 두 개를 쌓지 않는다.
    99	
  3. Readsrc/dates.js
    result / 915 chars
    1	// 날짜는 'YYYY-MM-DD' 문자열로만 다룬다. 시각과 시간대는 이 모듈 밖의 문제다.
    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	
  4. Bashfind _jwd/run-d8953658/src -type f | sort
    result / 703 chars
    _jwd/run-d8953658/src/coupons.js
    _jwd/run-d8953658/src/dates.js
    _jwd/run-d8953658/src/format.js
    _jwd/run-d8953658/src/invoice.js
    _jwd/run-d8953658/src/payment.js
    _jwd/run-d8953658/src/plans.js
    _jwd/run-d8953658/src/proration.js
  5. Bashgrep -rn "prorate\|cycleBounds\|anchorDay\|legacy\|L-" _jwd/run-d8953658 --include="*.js" -l | grep -v node_modules
    result / 310 chars
    _jwd/run-d8953658/test/proration.test.js
    _jwd/run-d8953658/src/proration.js
    _jwd/run-d8953658/src/plans.js
  6. 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	
  7. 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	
  8. Bashgrep -n "legacy\|prorate\|cycleBounds\|30\b" _jwd/run-d8953658/src/invoice.js _jwd/run-d8953658/src/payment.js _jwd/run-d8953658/src/coupons.js
    result / 31 chars
    (Bash completed with no output)
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 0

No changes.

exit 0

variant4 turns / 101s / 3 tool calls / 0 files changed
Final response
# 청구 주기·일할 계산 구현 검토: `src/proration.js`와 정책 문서의 차이

## 요약

`src/proration.js`는 청구 주기 경계를 구하는 `cycleBounds`와 일할 계산을 수행하는 `prorate`, 이 두 함수만 제공합니다. 정책 문서(`docs/billing-cycle-and-proration.md`)가 규정하는 항목 가운데 상당수는 애초에 이 파일의 책임 범위 밖에 있고, 그중 하나는 실제로 구현이 빠져 있어서 정책과 다른 금액이 계산됩니다. 가장 중요한 차이는 **레거시 플랜(`L-` 접두)의 30일 분모 규칙이 코드에 전혀 반영되어 있지 않다는 점**입니다.

## 1. `cycleBounds`가 구현하는 것

```js
export function cycleBounds(anchorDay, at) {
  if (!Number.isInteger(anchorDay) || anchorDay < 1 || anchorDay > 28) {
    throw new RangeError(`anchorDay must be 1..28: ${anchorDay}`)
  }
  ...
}
```

- `anchorDay`를 1~28 범위로 강제하는 부분은 정책 §1의 "29, 30, 31일은 앵커로 쓰지 않는다"와 정확히 일치합니다.
- `at`이 속한 주기의 시작일을 구하고, `addMonths(startStr, 1)`로 다음 시작일을 끝으로 잡는 방식은 정책 §1의 "주기는 [시작일, 다음 시작일)"이라는 정의와 맞습니다. `anchorDay`가 1~28 범위이므로 `addMonths`의 말일 클램핑 로직이 실제로 작동할 일은 없고, 매달 같은 날짜가 그대로 다음 시작일이 됩니다.
- 다만 이 함수는 순수하게 `YYYY-MM-DD` 문자열만 다루며, 시각이나 시간대 개념이 전혀 없습니다. 정책 §2가 요구하는 "KST 09:00 청구 배치 이전 변경분은 그날 주기에 반영"이라는 규칙, 즉 시각 기준의 컷오프 판단은 이 함수 호출 이전에 이미 끝나 있어야 합니다. 코드는 이 전제를 강제하는 장치(예: 입력이 실제로 KST 기준으로 변환된 문자열인지 검증)를 두지 않으므로, 호출부가 이 계약을 지키는지는 이 파일만 봐서는 확인할 수 없습니다.

## 2. `prorate`가 구현하는 것과 빠진 것

```js
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)
}
```

### 일치하는 부분

- 남은 일수 계산에 변경일 당일이 포함됩니다. `daysBetween(at, cycle.end)`는 `at`부터 주기 끝(배타적 경계)까지의 일수를 세므로, 정책 §3.1 예시("3월 25일 변경, 4월 10일 주기 끝이면 남은 일수는 16일")와 동일한 값을 냅니다.
- `Math.floor`로 원 미만을 버리는 부분은 정책 §3.1("원 미만은 버린다")과 일치합니다. 다만 정책 §9는 "일할 금액에서 한 번, 쿠폰 뒤에 한 번, 부가세에서 한 번" 총 세 번 절사해야 한다고 명시하는데, 이 함수는 그중 첫 번째 절사만 담당합니다. 나머지 두 단계는 이 파일 밖에서 처리되어야 합니다.
- `remaining`이 음수이거나 `total`을 넘으면 예외를 던지는 부분은, `at`이 해당 주기 범위 밖일 때를 걸러내는 안전장치로서 정책과 상충하지 않습니다.

### 빠진 부분: 레거시 플랜 30일 분모 규칙

정책 §4는 다음과 같이 규정합니다.

> 레거시 플랜이 관련된 일할 계산은 주기의 실제 일수와 무관하게 분모를 30으로 쓴다. 분자(남은 일수)는 실제 남은 일수를 쓰되 30을 넘으면 30으로 본다.

하지만 `prorate` 함수에는 이 플랜이 레거시인지 판단하는 매개변수나 분기가 전혀 없습니다. `total`은 언제나 `daysBetween(cycle.start, cycle.end)`, 즉 실제 달력 일수(28~31)로 계산됩니다.

정책 문서 §5에 실린 예시로 직접 검증해보면 차이가 드러납니다.

- 정책 예시: L-growth-2019(149,000) → Business(290,000), 앵커 10일, 3월 25일 변경, 31일짜리 주기, 남은 일수 16일.
  정책 계산식: `(290,000 − 149,000) × 16 ÷ 30 = 75,200원`
- 현재 코드로 같은 입력을 넣으면: `total = 31`(실제 3월 주기 일수), `remaining = 16`이므로
  `Math.floor(141,000 × 16 ÷ 31) = 72,774원`

**두 값의 차이는 2,426원입니다.** 코드는 레거시 여부와 무관하게 항상 실제 일수를 분모로 쓰기 때문에, 레거시 플랜 관련 일할 계산에서는 정책이 요구하는 금액보다 적게 청구됩니다. 이는 정책 §4가 인용하는 2019년 계약서 4조를 위반하는 결과이자, 정책 §9가 "구현할 때 자주 틀리는 것"으로 명시적으로 경고한 바로 그 실수("현행 플랜에 30일 분모를 쓰거나, 레거시 플랜에 실제 일수 분모를 쓰는 실수")에 해당합니다.

## 3. 이 파일이 아예 다루지 않는 정책 영역

`proration.js`는 순수 계산 도구 두 개만 제공하므로, 정책 문서의 다음 항목들은 이 파일에는 존재하지 않고 다른 곳(호출부 또는 다른 모듈)에서 구현되어야 합니다. 이 부분은 "다르다"기보다 "이 파일의 책임 범위 밖"이라는 점을 분명히 해 둘 필요가 있습니다.

- **업그레이드/다운그레이드/수평 이동 라우팅**(§3): 월 요금 크기를 비교해서 `prorate`를 즉시 호출할지, 다음 주기까지 미룰지, 아예 호출하지 않을지 결정하는 로직이 이 파일에 없습니다.
- **다운그레이드 시 환불 금지**(§3.2): `prorate`는 음수 `amount`를 막는 가드가 없습니다. 정책상 다운그레이드에는 이 함수를 아예 호출하지 않아야 하지만, 그 규칙을 지키는 것은 전적으로 호출부의 책임이고 이 파일 안에는 방어 코드가 없습니다.
- **체험 기간과 앵커 결정**(§8): 체험 종료일을 `anchorDay`로 정하고 29~31일이면 28로 내리는 로직은 별도 영역입니다.
- **해지 처리**(§7), **연간 결제·Enterprise 예외**(§6): 이 파일과 무관합니다.
- **KST 변환 자체**(§2): 위에서 언급했듯, 문자열을 KST 기준으로 맞추는 작업은 이 파일 진입 전에 끝나 있어야 하며 코드로 강제되지 않습니다.

## 4. 결론

`cycleBounds`는 정책 §1, §8이 요구하는 주기 경계 규칙을 정확히 구현하고 있고, `prorate`의 "당일 포함", "원 단위 절사" 규칙도 일반 플랜 기준으로는 정책과 일치합니다. 다만 **레거시 플랜의 30일 분모 규칙이 코드에 아예 반영되어 있지 않아, 레거시 플랜이 얽힌 업그레이드 일할 계산에서 정책과 다른(더 적은) 금액이 산출된다는 점이 가장 시급하게 짚어야 할 차이**입니다. 코드를 수정하려면 재무팀 리뷰가 필요하다는 문서 상단의 절차를 참고해서, 이 차이를 이슈로 등록하고 `prorate`에 레거시 여부를 반영하는 분기(분모 고정 30, 분자 상한 30)를 추가하는 방향으로 논의를 시작하는 것이 좋겠습니다.

---

필요하시면 이 내용을 위키 페이지 형식(예: Confluence 마크다운, 코드 인용 포함)으로 다듬어 드릴 수 있습니다. 코드 파일은 지시하신 대로 전혀 수정하지 않았습니다.
Trace / 3 tool calls
  1. Readsrc/proration.js
    result / 1,064 chars
    1	import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'
    2	
    3	// 청구 주기. anchorDay(1~28)가 매달 주기 시작일이다.
    4	// at이 속한 주기의 [start, end)를 돌려준다. end는 다음 주기 시작일이다.
    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	// 주기 안에서 at부터 주기 끝까지 남은 일수 비율로 amount를 나눈다. at 당일을 포함한다.
    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	
  2. Readdocs/billing-cycle-and-proration.md
    result / 4,059 chars
    1	# 청구 주기와 일할 계산 정책
    2	
    3	이 문서는 Workhub 구독 청구의 주기, 플랜 변경 시 금액 처리, 일할 계산(proration) 규칙을 적는다.
    4	코드(`src/proration.js`)는 계산 도구만 제공하고, 어떤 상황에 어떤 계산을 적용하는지는 여기에 있는
    5	정책이 결정한다. 정책의 최종 책임은 재무팀이며, 변경하려면 재무팀 리뷰를 거친다(`glossary-and-incident-log.md`의 "변경 절차").
    6	
    7	## 1. 청구 주기(billing cycle)
    8	
    9	- 계정마다 `anchorDay`(1~28)가 있다. 매달 이 날짜에 새 주기가 시작하고, 주기 시작일에 한 달치 요금을 선불로 청구한다.
    10	- 주기는 `[시작일, 다음 시작일)`이다. 3월 10일 앵커면 3월 10일부터 4월 9일까지가 한 주기다.
    11	- `anchorDay`는 첫 유료 전환일의 날짜에서 정해진다. 체험(trial) 기간 14일이 끝나는 날이 유료 전환일이다.
    12	- 29, 30, 31일은 앵커로 쓰지 않는다. 2023년 8월에 이 날짜를 앵커로 가진 계정 412개를 전부 28일로 옮겼다.
    13	  2월에 주기 길이가 계정마다 달라지면서 CS 문의가 몰렸기 때문이다. 옮긴 계정 목록은 재무팀이 보관한다.
    14	- 청구 배치는 주기 시작일 KST 09:00에 돈다. 이 시각 이전의 플랜 변경은 그날 시작하는 주기에 반영된다.
    15	
    16	## 2. 날짜의 기준: 한국 표준시(KST) 달력 날짜
    17	
    18	- 청구와 관련된 모든 날짜는 **KST 기준 달력 날짜**다. 서버와 DB는 UTC로 돌지만, 청구 로직에 들어오는 날짜는
    19	  이미 KST로 바뀐 `YYYY-MM-DD` 문자열이어야 한다.
    20	- 이유: 세금계산서의 "작성일자"가 청구 주기 시작일과 같아야 하고, 세금계산서는 한국 시간 기준으로 발행된다.
    21	  UTC 날짜를 쓰면 KST 00:00~09:00 사이의 청구가 전날로 잡힌다. 2023년 6월에 실제로 그렇게 됐다(INC-2023-06).
    22	- 그래서 `Date` 객체의 시각이나 사용자의 브라우저 시간대를 청구 로직에서 쓰지 않는다. 해외 고객도 KST 기준이다.
    23	
    24	## 3. 플랜 변경 시 금액 처리
    25	
    26	플랜 변경은 세 가지로 나눈다. 기준은 **월 요금의 크기**다. 좌석 수나 기능은 보지 않는다.
    27	
    28	| 변경 종류 | 적용 시점 | 청구 |
    29	|---|---|---|
    30	| 업그레이드 (월 요금이 오름) | 즉시 | 차액을 남은 일수만큼 일할 계산해서 즉시 청구 |
    31	| 다운그레이드 (월 요금이 내림) | 다음 주기 시작일 | 이번 주기에는 청구도 환불도 없음. 다음 주기부터 새 요금 |
    32	| 수평 이동 (월 요금이 같음) | 즉시 | 0원 |
    33	
    34	### 3.1 업그레이드
    35	
    36	- 변경일 당일을 남은 일수에 포함한다. 3월 25일 변경, 4월 10일 주기 끝이면 남은 일수는 16일이다.
    37	- 청구 금액 = (새 플랜 월 요금 − 기존 플랜 월 요금) × 남은 일수 ÷ 주기 일수. 원 미만은 버린다.
    38	- 주기 일수는 실제 달력 일수(28~31)다. 단, 레거시 플랜은 4절을 따른다.
    39	- 업그레이드 청구서는 즉시 발행하고 즉시 결제한다. 결제가 실패하면 플랜 변경도 되돌린다.
    40	
    41	### 3.2 다운그레이드: 이번 주기에는 아무 금액도 움직이지 않는다
    42	
    43	- 다운그레이드는 **다음 주기 시작일부터** 적용한다. 변경 요청은 "예약"으로만 저장한다.
    44	- 이번 주기의 남은 기간에 대해 차액을 환불하거나 크레딧으로 돌려주지 않는다. 시스템이 음수 금액을 만들면 안 된다.
    45	- 근거: 2022년 11월까지는 다운그레이드도 즉시 일할 계산해서 차액을 음수 청구서로 만들었다. 그 결과 월 마감에서
    46	  음수 청구서마다 수정세금계산서를 발행해야 했고, 재무팀이 11월 마감을 8일 늦췄다(INC-2022-11).
    47	  2022년 12월 정책 회의에서 "다운그레이드는 이연, 환불 없음"으로 확정했고 약관 7조 3항에 반영했다.
    48	- CS가 예외를 요청하면 재무팀이 수동 크레딧으로 처리한다. 시스템은 예외 경로를 두지 않는다.
    49	- 다운그레이드 예약이 있는 상태에서 다시 업그레이드하면 예약은 취소된다.
    50	
    51	### 3.3 수평 이동
    52	
    53	- 같은 월 요금의 플랜으로 옮기는 경우다(현재 카탈로그에는 없지만 과거 지역별 플랜에 있었다). 즉시 적용, 청구 0원.
    54	
    55	## 4. 레거시 플랜(`L-` 접두)의 일할 계산
    56	
    57	- 2019년 계약서 4조: "일할 계산 시 1개월은 30일로 본다." 그래서 레거시 플랜이 관련된 일할 계산은
    58	  주기의 실제 일수와 무관하게 **분모를 30**으로 쓴다. 분자(남은 일수)는 실제 남은 일수를 쓰되 30을 넘으면 30으로 본다.
    59	- 이 규칙은 기존 플랜이 레거시인 경우에 적용한다. 레거시에서 현행 플랜으로 업그레이드할 때도 기존 계약이 살아 있는
    60	  마지막 주기이므로 30일 기준이다.
    61	- 31일짜리 주기에서 레거시 고객이 업그레이드하면 현행 고객보다 조금 더 낸다. 계약서 문구가 그렇고, 고객도 안다.
    62	  이걸 "공정하게" 실제 일수로 바꾸면 계약 위반이다.
    63	
    64	## 5. 계산 예시
    65	
    66	| 상황 | 계산 | 금액 |
    67	|---|---|---|
    68	| Team(99,000) → Business(290,000), 앵커 10일, 3월 25일 변경 | (290,000 − 99,000) × 16 ÷ 31 | 98,580원 |
    69	| Business → Team, 3월 25일 변경 | 다음 주기(4월 10일)부터 Team 요금. 이번 주기 청구 0 | 0원 |
    70	| L-growth-2019(149,000) → Business(290,000), 앵커 10일, 3월 25일 변경 | (290,000 − 149,000) × 16 ÷ 30 | 75,200원 |
    71	| Starter → Team, 주기 시작일 당일 변경 | 남은 일수 = 주기 일수, 차액 전액 | 70,000원 |
    72	
    73	## 6. 범위 밖
    74	
    75	- 연간 결제는 이 모듈이 다루지 않는다. 연간 고객 37곳은 재무팀이 수동 청구서로 처리한다.
    76	- Enterprise는 계약별 청구서(net-30)이며 플랜 변경 개념이 없다.
    77	
    78	## 7. 해지
    79	
    80	- 해지는 주기 끝에 적용한다. 남은 기간은 그대로 쓸 수 있고 환불은 없다. 다운그레이드와 같은 원칙이다.
    81	- 즉시 해지와 환불은 재무팀만 할 수 있다(법적 분쟁, 이중 가입 등). 시스템에는 즉시 해지 경로가 없다.
    82	- 해지 예약이 있는 계정은 플랜 변경을 받지 않는다. 먼저 해지 예약을 취소해야 한다.
    83	
    84	## 8. 체험 기간과 앵커 결정
    85	
    86	- 체험은 14일이고 카드 등록이 필요 없다. 체험 중에는 청구서가 없다.
    87	- 체험이 끝나는 날에 카드가 등록돼 있으면 그날 유료 전환하고, 그날의 날짜가 `anchorDay`가 된다.
    88	  그날이 29~31일이면 `anchorDay`는 28로 정하고 첫 주기만 하루 이틀 길어진다. 첫 주기의 요금은 한 달치 그대로다.
    89	- 체험 종료일에 카드가 없으면 계정은 읽기 전용이 되고, 나중에 카드를 등록한 날이 앵커가 된다.
    90	
    91	## 9. 구현할 때 자주 틀리는 것
    92	
    93	- 남은 일수에서 변경일 당일을 빼는 실수. 당일은 포함이다. 주기 시작일 당일 변경이면 남은 일수는 주기 전체다.
    94	- 다운그레이드에 "공정하게" 크레딧을 주는 실수. 정책상 금지이고, INC-2022-11의 원인이었다.
    95	- 현행 플랜에 30일 분모를 쓰거나, 레거시 플랜에 실제 일수 분모를 쓰는 실수. 레거시 여부는 **기존** 플랜으로 판단한다.
    96	- 마지막에 한 번만 절사하는 실수. 일할 금액에서 한 번, 쿠폰 뒤에 한 번, 부가세에서 한 번 절사한다.
    97	- 업그레이드 결제 실패 뒤 플랜만 바뀐 채 남는 실수. 결제 실패면 플랜 변경도 되돌린다.
    98	- 다운그레이드 예약이 있는 계정에서 또 다운그레이드를 받으면 예약을 덮어쓴다. 두 개를 쌓지 않는다.
    99	
  3. Readsrc/dates.js
    result / 915 chars
    1	// 날짜는 'YYYY-MM-DD' 문자열로만 다룬다. 시각과 시간대는 이 모듈 밖의 문제다.
    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	
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 0

No changes.

exit 0

run 2baseline 4 turns / 128s / 3 tool calls / 0 files changedvariant 8 turns / 140s / 7 tool calls / 1 files changed
baseline4 turns / 128s / 3 tool calls / 0 files changed
Final response
코드(`src/proration.js`, `src/dates.js`)와 정책 문서(`docs/billing-cycle-and-proration.md`)를 대조한 결과를 정리했습니다. 코드는 전혀 수정하지 않았습니다.

---

# 청구 주기·일할 계산: 코드와 정책 비교

## 1. 코드가 실제로 하는 일

`src/proration.js`는 두 함수만 제공하는 순수 계산 유틸리티다.

- **`cycleBounds(anchorDay, at)`**: `anchorDay`(1~28)를 기준으로 `at`이 속한 주기의 `[start, end)`를 계산한다. `at`의 달의 `anchorDay`일이 `at`보다 미래면 한 달 전으로 당긴다. `end`는 `addMonths(start, 1)`.
- **`prorate(amount, cycle, at)`**: `total = daysBetween(cycle.start, cycle.end)`(실제 주기 일수), `remaining = daysBetween(at, cycle.end)`(변경일 포함 남은 일수)를 구해 `Math.floor(amount * remaining / total)`을 반환. `remaining`이 `[0, total]` 밖이면 `RangeError`.
- `src/dates.js`는 날짜를 오직 `'YYYY-MM-DD'` 문자열로만 다루고, 내부적으로 `Date.UTC`로 파싱한다. 시간대 변환은 "이 모듈 밖의 문제"라고 주석에 명시되어 있다.

즉 코드는 **"두 날짜와 금액이 주어지면 일할 비율을 계산한다"**는 순수 함수 두 개뿐이고, 어떤 상황에서 이 함수를 어떻게 호출할지는 전부 호출부(정책)의 책임이다. 이는 문서 서두 "코드는 계산 도구만 제공하고, 정책이 결정한다"는 설계와 일치한다.

## 2. 정책과 잘 맞는 부분

- `anchorDay` 1~28 제한(§1의 29~31 금지)이 `cycleBounds`에 `RangeError`로 명시적으로 강제되어 있다.
- 일할 계산에서 **변경일 당일을 남은 일수에 포함**하는 규칙(§9 "자주 틀리는 것" 1번, §3.1)이 `remaining = daysBetween(at, cycle.end)` 방식으로 정확히 구현되어 있다. 문서 예시(3/25 변경, 4/10 종료 → 16일)와 일치함을 직접 계산으로 확인했다.
- 절사는 `prorate()` 안에서 `Math.floor` 한 번만 일어난다. §9의 "절사는 일할 계산에서 한 번, 쿠폰 뒤 한 번, 부가세에서 한 번"이라는 규칙 중 **일할 계산분의 몫만** 코드가 담당하고 있고, 그 이상 중복 절사를 하지 않는다.
- `total`이 실제 달력 일수(28~31)를 쓰는 것은 §3.1 "주기 일수는 실제 달력 일수" 조항과 일치한다(단, 이는 현행 플랜 한정 — 3절 참고).

## 3. 코드가 다루지 않거나 정책과 어긋날 여지가 있는 부분

### 3.1 레거시(`L-`) 플랜의 30일 분모/상한 — 가장 중요한 갭

`prorate()`는 `total`과 `remaining`을 **둘 다 같은 `cycle` 객체에서** `daysBetween`으로 계산한다. 그런데 §4 정책은:

> 분모는 항상 30, 분자(남은 일수)는 **실제 남은 일수**(단 30 초과 시 30으로 캡)

를 요구한다. 즉 분모와 분자가 서로 다른 기준(하나는 고정 30, 하나는 실제 주기의 실제 종료일)을 가져야 하는데, `prorate()`의 시그니처는 이걸 표현할 방법이 없다.

호출부가 임시방편으로 `{ start: cycle.start, end: addDays(cycle.start, 30) }` 같은 "가짜 30일 주기"를 만들어 넘긴다고 해도 틀린 값이 나온다. 예를 들어 3/10~4/10(31일 주기)에서 `at = 4/9`(실제 남은 1일)인 경우:
- 정책대로면 남은 일수는 1일(30 이하라 캡 없음) → 분자 1, 분모 30.
- 가짜 30일 주기로 계산하면 `remaining = daysBetween(4/9, 4/9) = 0` → 분자 0.

분모를 고정하려고 종료일을 앞당기면 `remaining`의 기준점(진짜 주기 종료일)까지 같이 틀어져 버리기 때문이다. 즉 **`prorate()` 하나만으로는 §4를 올바르게 구현할 수 없고**, 호출부가 실제 남은 일수를 별도로 계산해 30으로 캡한 뒤 `Math.floor(amount * cappedRemaining / 30)`을 직접 계산하거나, `prorate()`를 우회해야 한다. 문서 §5의 예시(L-growth-2019, 16/30 = 75,200원)는 우연히 `remaining < 30`인 케이스라 어떤 방식으로 계산해도 같은 값이 나오는데, 이 때문에 이 갭이 리뷰 없이 넘어가기 쉽다.

또한 `L-` 접두 여부를 판단하는 로직 자체가 코드 어디에도 없다 — "레거시 여부는 기존 플랜으로 판단"(§9)이라는 판별 로직은 전적으로 호출부 몫이다.

### 3.2 체험 종료일이 29~31일일 때의 "첫 주기 연장" (§8)

정책: 체험 종료일이 29~31일이면 `anchorDay`는 28로 고정하지만, **첫 주기는 실제 전환일부터 시작**해서 하루 이틀 길어진다.

`cycleBounds(anchorDay, at)`는 `anchorDay`와 `at`만으로 항상 "28일~다음 28일"의 정규 주기를 계산한다. 전환일이 30일이었다는 사실 자체가 함수에 입력되지 않으므로, 이 "늘어난 첫 주기"를 표현할 방법이 없다. 이건 가입/전환 처리 로직에서 첫 주기의 `start`/`end`를 별도로 기록해두고, 두 번째 주기부터 `cycleBounds`를 쓰는 식으로 우회해야 한다는 뜻이다. 코드만 보면 이 예외 케이스가 존재한다는 사실 자체를 알 수 없다.

### 3.3 KST 캘린더 날짜 변환은 코드가 보장하지 않음 (§2)

`dates.js`는 문자열을 `Date.UTC`로 그대로 파싱할 뿐, KST 여부를 검증하거나 변환하지 않는다. 주석엔 "시간대는 이 모듈 밖의 문제"라고만 되어 있다. 정책은 "청구 로직에 들어오는 날짜는 이미 KST로 바뀐 문자열이어야 한다"고 요구하지만, 코드 레벨에서 이를 강제하는 검증이 전혀 없다 — 예를 들어 UTC 타임스탬프에서 `.toISOString().slice(0,10)`으로 잘못 만든 날짜를 넘겨도 `proration.js`는 조용히 받아들인다. INC-2023-06과 같은 사고가 재발한다면 이 경계에서일 가능성이 높다. 코드 리뷰나 타입 수준에서 "이 문자열은 KST 변환 완료"라는 걸 보장하는 장치가 없다는 점은 문서화해둘 가치가 있다.

### 3.4 업그레이드/다운그레이드/수평이동 분기, 결제 실패 롤백, 해지·다운그레이드 예약 — 전부 코드 밖

§3, §7의 업그레이드·다운그레이드·해지·예약 관련 규칙(다운그레이드는 청구 0원, 결제 실패 시 롤백, 해지 예약 중 플랜 변경 금지 등)은 `proration.js`에 대응하는 코드가 전혀 없다. `prorate()`는 금액이 양수든 음수든 그냥 계산해버리므로, 다운그레이드 케이스에서 실수로 이 함수를 호출하면(차액이 음수) 정책이 금지하는 "음수 청구서"가 그대로 나올 수 있다 — 코드에는 이를 막는 안전장치(가드)가 없다. 이 부분은 §9 "자주 틀리는 것" 목록에서 재무팀이 이미 우려하고 있는 지점과 정확히 겹친다.

## 4. 요약

| 정책 조항 | 코드 반영 여부 |
|---|---|
| §1 anchorDay 1~28 | O (RangeError로 강제) |
| §2 KST 날짜 문자열 입력 | 입력 형식만 가정, 검증 없음 |
| §3.1 당일 포함 남은 일수, 실제 일수 분모 | O |
| §3.2 다운그레이드 0원/이연 | 코드에 대응 로직 없음 (호출부 책임, 음수 방지 가드 없음) |
| §4 레거시 30일 분모+상한 | **`prorate()` 시그니처로 표현 불가** — 별도 계산 필요 |
| §8 첫 주기 연장 | `cycleBounds`가 표현 불가 — 호출부에서 별도 관리 필요 |
| §9 절사 1회(일할분) | O, 나머지(쿠폰/부가세)는 이 모듈 밖 |

핵심 결론: `proration.js`는 정책과 **모순**되지는 않지만, 정책이 요구하는 조건(레거시 30일 상한, 첫 주기 연장, 다운그레이드 음수 방지)을 표현할 파라미터가 없어서 **호출부가 정확히 구현하지 않으면 조용히 틀린 값을 낼 수 있는 지점**들이 있다. 특히 3.1(레거시 30일 처리)은 우회 구현이 미묘하게 틀리기 쉬운 구조라 리뷰 시 우선적으로 봐야 한다.
Trace / 3 tool calls
  1. Readsrc/proration.js
    result / 1,064 chars
    1	import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'
    2	
    3	// 청구 주기. anchorDay(1~28)가 매달 주기 시작일이다.
    4	// at이 속한 주기의 [start, end)를 돌려준다. end는 다음 주기 시작일이다.
    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	// 주기 안에서 at부터 주기 끝까지 남은 일수 비율로 amount를 나눈다. at 당일을 포함한다.
    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	
  2. Readdocs/billing-cycle-and-proration.md
    result / 4,059 chars
    1	# 청구 주기와 일할 계산 정책
    2	
    3	이 문서는 Workhub 구독 청구의 주기, 플랜 변경 시 금액 처리, 일할 계산(proration) 규칙을 적는다.
    4	코드(`src/proration.js`)는 계산 도구만 제공하고, 어떤 상황에 어떤 계산을 적용하는지는 여기에 있는
    5	정책이 결정한다. 정책의 최종 책임은 재무팀이며, 변경하려면 재무팀 리뷰를 거친다(`glossary-and-incident-log.md`의 "변경 절차").
    6	
    7	## 1. 청구 주기(billing cycle)
    8	
    9	- 계정마다 `anchorDay`(1~28)가 있다. 매달 이 날짜에 새 주기가 시작하고, 주기 시작일에 한 달치 요금을 선불로 청구한다.
    10	- 주기는 `[시작일, 다음 시작일)`이다. 3월 10일 앵커면 3월 10일부터 4월 9일까지가 한 주기다.
    11	- `anchorDay`는 첫 유료 전환일의 날짜에서 정해진다. 체험(trial) 기간 14일이 끝나는 날이 유료 전환일이다.
    12	- 29, 30, 31일은 앵커로 쓰지 않는다. 2023년 8월에 이 날짜를 앵커로 가진 계정 412개를 전부 28일로 옮겼다.
    13	  2월에 주기 길이가 계정마다 달라지면서 CS 문의가 몰렸기 때문이다. 옮긴 계정 목록은 재무팀이 보관한다.
    14	- 청구 배치는 주기 시작일 KST 09:00에 돈다. 이 시각 이전의 플랜 변경은 그날 시작하는 주기에 반영된다.
    15	
    16	## 2. 날짜의 기준: 한국 표준시(KST) 달력 날짜
    17	
    18	- 청구와 관련된 모든 날짜는 **KST 기준 달력 날짜**다. 서버와 DB는 UTC로 돌지만, 청구 로직에 들어오는 날짜는
    19	  이미 KST로 바뀐 `YYYY-MM-DD` 문자열이어야 한다.
    20	- 이유: 세금계산서의 "작성일자"가 청구 주기 시작일과 같아야 하고, 세금계산서는 한국 시간 기준으로 발행된다.
    21	  UTC 날짜를 쓰면 KST 00:00~09:00 사이의 청구가 전날로 잡힌다. 2023년 6월에 실제로 그렇게 됐다(INC-2023-06).
    22	- 그래서 `Date` 객체의 시각이나 사용자의 브라우저 시간대를 청구 로직에서 쓰지 않는다. 해외 고객도 KST 기준이다.
    23	
    24	## 3. 플랜 변경 시 금액 처리
    25	
    26	플랜 변경은 세 가지로 나눈다. 기준은 **월 요금의 크기**다. 좌석 수나 기능은 보지 않는다.
    27	
    28	| 변경 종류 | 적용 시점 | 청구 |
    29	|---|---|---|
    30	| 업그레이드 (월 요금이 오름) | 즉시 | 차액을 남은 일수만큼 일할 계산해서 즉시 청구 |
    31	| 다운그레이드 (월 요금이 내림) | 다음 주기 시작일 | 이번 주기에는 청구도 환불도 없음. 다음 주기부터 새 요금 |
    32	| 수평 이동 (월 요금이 같음) | 즉시 | 0원 |
    33	
    34	### 3.1 업그레이드
    35	
    36	- 변경일 당일을 남은 일수에 포함한다. 3월 25일 변경, 4월 10일 주기 끝이면 남은 일수는 16일이다.
    37	- 청구 금액 = (새 플랜 월 요금 − 기존 플랜 월 요금) × 남은 일수 ÷ 주기 일수. 원 미만은 버린다.
    38	- 주기 일수는 실제 달력 일수(28~31)다. 단, 레거시 플랜은 4절을 따른다.
    39	- 업그레이드 청구서는 즉시 발행하고 즉시 결제한다. 결제가 실패하면 플랜 변경도 되돌린다.
    40	
    41	### 3.2 다운그레이드: 이번 주기에는 아무 금액도 움직이지 않는다
    42	
    43	- 다운그레이드는 **다음 주기 시작일부터** 적용한다. 변경 요청은 "예약"으로만 저장한다.
    44	- 이번 주기의 남은 기간에 대해 차액을 환불하거나 크레딧으로 돌려주지 않는다. 시스템이 음수 금액을 만들면 안 된다.
    45	- 근거: 2022년 11월까지는 다운그레이드도 즉시 일할 계산해서 차액을 음수 청구서로 만들었다. 그 결과 월 마감에서
    46	  음수 청구서마다 수정세금계산서를 발행해야 했고, 재무팀이 11월 마감을 8일 늦췄다(INC-2022-11).
    47	  2022년 12월 정책 회의에서 "다운그레이드는 이연, 환불 없음"으로 확정했고 약관 7조 3항에 반영했다.
    48	- CS가 예외를 요청하면 재무팀이 수동 크레딧으로 처리한다. 시스템은 예외 경로를 두지 않는다.
    49	- 다운그레이드 예약이 있는 상태에서 다시 업그레이드하면 예약은 취소된다.
    50	
    51	### 3.3 수평 이동
    52	
    53	- 같은 월 요금의 플랜으로 옮기는 경우다(현재 카탈로그에는 없지만 과거 지역별 플랜에 있었다). 즉시 적용, 청구 0원.
    54	
    55	## 4. 레거시 플랜(`L-` 접두)의 일할 계산
    56	
    57	- 2019년 계약서 4조: "일할 계산 시 1개월은 30일로 본다." 그래서 레거시 플랜이 관련된 일할 계산은
    58	  주기의 실제 일수와 무관하게 **분모를 30**으로 쓴다. 분자(남은 일수)는 실제 남은 일수를 쓰되 30을 넘으면 30으로 본다.
    59	- 이 규칙은 기존 플랜이 레거시인 경우에 적용한다. 레거시에서 현행 플랜으로 업그레이드할 때도 기존 계약이 살아 있는
    60	  마지막 주기이므로 30일 기준이다.
    61	- 31일짜리 주기에서 레거시 고객이 업그레이드하면 현행 고객보다 조금 더 낸다. 계약서 문구가 그렇고, 고객도 안다.
    62	  이걸 "공정하게" 실제 일수로 바꾸면 계약 위반이다.
    63	
    64	## 5. 계산 예시
    65	
    66	| 상황 | 계산 | 금액 |
    67	|---|---|---|
    68	| Team(99,000) → Business(290,000), 앵커 10일, 3월 25일 변경 | (290,000 − 99,000) × 16 ÷ 31 | 98,580원 |
    69	| Business → Team, 3월 25일 변경 | 다음 주기(4월 10일)부터 Team 요금. 이번 주기 청구 0 | 0원 |
    70	| L-growth-2019(149,000) → Business(290,000), 앵커 10일, 3월 25일 변경 | (290,000 − 149,000) × 16 ÷ 30 | 75,200원 |
    71	| Starter → Team, 주기 시작일 당일 변경 | 남은 일수 = 주기 일수, 차액 전액 | 70,000원 |
    72	
    73	## 6. 범위 밖
    74	
    75	- 연간 결제는 이 모듈이 다루지 않는다. 연간 고객 37곳은 재무팀이 수동 청구서로 처리한다.
    76	- Enterprise는 계약별 청구서(net-30)이며 플랜 변경 개념이 없다.
    77	
    78	## 7. 해지
    79	
    80	- 해지는 주기 끝에 적용한다. 남은 기간은 그대로 쓸 수 있고 환불은 없다. 다운그레이드와 같은 원칙이다.
    81	- 즉시 해지와 환불은 재무팀만 할 수 있다(법적 분쟁, 이중 가입 등). 시스템에는 즉시 해지 경로가 없다.
    82	- 해지 예약이 있는 계정은 플랜 변경을 받지 않는다. 먼저 해지 예약을 취소해야 한다.
    83	
    84	## 8. 체험 기간과 앵커 결정
    85	
    86	- 체험은 14일이고 카드 등록이 필요 없다. 체험 중에는 청구서가 없다.
    87	- 체험이 끝나는 날에 카드가 등록돼 있으면 그날 유료 전환하고, 그날의 날짜가 `anchorDay`가 된다.
    88	  그날이 29~31일이면 `anchorDay`는 28로 정하고 첫 주기만 하루 이틀 길어진다. 첫 주기의 요금은 한 달치 그대로다.
    89	- 체험 종료일에 카드가 없으면 계정은 읽기 전용이 되고, 나중에 카드를 등록한 날이 앵커가 된다.
    90	
    91	## 9. 구현할 때 자주 틀리는 것
    92	
    93	- 남은 일수에서 변경일 당일을 빼는 실수. 당일은 포함이다. 주기 시작일 당일 변경이면 남은 일수는 주기 전체다.
    94	- 다운그레이드에 "공정하게" 크레딧을 주는 실수. 정책상 금지이고, INC-2022-11의 원인이었다.
    95	- 현행 플랜에 30일 분모를 쓰거나, 레거시 플랜에 실제 일수 분모를 쓰는 실수. 레거시 여부는 **기존** 플랜으로 판단한다.
    96	- 마지막에 한 번만 절사하는 실수. 일할 금액에서 한 번, 쿠폰 뒤에 한 번, 부가세에서 한 번 절사한다.
    97	- 업그레이드 결제 실패 뒤 플랜만 바뀐 채 남는 실수. 결제 실패면 플랜 변경도 되돌린다.
    98	- 다운그레이드 예약이 있는 계정에서 또 다운그레이드를 받으면 예약을 덮어쓴다. 두 개를 쌓지 않는다.
    99	
  3. Readsrc/dates.js
    result / 915 chars
    1	// 날짜는 'YYYY-MM-DD' 문자열로만 다룬다. 시각과 시간대는 이 모듈 밖의 문제다.
    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	
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 0

No changes.

exit 0

variant8 turns / 140s / 7 tool calls / 1 files changed
Final response
`docs/proration-code-vs-policy.md`에 분석 글을 작성했습니다. 코드는 전혀 건드리지 않았습니다.

핵심 발견은 이렇습니다. `src/proration.js`는 `cycleBounds`(주기 경계 계산)와 `prorate`(금액을 일수 비율로 내림 처리하는 순수 계산)만 제공하는데, 이 둘은 정책 문서의 다음 항목들을 구현하지 않습니다.

- **레거시 플랜 30일 분모 규칙** (정책 4절): `prorate`에는 레거시 여부를 판단할 파라미터 자체가 없고, 분모는 항상 실제 주기 일수(28~31)로 계산됩니다. 30일 캡 로직도 없습니다.
- **업그레이드/다운그레이드/수평 이동 분기** (정책 3절): 함수는 금액 하나를 그대로 일할 계산할 뿐이라, 다운그레이드 시 음수 금액이 들어가도 막지 못합니다.
- **KST 변환**: `dates.js`가 "시간대는 모듈 밖의 문제"라고 명시적으로 위임하고 있어, 정책과 어긋난다기보다는 호출부가 책임을 제대로 지키는지 코드만으로는 검증할 수 없는 상태입니다.
- **체험 종료일 → anchorDay 결정, 해지 처리**도 이 파일에는 없습니다.

반대로 앵커 1~28 검증, `[start, end)` 반열린 구간, 당일 포함 일수 계산, 내림 절사는 정책과 정확히 일치합니다.
Trace / 7 tool calls
  1. Readsrc/proration.js
    result / 1,064 chars
    1	import { addMonths, daysBetween, parseDate, formatDate } from './dates.js'
    2	
    3	// 청구 주기. anchorDay(1~28)가 매달 주기 시작일이다.
    4	// at이 속한 주기의 [start, end)를 돌려준다. end는 다음 주기 시작일이다.
    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	// 주기 안에서 at부터 주기 끝까지 남은 일수 비율로 amount를 나눈다. at 당일을 포함한다.
    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	
  2. Readdocs/billing-cycle-and-proration.md
    result / 4,059 chars
    1	# 청구 주기와 일할 계산 정책
    2	
    3	이 문서는 Workhub 구독 청구의 주기, 플랜 변경 시 금액 처리, 일할 계산(proration) 규칙을 적는다.
    4	코드(`src/proration.js`)는 계산 도구만 제공하고, 어떤 상황에 어떤 계산을 적용하는지는 여기에 있는
    5	정책이 결정한다. 정책의 최종 책임은 재무팀이며, 변경하려면 재무팀 리뷰를 거친다(`glossary-and-incident-log.md`의 "변경 절차").
    6	
    7	## 1. 청구 주기(billing cycle)
    8	
    9	- 계정마다 `anchorDay`(1~28)가 있다. 매달 이 날짜에 새 주기가 시작하고, 주기 시작일에 한 달치 요금을 선불로 청구한다.
    10	- 주기는 `[시작일, 다음 시작일)`이다. 3월 10일 앵커면 3월 10일부터 4월 9일까지가 한 주기다.
    11	- `anchorDay`는 첫 유료 전환일의 날짜에서 정해진다. 체험(trial) 기간 14일이 끝나는 날이 유료 전환일이다.
    12	- 29, 30, 31일은 앵커로 쓰지 않는다. 2023년 8월에 이 날짜를 앵커로 가진 계정 412개를 전부 28일로 옮겼다.
    13	  2월에 주기 길이가 계정마다 달라지면서 CS 문의가 몰렸기 때문이다. 옮긴 계정 목록은 재무팀이 보관한다.
    14	- 청구 배치는 주기 시작일 KST 09:00에 돈다. 이 시각 이전의 플랜 변경은 그날 시작하는 주기에 반영된다.
    15	
    16	## 2. 날짜의 기준: 한국 표준시(KST) 달력 날짜
    17	
    18	- 청구와 관련된 모든 날짜는 **KST 기준 달력 날짜**다. 서버와 DB는 UTC로 돌지만, 청구 로직에 들어오는 날짜는
    19	  이미 KST로 바뀐 `YYYY-MM-DD` 문자열이어야 한다.
    20	- 이유: 세금계산서의 "작성일자"가 청구 주기 시작일과 같아야 하고, 세금계산서는 한국 시간 기준으로 발행된다.
    21	  UTC 날짜를 쓰면 KST 00:00~09:00 사이의 청구가 전날로 잡힌다. 2023년 6월에 실제로 그렇게 됐다(INC-2023-06).
    22	- 그래서 `Date` 객체의 시각이나 사용자의 브라우저 시간대를 청구 로직에서 쓰지 않는다. 해외 고객도 KST 기준이다.
    23	
    24	## 3. 플랜 변경 시 금액 처리
    25	
    26	플랜 변경은 세 가지로 나눈다. 기준은 **월 요금의 크기**다. 좌석 수나 기능은 보지 않는다.
    27	
    28	| 변경 종류 | 적용 시점 | 청구 |
    29	|---|---|---|
    30	| 업그레이드 (월 요금이 오름) | 즉시 | 차액을 남은 일수만큼 일할 계산해서 즉시 청구 |
    31	| 다운그레이드 (월 요금이 내림) | 다음 주기 시작일 | 이번 주기에는 청구도 환불도 없음. 다음 주기부터 새 요금 |
    32	| 수평 이동 (월 요금이 같음) | 즉시 | 0원 |
    33	
    34	### 3.1 업그레이드
    35	
    36	- 변경일 당일을 남은 일수에 포함한다. 3월 25일 변경, 4월 10일 주기 끝이면 남은 일수는 16일이다.
    37	- 청구 금액 = (새 플랜 월 요금 − 기존 플랜 월 요금) × 남은 일수 ÷ 주기 일수. 원 미만은 버린다.
    38	- 주기 일수는 실제 달력 일수(28~31)다. 단, 레거시 플랜은 4절을 따른다.
    39	- 업그레이드 청구서는 즉시 발행하고 즉시 결제한다. 결제가 실패하면 플랜 변경도 되돌린다.
    40	
    41	### 3.2 다운그레이드: 이번 주기에는 아무 금액도 움직이지 않는다
    42	
    43	- 다운그레이드는 **다음 주기 시작일부터** 적용한다. 변경 요청은 "예약"으로만 저장한다.
    44	- 이번 주기의 남은 기간에 대해 차액을 환불하거나 크레딧으로 돌려주지 않는다. 시스템이 음수 금액을 만들면 안 된다.
    45	- 근거: 2022년 11월까지는 다운그레이드도 즉시 일할 계산해서 차액을 음수 청구서로 만들었다. 그 결과 월 마감에서
    46	  음수 청구서마다 수정세금계산서를 발행해야 했고, 재무팀이 11월 마감을 8일 늦췄다(INC-2022-11).
    47	  2022년 12월 정책 회의에서 "다운그레이드는 이연, 환불 없음"으로 확정했고 약관 7조 3항에 반영했다.
    48	- CS가 예외를 요청하면 재무팀이 수동 크레딧으로 처리한다. 시스템은 예외 경로를 두지 않는다.
    49	- 다운그레이드 예약이 있는 상태에서 다시 업그레이드하면 예약은 취소된다.
    50	
    51	### 3.3 수평 이동
    52	
    53	- 같은 월 요금의 플랜으로 옮기는 경우다(현재 카탈로그에는 없지만 과거 지역별 플랜에 있었다). 즉시 적용, 청구 0원.
    54	
    55	## 4. 레거시 플랜(`L-` 접두)의 일할 계산
    56	
    57	- 2019년 계약서 4조: "일할 계산 시 1개월은 30일로 본다." 그래서 레거시 플랜이 관련된 일할 계산은
    58	  주기의 실제 일수와 무관하게 **분모를 30**으로 쓴다. 분자(남은 일수)는 실제 남은 일수를 쓰되 30을 넘으면 30으로 본다.
    59	- 이 규칙은 기존 플랜이 레거시인 경우에 적용한다. 레거시에서 현행 플랜으로 업그레이드할 때도 기존 계약이 살아 있는
    60	  마지막 주기이므로 30일 기준이다.
    61	- 31일짜리 주기에서 레거시 고객이 업그레이드하면 현행 고객보다 조금 더 낸다. 계약서 문구가 그렇고, 고객도 안다.
    62	  이걸 "공정하게" 실제 일수로 바꾸면 계약 위반이다.
    63	
    64	## 5. 계산 예시
    65	
    66	| 상황 | 계산 | 금액 |
    67	|---|---|---|
    68	| Team(99,000) → Business(290,000), 앵커 10일, 3월 25일 변경 | (290,000 − 99,000) × 16 ÷ 31 | 98,580원 |
    69	| Business → Team, 3월 25일 변경 | 다음 주기(4월 10일)부터 Team 요금. 이번 주기 청구 0 | 0원 |
    70	| L-growth-2019(149,000) → Business(290,000), 앵커 10일, 3월 25일 변경 | (290,000 − 149,000) × 16 ÷ 30 | 75,200원 |
    71	| Starter → Team, 주기 시작일 당일 변경 | 남은 일수 = 주기 일수, 차액 전액 | 70,000원 |
    72	
    73	## 6. 범위 밖
    74	
    75	- 연간 결제는 이 모듈이 다루지 않는다. 연간 고객 37곳은 재무팀이 수동 청구서로 처리한다.
    76	- Enterprise는 계약별 청구서(net-30)이며 플랜 변경 개념이 없다.
    77	
    78	## 7. 해지
    79	
    80	- 해지는 주기 끝에 적용한다. 남은 기간은 그대로 쓸 수 있고 환불은 없다. 다운그레이드와 같은 원칙이다.
    81	- 즉시 해지와 환불은 재무팀만 할 수 있다(법적 분쟁, 이중 가입 등). 시스템에는 즉시 해지 경로가 없다.
    82	- 해지 예약이 있는 계정은 플랜 변경을 받지 않는다. 먼저 해지 예약을 취소해야 한다.
    83	
    84	## 8. 체험 기간과 앵커 결정
    85	
    86	- 체험은 14일이고 카드 등록이 필요 없다. 체험 중에는 청구서가 없다.
    87	- 체험이 끝나는 날에 카드가 등록돼 있으면 그날 유료 전환하고, 그날의 날짜가 `anchorDay`가 된다.
    88	  그날이 29~31일이면 `anchorDay`는 28로 정하고 첫 주기만 하루 이틀 길어진다. 첫 주기의 요금은 한 달치 그대로다.
    89	- 체험 종료일에 카드가 없으면 계정은 읽기 전용이 되고, 나중에 카드를 등록한 날이 앵커가 된다.
    90	
    91	## 9. 구현할 때 자주 틀리는 것
    92	
    93	- 남은 일수에서 변경일 당일을 빼는 실수. 당일은 포함이다. 주기 시작일 당일 변경이면 남은 일수는 주기 전체다.
    94	- 다운그레이드에 "공정하게" 크레딧을 주는 실수. 정책상 금지이고, INC-2022-11의 원인이었다.
    95	- 현행 플랜에 30일 분모를 쓰거나, 레거시 플랜에 실제 일수 분모를 쓰는 실수. 레거시 여부는 **기존** 플랜으로 판단한다.
    96	- 마지막에 한 번만 절사하는 실수. 일할 금액에서 한 번, 쿠폰 뒤에 한 번, 부가세에서 한 번 절사한다.
    97	- 업그레이드 결제 실패 뒤 플랜만 바뀐 채 남는 실수. 결제 실패면 플랜 변경도 되돌린다.
    98	- 다운그레이드 예약이 있는 계정에서 또 다운그레이드를 받으면 예약을 덮어쓴다. 두 개를 쌓지 않는다.
    99	
  3. Bashfind . -name "dates.js" -not -path "*/node_modules/*"; echo "---"; find . -iname "*proration*" -o -iname "*plan-change*" -o -iname "*legacy*" | grep -v node_modules
    result / 137 chars
    ./src/dates.js
    ---
    ./test/proration.test.js
    ./docs/billing-cycle-and-proration.md
    ./docs/plans-and-legacy-contracts.md
    ./src/proration.js
  4. Readsrc/dates.js
    result / 915 chars
    1	// 날짜는 'YYYY-MM-DD' 문자열로만 다룬다. 시각과 시간대는 이 모듈 밖의 문제다.
    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/plans-and-legacy-contracts.md
    result / 4,105 chars
    1	# 플랜 연혁과 레거시 계약
    2	
    3	`src/plans.js`의 `PLANS`에는 현행 플랜 3개와 레거시 플랜 2개가 섞여 있다. 왜 레거시가 남아 있고,
    4	레거시 고객에게 무엇을 약속했는지는 코드에 없다. 이 문서가 그 약속을 적는다.
    5	
    6	## 1. 연혁
    7	
    8	| 시기 | 변화 |
    9	|---|---|
    10	| 2019.03 | 출시. Startup(49,000원, 10석), Growth(149,000원, 30석). 초기 고객과는 서면 계약을 맺었다 |
    11	| 2021.06 | 플랜 개편. Starter/Team/Business로 교체. 2019 플랜은 신규 판매 중단, ID 앞에 `L-`를 붙여 구분 |
    12	| 2023.08 | 앵커 날짜 29~31일 계정을 28일로 이전 |
    13	| 2024.02 | Business 가격을 250,000원에서 290,000원으로 인상. 기존 Business 고객은 6개월 유예 후 적용 |
    14	| 2025.01 | 레거시 플랜 레코드를 정리하려다 청구 실패 발생(INC-2025-01). 이후 "레거시 레코드 삭제 금지" |
    15	
    16	## 2. 레거시 플랜 고객과의 계약 조건
    17	
    18	2019년 서면 계약을 맺은 고객은 2026년 3월 기준 61곳이 남아 있다. 계약서의 핵심 조항은 다음과 같다.
    19	
    20	- **가격 고정(4조 1항)**: 계약 기간 동안 월 요금을 올리지 않는다. 계약은 매년 자동 갱신되며 갱신 시에도 가격은 유지된다.
    21	  양쪽 중 한쪽이 90일 전 서면 통지로 해지할 수 있다.
    22	- **30일 기준 일할 계산(4조 3항)**: 일할 계산이 필요한 모든 경우에 1개월을 30일로 본다.
    23	  자세한 적용은 `billing-cycle-and-proration.md` 4절.
    24	- **좌석 정의(5조)**: 레거시 계약의 좌석은 "초대된 사용자 수"다. 현행 플랜의 좌석은 "지난 30일 안에 로그인한 활성 사용자 수"다.
    25	  같은 워크스페이스라도 플랜에 따라 좌석 수가 다르게 집계된다. 좌석 집계 로직은 별도 서비스(`seat-counter`)에 있다.
    26	- **기능 동결 없음**: 레거시 고객도 신규 기능을 쓴다. 기능으로 차별하지 않는다.
    27	
    28	## 3. 레거시 플랜을 다루는 규칙
    29	
    30	- 레거시 플랜은 **신규 가입이 불가능**하다. 가격 페이지와 가입 화면에서는 숨긴다. 그러나 `PLANS`에서는 지우지 않는다.
    31	  2025년 1월에 "안 쓰는 플랜 정리"로 레코드를 지웠다가 61개 계정의 청구 배치가 `unknown plan`으로 실패했다.
    32	- **레거시 → 현행 업그레이드**: 가능하다. 다만 업그레이드하는 순간 가격 고정 조항이 소멸한다(계약서 4조 4항).
    33	  CS가 고객에게 이 사실을 사전 고지해야 하며, 고지 기록을 CRM에 남긴 뒤에만 변경을 진행한다.
    34	  시스템은 이 고지를 강제하지 않는다. 고지 여부는 CS 프로세스에 맡긴다.
    35	- **현행 → 레거시 다운그레이드**: 불가능하다. 레거시 플랜은 새로 배정할 수 없다. 시스템은 이 요청을 거부해야 한다.
    36	- **레거시 → 레거시** (Startup → Growth): 가능하다. 둘 다 레거시이므로 가격 고정은 유지된다.
    37	- 레거시 플랜 ID(`L-startup-2019`, `L-growth-2019`)는 CRM과 회계 시스템의 매핑 키다. 바꾸면 안 된다.
    38	
    39	## 4. 플랜 ID와 이름
    40	
    41	- 플랜 ID는 외부 시스템(CRM, 회계, 데이터 웨어하우스)에서 매핑 키로 쓴다. 한 번 정해진 ID는 바꾸지 않는다.
    42	  이름(`name`)은 마케팅이 바꿀 수 있다.
    43	- `L-` 접두는 "2021년 개편 이전의 서면 계약 플랜"이라는 뜻이다. `legacy: true` 플래그와 항상 같이 간다.
    44	  새 레거시 플랜이 생기는 일은 없다.
    45	
    46	## 5. 가격 표기
    47	
    48	- `monthlyPrice`는 **부가세 별도** 금액이다. 청구서에는 공급가액으로 들어가고 부가세는 따로 계산한다.
    49	- 마케팅 페이지는 소비자에게 보이는 곳이라 부가세 포함 가격을 표시한다(29,000원 → "월 31,900원(VAT 포함)").
    50	  B2B 견적서와 청구서는 부가세 별도로 표시한다.
    51	- 2024년 2월 가격 인상 때 유예 대상 Business 고객(당시 213곳)은 6개월 동안 250,000원으로 청구했다.
    52	  이 유예는 `PLANS`가 아니라 계정별 `priceOverride`로 처리했고, 2024년 8월에 모두 만료됐다.
    53	
    54	## 6. 좌석 초과
    55	
    56	- 현행 플랜에서 활성 사용자가 `seatLimit`을 넘으면 자동으로 상위 플랜으로 옮기지 않는다.
    57	  워크스페이스 관리자에게 알림을 보내고, 초과 상태가 2주기 이어지면 CS가 연락한다.
    58	- 레거시 플랜은 초대 사용자 기준이라 초과가 거의 없다. 초과하면 CS가 개별 협의한다.
    59	
    60	## 7. Enterprise
    61	
    62	- Enterprise는 `PLANS`에 없다. 계약별 견적, 청구서 발행(net-30), 계좌이체 결제이며 카드 결제와 자동 청구 배치를 타지 않는다.
    63	- 이 모듈에서 Enterprise 계정을 만나면 처리 대상이 아니다. Enterprise 계정은 `planId`가 `null`이다.
    64	
    65	## 8. 계정 데이터에서 플랜과 관련된 필드
    66	
    67	| 필드 | 뜻 | 비고 |
    68	|---|---|---|
    69	| `planId` | 현재 적용 중인 플랜 | Enterprise는 `null` |
    70	| `pendingPlanId` | 다음 주기부터 적용될 플랜(다운그레이드 예약) | 없으면 `null` |
    71	| `anchorDay` | 청구 주기 시작일 | 1~28 |
    72	| `priceOverride` | 계정별 요금 예외(원) | 2024년 가격 인상 유예에 썼고, 지금은 전부 `null` |
    73	| `contractRef` | 서면 계약 문서 번호 | 레거시 고객만 값이 있다 |
    74	| `credits` | 잔여 크레딧(원) | `coupons-and-credits.md` |
    75	
    76	- `pendingPlanId`는 주기 시작 배치가 `planId`로 옮기고 비운다. 배치가 실패하면 다음 날 다시 시도한다.
    77	- `contractRef`가 있는 계정이 곧 레거시 계약 고객이다. `planId`의 `L-` 접두와 항상 일치해야 한다. 어긋나면 데이터 오류다.
    78	
    79	## 9. 레거시 고객 응대 원칙
    80	
    81	- 레거시 고객 61곳은 초기 고객이라 매출 비중은 4%지만 레퍼런스 가치가 크다. 계약 조건을 시스템 편의로 바꾸지 않는다.
    82	- 레거시 고객이 "새 플랜이 더 싸다"고 문의하면 CS가 현행 플랜과 비교표를 보내고, 옮기면 가격 고정이 사라진다는 점을 고지한다.
    83	  이 고지는 CRM에 "가격 고정 소멸 고지" 태그로 남긴다. 태그 없이 업그레이드한 사례가 발견되면 CS 리드가 확인한다.
    84	- 레거시 고객에게 보내는 청구서에는 계약 번호(`contractRef`)를 품목 설명에 넣는다. 고객사 회계팀이 계약과 대조하기 때문이다.
    85	
    86	## 10. 자주 받는 질문
    87	
    88	- **레거시 플랜을 `PLANS`에서 지워도 되나?** 안 된다. INC-2025-01. 숨기는 것은 UI가 한다.
    89	- **새 고객을 레거시 플랜에 넣어달라는 영업 요청**: 거절한다. 레거시는 2019년 서면 계약 고객만 대상이다. 특별 가격이 필요하면
    90	  세일즈 쿠폰이나 Enterprise 계약으로 한다.
    91	- **레거시 가격을 올릴 수 있나?** 계약 해지(90일 통지) 후 재계약해야 한다. 지금까지 한 적 없다.
    92	- **`legacy` 플래그와 `L-` 접두 중 무엇을 봐야 하나?** 같은 뜻이다. 코드에서는 `legacy` 플래그를 보고, 사람이 읽는 곳에서는 접두로 알아본다.
    93	- **Business 유예 가격이 다시 필요하면?** `priceOverride`를 쓴다. `PLANS`의 가격을 계정별로 바꾸지 않는다.
    94	- **플랜 이름을 바꾸면 청구서의 품목 이름도 바뀌나?** 발행 시점의 이름을 청구서에 저장하므로 과거 청구서는 안 바뀐다.
    95	
  6. 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	
  7. Writedocs/proration-code-vs-policy.md
    result / 214 chars
    File created successfully at: _jwd/run-c58dfa52/docs/proration-code-vs-policy.md (file state is current in your context — no need to Read it back)
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 1 / +135 -0
docs/proration-code-vs-policy.md new+135 -0
new file mode 100644index 0000000..c8a0746--- /dev/null+++ b/docs/proration-code-vs-policy.md@@ -0,0 +1,135 @@+# `src/proration.js` 구현과 정책 문서 비교++작성일: 2026-08-25+대상 코드: `src/proration.js`, `src/dates.js`+비교 대상 정책: `billing-cycle-and-proration.md`, `plans-and-legacy-contracts.md`++이 글은 청구 주기·일할 계산 정책이 코드에서 실제로 어떻게 구현돼 있는지, 그리고 정책 문서와 코드+사이에 어떤 차이가 있는지를 정리한다. 코드는 수정하지 않았고, 현재 상태를 있는 그대로 분석한 결과다.++## 1. 코드가 실제로 하는 일++`src/proration.js`는 함수 두 개만 내보낸다.++### `cycleBounds(anchorDay, at)`++- `anchorDay`가 정수이면서 1~28 범위인지 검증한다. 벗어나면 `RangeError`를 던진다.+- `at`이 속한 주기의 시작일을 구한다. `at`이 있는 달의 `anchorDay`가 `at`보다 미래면 전달로 넘어간다.+- 종료일은 `addMonths(시작일, 1)`이다. 즉 `[시작일, 종료일)` 반열린 구간을 돌려준다.++### `prorate(amount, cycle, at)`++- `total`은 `cycle.start`부터 `cycle.end`까지의 실제 일수(`daysBetween`)다.+- `remaining`은 `at`부터 `cycle.end`까지의 일수다. `daysBetween`이 종료일을 배제하는 방식으로 동작하기+ 때문에, 결과적으로 `at` 당일이 남은 일수에 포함된다.+- `remaining`이 0보다 작거나 `total`보다 크면 `RangeError`를 던진다.+- `Math.floor((amount * remaining) / total)`을 반환한다.++즉 이 함수는 "금액 하나를 주기 안에서 남은 일수 비율로 나누고 내림 처리하는" 범용 계산기다.+업그레이드인지 다운그레이드인지, 레거시 플랜인지 아닌지는 함수 시그니처 어디에도 없다.++### `src/dates.js`의 전제++파일 첫 줄 주석이 명확하다: "날짜는 'YYYY-MM-DD' 문자열로만 다룬다. 시각과 시간대는 이 모듈 밖의 문제다."+`parseDate`는 문자열을 `Date.UTC`로 그대로 해석할 뿐, 문자열이 어느 시간대의 달력 날짜인지는 신경 쓰지+않는다.++## 2. 정책과 일치하는 부분++- **앵커 29~31일 금지** (정책 1절): `cycleBounds`의 `anchorDay` 검증(1~28)이 정확히 이 규칙을 강제한다.+- **주기는 `[시작일, 다음 시작일)`** (정책 1절): `cycleBounds`의 반환값과 정확히 일치한다.+- **변경일 당일을 남은 일수에 포함** (정책 3.1, 9절): `daysBetween(at, cycle.end)`의 배제 방식 덕분에+ 당일이 자동으로 포함된다. 정책 문서의 예시(3월 25일 변경, 4월 10일 종료 → 16일)와 코드 결과가 일치한다.+- **일할 금액을 내림 처리** (정책 9절 "마지막에 한 번만 절사하는 실수" 항목의 첫 번째 절사): `Math.floor`가+ 이를 담당한다.++## 3. 정책에는 있지만 코드에는 없는 부분++`proration.js`는 계산 도구 하나만 제공할 뿐, 정책 문서(`billing-cycle-and-proration.md` 서두)가+스스로 밝히는 대로 "어떤 상황에 어떤 계산을 적용할지"는 이 파일 밖에서 결정돼야 한다. 다만 그 경계선이+정책이 요구하는 것보다 훨씬 넓다는 점을 짚어야 한다.++### 3.1 레거시 플랜의 30일 분모 규칙이 통째로 빠져 있다++정책 4절: 레거시 플랜(`L-` 접두)이 관련된 일할 계산은 분모를 무조건 30으로 쓰고, 분자(남은 일수)는+실제 값이되 30을 넘으면 30으로 캡을 씌운다.++`prorate`에는 이를 판단할 파라미터 자체가 없다. `total`은 항상 `cycle.start`~`cycle.end`의 실제 일수+(28~31)로 계산된다. 정책 5절 예시(`L-growth-2019`, 앵커 10일, 3월 25일 변경 → `× 16 ÷ 30`)를 이 함수로+그대로 재현하려면, 호출부가 다음을 전부 직접 처리해야 한다.++- 분모를 30으로 강제하기 위해 `cycle`을 실제 청구 주기와 다른 값(예: `end`를 `start + 30일`)으로 조작해서+ 넘기거나, 별도 계산 경로를 따로 만들어야 한다.+- `cycle`을 조작하는 방식을 택하면 `remaining = daysBetween(at, cycle.end)`도 그 조작된 `end` 기준으로+ 달라지므로, "31일 주기에서 변경했는데 분자는 실제 남은 일수, 분모만 30"이라는 정책의 정확한 조합을+ 이 함수 하나로는 만들어낼 수 없다. 즉 `cycle`을 속이면 `remaining`도 같이 틀어진다.+- "분자가 30을 넘으면 30으로 캡"하는 로직은 `prorate` 안 어디에도 없다. 호출부가 `at`을 넘기기 전에+ 직접 캡을 씌워야 한다.++정리하면, 레거시 플랜 계산은 이 함수의 옵션이 아니라 호출부가 통째로 새로 구현해야 하는 별도 경로다.+`proration.js`만 읽어서는 레거시 규칙이 존재한다는 사실조차 알 수 없다.++### 3.2 업그레이드/다운그레이드/수평 이동 분기가 없다++정책 3절: 월 요금 증감에 따라 즉시 청구(업그레이드), 다음 주기 예약·환불 없음(다운그레이드), 0원(수평+이동)으로 완전히 다른 흐름을 탄다.++`prorate`는 `amount`(아마도 신규가-기존가 차액으로 추정) 하나를 받아 그대로 일할 계산할 뿐이다. 다운그레이드+상황에서 이 함수에 음수 `amount`를 실수로 넣으면, 함수는 이를 막지 않고 음수 결과를 그대로 돌려준다.+`RangeError`는 `remaining`/`total` 범위만 검사하지 금액의 부호는 보지 않는다. "시스템이 음수 금액을+만들면 안 된다"(정책 3.2)는 제약은 코드가 아니라 호출부의 규율에 전적으로 의존한다.++다운그레이드 예약(`pendingPlanId`) 저장, 예약 취소·덮어쓰기, 결제 실패 시 플랜 롤백(정책 3.1) 같은 상태+전이도 이 파일에는 없다.++### 3.3 KST 기준 날짜 변환은 명시적으로 모듈 밖으로 위임돼 있다++정책 2절: 청구 로직에 들어오는 모든 날짜는 이미 KST 달력 날짜로 변환된 `YYYY-MM-DD` 문자열이어야 한다.++`dates.js` 주석이 이 책임을 정확히 인정하고 모듈 밖으로 넘긴다("시각과 시간대는 이 모듈 밖의 문제다").+`parseDate`는 입력 문자열이 어느 시간대 기준인지 검증하지 않고 `Date.UTC`로 그대로 해석한다. 이는 정책을+어긴 것은 아니지만(정책도 "변환은 이 모듈에 들어오기 전에 끝나 있어야 한다"고 규정하므로), 코드만 봐서는+호출부가 실제로 KST 변환을 마치고 넘기는지 검증할 방법이 없다. INC-2023-06(UTC 날짜를 그대로 써서 발생한+사고)이 재발하지 않는지는 전적으로 호출부 구현에 달려 있다.++### 3.4 체험 종료일 → `anchorDay` 결정 로직이 없다++정책 8절: 체험 종료일에 유료 전환하면 그날 날짜가 `anchorDay`가 되고, 그날이 29~31일이면 `anchorDay`는+28로 정한다.++`cycleBounds`는 `anchorDay`를 이미 정해진 값으로만 받아 범위만 검증한다. "전환일 날짜 → anchorDay,+29~31일이면 28로 클램프"하는 결정 로직 자체는 이 파일에 없다. 참고로 `dates.js`의 `addMonths`에는 말일+클램핑 로직(예: 1/31 + 1개월 → 2/28)이 있지만, `anchorDay`가 항상 1~28로 제한되는 한 이 클램핑은+`cycleBounds` 호출 경로에서는 사실상 발동하지 않는다.++### 3.5 해지 처리가 없다++정책 7절: 해지는 주기 끝에 적용하고 환불 없음(다운그레이드와 동일 원칙). `proration.js`에는 해지 관련+로직이 없다.++## 4. 코드에는 있지만 정책 문서에 명시되지 않은 부분++- `prorate`의 `remaining < 0 || remaining > total` 범위 검사는 정책 문서 어디에도 명시된 요구사항이+ 아니다. 잘못된 `cycle`/`at` 조합이 조용히 이상한 금액을 만드는 것을 막는 방어 코드로 보인다.+- `cycleBounds`가 `anchorDay`의 정수·범위를 검증하며 던지는 `RangeError`도 마찬가지로, 정책이 "29~31일은+ 쓰지 않는다"고 서술한 것을 코드가 예외로 강제하는 형태다. 정책 자체가 요구하는 동작과 결과는 같지만,+ "예외를 던진다"는 구현 방식은 정책 문서에 없는 코드의 선택이다.++## 5. 요약++`src/proration.js`는 정책 문서 전체가 아니라 "주기 경계 계산"과 "금액 하나를 일수 비율로 나누고 내림+처리하는 계산" 두 가지만 구현한다. 정책 문서가 규정하는 항목 중 다음은 이 파일 밖(호출부 또는 아직+찾지 못한 다른 모듈)에서 구현돼야 하며, 현재 저장소 안에서는 해당 구현을 확인하지 못했다.++| 정책 항목 | 위치 | 코드 반영 여부 |+|---|---|---|+| 레거시 플랜 30일 분모 + 30일 캡 | 4절 | 없음 |+| 업그레이드/다운그레이드/수평 이동 분기, 음수 금액 방지 | 3절 | 없음 |+| 다운그레이드 예약·취소, 결제 실패 롤백 | 3.1, 3.2절 | 없음 |+| KST 변환 | 2절 | 모듈 밖으로 명시적 위임(검증 불가) |+| 체험 종료일 → anchorDay 결정, 29~31일 캡 | 8절 | 없음 |+| 해지 처리 | 7절 | 없음 |++이 함수를 레거시 플랜이나 다운그레이드 케이스에 그대로 가져다 쓰면 정책과 다른 금액이 나올 수 있다.+`prorate`를 호출하는 코드를 리뷰할 때는 레거시 여부 판단과 30일 캡, 플랜 변경 종류 분기가 호출부에+제대로 들어 있는지를 반드시 함께 확인해야 한다.

exit 0

Judgment

Which condition showed the expected effect more clearly?

TC-03 Code onlycontrol (no effect expected)

Prompt
src/plans.js에 플랜을 월 요금 오름차순으로 돌려주는 listPlansByPrice() 함수를 추가해줘. 설명은 하지 말고 코드만 고쳐.
Expected effect (written before the run)

No effect. Same kind of diff on both sides and a one-line answer. A long Korean explanation in the variant would be over-application.

run 1baseline 3 turns / 10s / 2 tool calls / 1 files changedvariant 3 turns / 10s / 2 tool calls / 1 files changed
baseline3 turns / 10s / 2 tool calls / 1 files changed
Final response
완료했습니다.
Trace / 2 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. Editsrc/plans.js
    result / 204 chars
    The file _jwd/run-a45e3681/src/plans.js has been updated successfully. (file state is current in your context — no need to Read it back)
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 1 / +4 -0
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)+}

exit 0

variant3 turns / 10s / 2 tool calls / 1 files changed
Final response
`src/plans.js`에 `listPlansByPrice()` 함수를 추가했습니다.
Trace / 2 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. Editsrc/plans.js
    result / 204 chars
    The file _jwd/run-bcc92fb8/src/plans.js has been updated successfully. (file state is current in your context — no need to Read it back)
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 1 / +4 -0
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)+}

exit 0

run 2baseline 3 turns / 9s / 2 tool calls / 1 files changedvariant 3 turns / 11s / 2 tool calls / 1 files changed
baseline3 turns / 9s / 2 tool calls / 1 files changed
Final response
완료했습니다.
Trace / 2 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. Editsrc/plans.js
    result / 204 chars
    The file _jwd/run-a8623e81/src/plans.js has been updated successfully. (file state is current in your context — no need to Read it back)
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 1 / +4 -0
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)+}

exit 0

variant3 turns / 11s / 2 tool calls / 1 files changed
Final response
`listPlansByPrice()` 함수를 `src/plans.js`에 추가했습니다.
Trace / 2 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. Editsrc/plans.js
    result / 204 chars
    The file _jwd/run-f25c4df7/src/plans.js has been updated successfully. (file state is current in your context — no need to Read it back)
Instruction files loaded: 1
  • CLAUDE.mdProject / session_start
Changed files: 1 / +4 -0
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)+}

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 / +49 -0
.claude/output-styles/fluent-korean.md new+48 -0
new file mode 100644index 0000000..bce347c--- /dev/null+++ b/.claude/output-styles/fluent-korean.md@@ -0,0 +1,48 @@+---+name: fluent-korean+description: 의미가 명확한 한국어 문장을 출력하게 하는 지침입니다. Claude Code의 코딩 지침을 유지합니다.+keep-coding-instructions: true+---++당신은 한국어를 활용해야 하는 상황에 있다면 본 문서에 제시된 지침들을 준수해야 합니다. 그럼으로써 의사소통의 효율성을 높일 수 있습니다. 이 지침들은, 의미가 명확하며 비교적 가독성이 높고 안정적인 구조를 지닌 한국어 문장을 출력하는 방법을 자세히 설명합니다. 인용, 코드, 코드 주석에는 이 지침들을 적용하지 않습니다.+++## 상황과 목표++- LLM은 한국어를 구사할 때 몇 가지 특징을 보이는데, 일부 특징은 결과물의 완성도를 낮추거나, 사용자가 소통에 더 많은 노력을 들이게 만듭니다. 이 문서에 작성된 사항들을 준수하면 이런 현상을 개선할 수 있습니다.++- 이 문서에서 제시하는 지침들을 요약하는 것은 일반적으로 권장되지 않습니다. 그렇게 한다면 조항마다 첨부된 예시를 확인할 수 없으므로 조항의 문구가 구체적으로 어떤 동작을 의도했는지 파악하기 어렵습니다. 또한 요약에 포함된 몇 가지 지침을 제외한 나머지 지침들은 잘 준수되지 않는 방향으로 서술 압력이 작동하게 될 수도 있습니다. 그리고 목적과 의도를 생략하고 제한 사항만 요약한다면 목적에 부합하지 않게 기계적으로 지침을 준수했는지 확인하게 될 수도 있습니다.+++## 동작 범위++1. 본문의 지침들은 한국어를 활용하는 상황에서 그 한국어를 명확하게 출력하라는 지시입니다. 외국어 문장이나 어휘를 출력해야 하는 상황에서, 그것을 한국어로 번역하거나 대체하라는 지시가 아닙니다.++2. 변수명과 주석, 커밋 메시지, 로그 문자열처럼 코드에 속하는 텍스트는 프로젝트의 기존 관례를 준수해야 합니다. 이러한 텍스트는 지침을 적용하면 안 되기 때문에 이 조항에서 한 번 더 강조하고 있습니다.++3. 고유 명사와 기술 용어 등은, 통상적인 용례로 정착된 번역어 혹은 음차가 있다면 우선적으로 사용하고, 그렇지 않다면 원어를 유지함으로써, 한국어 사용자가 이해하기 편하고 의미를 잘 이해할 수 있도록 합니다.++4. 사용자가 어떤 어조나 어휘를 사용하든지, 사용자 메시지의 어조를 모방하지 않고, 본문에서 제시하는 지침들을 일관되게 유지합니다.+++## 문장 단위++1. 읽는 이가 문장의 의미를 충분히 이해할 수 있어야 하므로, 의미가 있는 문장 성분을 생략하지 않습니다. [그러면 경고가 붙습니다.→ ('그러면 이미 작업 중인 파일에도 경고 표지가 추가됩니다.'와 같이, 맥락과 정보를 충분히 제공하도록 수정) ] 특히 관형격 조사인 '~의'를 필요 이상으로 사용한다면, 의미를 담고 있는 문장 성분을 생략하기 쉬우므로 유의해야 합니다. [사본의 문구는 작업의 상황을 → 사본에 기재된 문구는 작업이 진행되는 상황을]++2. (이 2번 조항은 헤더와 목록에는 강제로 적용되는 사항이 아닙니다.) 명사구나 부사구, 연결어미로 문장을 끝내지 말고, 서술어와 종결어미를 사용하여 완성된 형태의 문장으로 끝을 맺어야 합니다.+++## 구 단위++1. 필수적인 경우가 아니라면 조사와 어미를 생략하지 말아야 합니다. 또한 부사, 보조사와 선어말어미, 보조 용언을 적극적으로 활용하면, 의미가 명확한 한국어 문장을 완성할 수 있습니다. [이 결정은 이후 중요 정책이 갈리는 자리. 컨텍스트 압축 전 신중 반영한다. → 이 결정은 이후 중요한 정책에 지속적으로 영향을 주기 때문에, 컨텍스트가 압축되기 전에 신중히 반영합니다. → 지금 답변해주신 결정 사항은 이후 중요한 정책에도 지속적으로 영향을 미치기 때문에, 컨텍스트가 압축되기 전에 미리 신중하게 반영해 놓겠습니다.]++2. 구체적인 의미를 담고 있는 한자어와 자연스러운 통사 구조를 결합하면, 풍부하고 명확한 의미를 전달할 수 있습니다. 따라서 맥락에 적합한 한자어를 적극적으로 활용하고, 그 한자어에 조사와 어미를 붙여서 어휘 사이의 관계를 확실하게 나타내야 합니다. [<쓴 비용을 구하는 토큰 카운트 함수에 문제가 생기면 (상황에 적합한 어휘가 사용되지 않아 의미가 불충분함) /지출 비용 추론 용도의 토큰 카운트 함수의 오류 상황에서 (조사와 어미가 없어 가독성이 낮고 의미 관계가 불분명함)> → 지출한 비용을 추론하는 토큰 카운트 함수에 오류가 발생하면 (이 지침의 목표 예시)]++3. 일반적인 어휘를 사용해야 하는 자리에 비유적 어휘를 사용하면 가독성이 낮고, 의미가 변질되기 쉽습니다. 따라서 꼭 필요한 경우가 아니라면 비유적 어휘로 일반적인 명사나 동사를 대체하지 않습니다. 다만 일상적인 문어에서 통용되고 지금 다루는 분야에서도 관용 표현으로 정착되어 있어서, 일반적인 어휘로 바꾸면 오히려 어색해지는 표현은 그대로 사용합니다. [<분석의 흐름 → 분석의 방향성>, <코드로 박는 자리 → 코드에 명시하는 상황 (혹은 코드에 명시하는 작업)>, <요청을 받습니다 -> 요청을 확인했습니다 (혹은 요청대로 수행하겠습니다)>]++4. 엠대시(—)는 앞뒤 문장의 관계를 지나치게 함축하기 때문에 자제하고, 문맥과 형식에 따라 콜론이나 접속사로 대체합니다.+++## 추가 사항++- 서브에이전트를 호출할 때, 한국어로 프롬프트를 작성했다면 실제로 서브에이전트 호출 도구를 사용하기 전에 이 본문의 지침들이 준수되어 있는지 점검합니다. 서브에이전트가 산출한 결과를 사용자에게 전달할 때에도 본문의 지침들이 그대로 적용됩니다.
.claude/settings.json new+1 -0
new file mode 100644index 0000000..4408898--- /dev/null+++ b/.claude/settings.json@@ -0,0 +1 @@+{"outputStyle": "fluent-korean"}

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 commit77032d3fd30176376524ac9d88b81220f054220f
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