fluent-korean
Overview
| Hypothesis | With 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 time | 2026-08-25T23:01:22+09:00 -> 2026-08-25T23:11:41+09:00 |
| Claude Code | 2.1.245 |
| Model (observed) | claude-sonnet-5 |
| N | 2 per condition per test case |
| Test cases | 3 (2 target, 1 control) |
| Runs | 12/12 completed / 0 with timeout, error or permission denials |
| Total cost | $1.31 |
| Caution | N=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
src/payment.js의 charge를 읽고, docs/payment-gateway-payko.md 기준으로 빠진 처리를 정리해서 보고해줘. 코드는 고치지 마.
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
`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
- 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 - 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
No changes.
variant3 turns / 30s / 2 tool calls / 0 files changed
`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
- 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 - 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
No changes.
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
## `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
- 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 - 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
No changes.
variant3 turns / 26s / 2 tool calls / 0 files changed
`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
- 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 - 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
No changes.
Judgment
TC-02 Wiki article on billing cyclestarget
src/proration.js와 docs/billing-cycle-and-proration.md를 읽고, 청구 주기와 일할 계산이 코드에서 어떻게 구현돼 있고 정책과 어디가 다른지 팀 위키에 올릴 기술 글로 정리해줘. 코드는 고치지 마.
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
코드 두 파일과 실제 사용처(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
- 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 - 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
- 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 - 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 - 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 - 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 - 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 - 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
No changes.
variant4 turns / 101s / 3 tool calls / 0 files changed
# 청구 주기·일할 계산 구현 검토: `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
- 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 - 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
- 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
No changes.
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
코드(`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
- 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 - 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
- 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
No changes.
variant8 turns / 140s / 7 tool calls / 1 files changed
`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
- 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 - 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
- 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
- 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 - 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
- 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 - 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
docs/proration-code-vs-policy.md new+135 -0
Judgment
TC-03 Code onlycontrol (no effect expected)
src/plans.js에 플랜을 월 요금 오름차순으로 돌려주는 listPlansByPrice() 함수를 추가해줘. 설명은 하지 말고 코드만 고쳐.
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
완료했습니다.
Trace / 2 tool calls
- 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 - 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
src/plans.js+4 -0
variant3 turns / 10s / 2 tool calls / 1 files changed
`src/plans.js`에 `listPlansByPrice()` 함수를 추가했습니다.
Trace / 2 tool calls
- 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 - 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
src/plans.js+4 -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
완료했습니다.
Trace / 2 tool calls
- 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 - 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
src/plans.js+4 -0
variant3 turns / 11s / 2 tool calls / 1 files changed
`listPlansByPrice()` 함수를 `src/plans.js`에 추가했습니다.
Trace / 2 tool calls
- 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 - 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
src/plans.js+4 -0
Judgment
Summary
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.
.claude/output-styles/fluent-korean.md new+48 -0
.claude/settings.json new+1 -0
Fixed conditions
Applied identically to every run.
| model | claude-sonnet-5 |
|---|---|
| permissions | --dangerously-skip-permissions (allow everything) |
| setting sources | project - excludes ~/.claude CLAUDE.md, settings, plugins, hooks, skills |
| MCP | none (--strict-mcp-config, no --mcp-config) |
| auto memory | off (CLAUDE_CODE_DISABLE_AUTO_MEMORY=1) |
| session persistence | off (--no-session-persistence) |
| instruction load record | InstructionsLoaded hook -> instructions.jsonl |
| budget cap | $1.5 per run (--max-budget-usd) |
| timeout | 600s per run |
| worktree | one 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 commit | 77032d3fd30176376524ac9d88b81220f054220f |
| variant | baseline commit + variant.patch |
| setup | none |
| run order | per test case, for each run k: baseline then variant, alternating, sequential |
| Claude Code | 2.1.245 (Claude Code) |
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