---
template: policy-doc@1
doc_id: 02-billing-cycles
title: 과금 주기 — 정기결제 · 해지 · 정산 · 머니 소멸의 회차 경계
config: ../_meta/template-config.yaml
last_verified: 4d798f55
policies:
  - id: billing:disable-subscription
    status: conditional-unknown
    evidence: [A-scheduler, A-comment]
    unknown_reason: 이 규칙의 발효는 담당 배치의 활성에 걸려 있고 그 활성은 판정되지 않았다 — 04-runtime-topology.md#schedule:disable-subscription 이 그 미판정의 근거와 남은 입력을 진다. 해지 완료 처리의 전날 경계 자체는 동결 stg 4d798f55 의 코드 행으로 확정된다.
    anchors:
      - insuniverse-server/apps/api/src/schedules/disable-subscription.schedule.ts:20
      - insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:334
  - id: billing:expired-money
    status: conditional-unknown
    evidence: [A-scheduler, A-comment]
    unknown_reason: 이 규칙의 발효는 담당 배치의 활성에 걸려 있고 그 활성은 판정되지 않았다 — 04-runtime-topology.md#schedule:expired-money 가 그 미판정의 근거와 남은 입력을 진다. 이 규칙에는 기간 상수가 없다 — 대상은 각 머니 건에 기록된 만료 시각의 도래로 정해진다.
    anchors:
      - insuniverse-server/apps/api/src/schedules/expired-money.schedule.ts:24
      - insuniverse-server/apps/api/src/modules/money/money.repository.ts:1746
  - id: billing:regular-agency-subscription
    status: conditional-unknown
    evidence: [A-scheduler, A-comment]
    unknown_reason: 이 규칙의 발효는 담당 배치의 활성에 걸려 있고 그 활성은 판정되지 않았다 — 04-runtime-topology.md#schedule:regular-agency-subscription 이 그 미판정의 근거와 남은 입력을 진다. 이 규칙에는 기간 상수가 없다 — 대상은 구독의 다음 결제일 당일 일치로 정해진다.
    anchors:
      - insuniverse-server/apps/api/src/schedules/regular-agency-subscription.schedule.ts:23
      - insuniverse-server/apps/api/src/schedules/regular-agency-subscription.schedule.ts:29
      - insuniverse-server/apps/api/src/schedules/regular-agency-subscription.schedule.ts:34
      - insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:527
  - id: billing:regular-subscription
    status: conditional-unknown
    evidence: [A-scheduler, A-comment]
    unknown_reason: 이 규칙의 발효는 담당 배치의 활성에 걸려 있고 그 활성은 판정되지 않았다 — 04-runtime-topology.md#schedule:regular-subscription 이 그 미판정의 근거와 남은 입력을 진다. 이 규칙에는 기간 상수가 없다 — 대상은 구독의 다음 결제일 당일 일치로 정해지고, 아침·저녁 두 회차의 구분은 실행 시각으로 갈린다.
    limitations:
      - 아침 회차와 저녁 회차를 실행 시각(9시 · 18시)으로 가르고 그 밖의 시각에 들어오면 두 회차 어느 쪽도 아닌 값으로 진행하며 로그만 남긴다 (`regular-subscription.schedule.ts:24`~`:30`). 어느 시각에 호출되는지는 저장소 밖의 규칙이 정한다
    anchors:
      - insuniverse-server/apps/api/src/schedules/regular-subscription.schedule.ts:22
      - insuniverse-server/apps/api/src/schedules/regular-subscription.schedule.ts:37
      - insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:479
  - id: billing:settlement-agency
    status: conditional-unknown
    evidence: [A-scheduler, A-comment]
    unknown_reason: 이 규칙의 발효는 담당 배치의 활성에 걸려 있고 그 활성은 판정되지 않았다 — 04-runtime-topology.md#schedule:settlement-agency 가 그 미판정의 근거와 남은 입력을 진다. 여기에 더해 회차 기준일 자체가 미확인이다 — SETTLE_TARGET_DATE 환경변수가 지정되면 그 날짜가 기준이 되고 그 운영 설정값은 저장소 밖에 있다.
    limitations:
      - 회차 기준일에 환경변수 손잡이가 있다. 지정되지 않으면 전월이 기준이고, 지정되면 그 날짜가 기준이다 (`settlement-agency.schedule.ts:26`~`:29`). 같은 정산 축의 클라이언트 배치에는 이 손잡이가 없다
      - 두 정산 배치의 docblock 제목이 둘 다 `기업 정산 스케줄러` 다. 파일명과 호출 서비스로만 갈린다
    anchors:
      - insuniverse-server/apps/api/src/schedules/settlement-agency.schedule.ts:30
  - id: billing:settlement-client
    status: conditional-unknown
    evidence: [A-scheduler, A-comment]
    unknown_reason: 이 규칙의 발효는 담당 배치의 활성에 걸려 있고 그 활성은 판정되지 않았다 — 04-runtime-topology.md#schedule:settlement-client 가 그 미판정의 근거와 남은 입력을 진다. 회차 기준일은 전월로 고정돼 있어 이 축에는 추가 미확인이 없다.
    anchors:
      - insuniverse-server/apps/api/src/schedules/settlement-client.schedule.ts:23
---

# 과금 주기 — 정기결제 · 해지 · 정산 · 머니 소멸의 회차 경계

*"정산 회차가 잡는 대상의 경계는 무엇인가"* 의 답을 저장소 안에서 찾은 결과다. 여섯 규칙 중
기간 상수를 가진 것은 셋뿐이고 나머지 셋은 **기간이 아니라 도래로** 대상을 고른다 — 그 부재가
결손이 아니라 관측이며 `## 근거 대조표` 가 그 사실을 판정 `임계 없음` 으로 싣는다.

`04-runtime-topology.md` 가 확정한 것은 이 배치들의 *선언* 이었고 **어떤 회차를 어떻게 잡는지는
그 문서가 적지 않았다.** 이 문서가 그 자리를 채운다. 반대로 배치가 지금 실제로 도는지는 이
문서도 답하지 않으며, 그 판정에 필요한 입력은 각 정책의 `unknown_reason` 이 M1 항목을 이름으로
가리켜 넘긴다.

**정산 축에는 미확인이 한 겹 더 있다.** 기업 정산의 회차 기준일에는 환경변수 손잡이가 있고 그
운영 설정값은 저장소 밖이다. 그래서 이 축은 *게이트 유무*뿐 아니라 **회차 기준일 자체**가
미확인이다. 클라이언트 정산에는 그 손잡이가 없다.

결제 산식 · 환불 계산 · 정산 금액 집계는 `50-domain` 소유다. 이 문서는 대상을 고르는 조건과
회차 경계까지만 적는다.

## 정책 (기획)

**이 절에는 정책 블록이 없다. 과금 규칙 여섯 전부가 `## 부록` 에 있다.**

과금 주기는 *언제 청구되는가* 가 아니라 **언제 청구가 시작되는가** 로 읽힌다. 회차 경계가
전월이라는 사실과 매월 1일에 실제로 정산 데이터가 생긴다는 사실은 다르고, 둘을 가르는 것은
담당 배치가 그 시각에 호출되는가다. 그 호출은 EventBridge 규칙과 ECS task-def 가 정하며 **둘 다
저장소 밖에 있다.**

정산 축은 여기에 한 겹이 더 붙는다. 기업 정산은 회차 기준일 자체가 환경변수로 움직일 수 있고
그 값도 저장소 밖이다. 즉 이 축은 *배치가 도는가* 와 *어느 회차를 잡는가* 두 축이 함께
미확인이며, 그 상태로 본문에 두면 독자가 확정된 청구 주기로 읽는다.

## 근거 대조표

각 행은 규칙 값 하나에 대한 **코드 측 주장과 주석 측 주장**을 나란히 싣는다. 두 원문은 동결
stg `4d798f55` 트리의 그 행 그대로이고, `기준값` 은 코드 측 정규 쌍의 표준 표기(코드 측이 없으면
주석 측)이며, `판정` 은 게이트가 두 원문에서 다시 유도해 계산한다. 판정 `임계 없음` 은 그
관측 지점에 수치 임계가 **없다**는 관측이며, 그 지점의 좌표는 이 문서가 고르지 않는다.

| 정책 id | 기준값 | 코드 앵커 | 코드 원문 | 주석 앵커 | 주석 원문 | 판정 |
|---|---|---|---|---|---|---|
| `billing:disable-subscription` | 1일 | `insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:334` | `    const subDate = nowDate.subtract(1, 'day').format('YYYY-MM-DD');` | — | — | 주석 없음 |
| `billing:expired-money` | — | `insuniverse-server/apps/api/src/modules/money/money.repository.ts:1746` | `  async getExpiredSources() {` | — | — | 임계 없음 |
| `billing:regular-agency-subscription` | — | `insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:527` | `  async getAgencyListForSchedule() {` | — | — | 임계 없음 |
| `billing:regular-subscription` | — | `insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:479` | `  async getListForSchedule(scheduled: boolean, id?: number) {` | — | — | 임계 없음 |
| `billing:regular-subscription` | — | `insuniverse-server/apps/api/src/schedules/regular-subscription.schedule.ts:8` | ` * 일반FC 정기구독결제 스케줄러` | — | — | 임계 없음 |
| `billing:settlement-agency` | 1개월 | `insuniverse-server/apps/api/src/schedules/settlement-agency.schedule.ts:29` | `    dayjs().subtract(1, 'month').format('YYYY-MM-DD');` | — | — | 주석 없음 |
| `billing:settlement-agency` | 1일 | — | — | `insuniverse-server/apps/api/src/schedules/settlement-agency.schedule.ts:9` | ` * 동작주기 : 매월 1일 자정` | 코드 없음 |
| `billing:settlement-client` | 1개월 | `insuniverse-server/apps/api/src/schedules/settlement-client.schedule.ts:22` | `  const date = dayjs().subtract(1, 'month').format('YYYY-MM-DD');` | — | — | 주석 없음 |
| `billing:settlement-client` | 1일 | — | — | `insuniverse-server/apps/api/src/schedules/settlement-client.schedule.ts:9` | ` * 동작주기 : 매월 1일 자정` | 코드 없음 |

**정산의 두 `1일` 과 `1개월` 을 짝짓지 않는다.** docblock 의 `매월 1일 자정` 은 기간이 아니라
**호출 시점**이고 `subtract(1, 'month')` 는 **집계 대상 회차**다. 둘을 한 행에 묶으면 존재하지
않는 갈림이 생긴다. 두 주장은 각각 반대쪽 열을 `—` 로 둔 채 따로 실린다.

`동작주기` 주장이 전부 `코드 없음` 인 것은 호출 주기가 저장소 밖(EventBridge)에서 정해지기
때문이다. 저장소는 그 주기를 주석으로만 말한다.

`임계 없음` 네 행은 조회 지점에 기간 상수가 **없다**는 관측이다. 머니 소멸은 각 머니 건에 적힌
만료 시각의 도래로, 정기구독 대상은 구독의 다음 결제일 당일 일치로 판정하므로 뺄 기간이 없다.

## 강제 지점 (개발)

규칙 내용을 다시 쓰지 않는다. 각 정책이 저장소 안 **어디에서** 강제되는가와 그 지점이 어떤
기제인가만 적는다. 배치가 부록인 정책도 이 절에 있다.

### billing:disable-subscription

- 강제 방식: 리포지터리 갱신 질의. `insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:334` 가 전날 경계를 계산해 해지 완료 대상을 고른다.
- 진입: `insuniverse-server/apps/api/src/schedules/disable-subscription.schedule.ts:20` 이 유일한 호출이다.
- 기준점: 동결 stg `4d798f55`.

### billing:expired-money

- 강제 방식: 리포지터리 조회 질의. `insuniverse-server/apps/api/src/modules/money/money.repository.ts:1746` 이 만료 시각이 지난 머니 원천을 고른다. 기간 상수가 없다.
- 진입: `insuniverse-server/apps/api/src/schedules/expired-money.schedule.ts:24` 이 유일한 호출이며 `try`/`catch` 안에 있다.
- 기준점: 동결 stg `4d798f55`.

### billing:regular-agency-subscription

- 강제 방식: 리포지터리 조회 질의 + 실패 적재. `insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:527` 이 대상을 고르고, `insuniverse-server/apps/api/src/schedules/regular-agency-subscription.schedule.ts:34` 가 건별 실패를 별도 테이블에 남긴다.
- 진입: `insuniverse-server/apps/api/src/schedules/regular-agency-subscription.schedule.ts:23` 이 목록을 읽고 `:29` 가 건별 결제·주문 처리를 부른다.
- 기준점: 동결 stg `4d798f55`.

### billing:regular-subscription

- 강제 방식: 리포지터리 조회 질의. `insuniverse-server/apps/api/src/modules/subscribe/subscribe/subscribe.repository.ts:479` 가 대상을 고른다. 아침·저녁 회차 구분은 스케줄러 파일 안에서 실행 시각으로 갈린다.
- 진입: `insuniverse-server/apps/api/src/schedules/regular-subscription.schedule.ts:22` 가 목록을 읽고 `:37` 이 건별 결제·주문 처리를 부른다.
- 기준점: 동결 stg `4d798f55`.

### billing:settlement-agency

- 강제 방식: 정산 서비스 호출. `insuniverse-server/apps/api/src/schedules/settlement-agency.schedule.ts:30` 이 회차 기준일 문자열 하나를 넘겨 집계를 만든다. 그 기준일 계산이 같은 파일 안에 있다.
- 진입: 동 `:30` 이 유일한 호출이다.
- 기준점: 동결 stg `4d798f55`.

### billing:settlement-client

- 강제 방식: 정산 서비스 호출. `insuniverse-server/apps/api/src/schedules/settlement-client.schedule.ts:23` 이 회차 기준일 문자열 하나를 넘겨 집계를 만든다. 기준일은 전월 고정이다.
- 진입: 동 `:23` 이 유일한 호출이다.
- 기준점: 동결 stg `4d798f55`.

## 적용 범위 (actor × 서비스)

과금 주기 규칙에는 actor 표면이 없다. 결제·정산의 *결과* 는 여러 actor 에게 보이지만 이 문서의
정책은 **서버가 회차 대상을 고르는 조건**에 대한 사실이며 그 조건은 어느 actor 의 요청 경로에도
등장하지 않는다. actor 다섯 열이 전건 `—` 인 것은 빈 칸이 아니라 관측된 부재다. 서비스 축에서는
`server` 하나만 `적용` 이다.

| 정책 id | 관리자 | 영업대리점 | 기업담당자 | 일반회원 | 기업회원 | server | site | admin |
|---|---|---|---|---|---|---|---|---|
| billing:disable-subscription | — | — | — | — | — | 적용 | — | — |
| billing:expired-money | — | — | — | — | — | 적용 | — | — |
| billing:regular-agency-subscription | — | — | — | — | — | 적용 | — | — |
| billing:regular-subscription | — | — | — | — | — | 적용 | — | — |
| billing:settlement-agency | — | — | — | — | — | 적용 | — | — |
| billing:settlement-client | — | — | — | — | — | 적용 | — | — |

## 예외·폴백

예외가 없는 정책도 행을 둔다 — 없다는 것도 확인의 결과이며 그 확인 근거를 함께 적는다.

| 정책 id | 예외 | 폴백 | 확인 근거 |
|---|---|---|---|
| billing:disable-subscription | 관측된 예외 없음 | 관측된 폴백 없음 | 최상위 호출에 `catch` 절이 없다. 같은 파일 22~32행의 이전 구현은 주석 처리돼 있어 강제 지점이 아니다 |
| billing:expired-money | 관측된 예외 없음 | 배치 전체를 `try`/`catch` 로 감싸 실패를 로그로만 남기고 종료 코드는 정상이다 (`apps/api/src/schedules/expired-money.schedule.ts:26`~`:28`) | 건별이 아니라 배치 단위 `catch` 다 — 한 건이 던지면 남은 건이 그 실행에서 처리되지 않는다 |
| billing:regular-agency-subscription | 관측된 예외 없음 | 건별 실패를 구독실패 테이블에 적재하고 다음 건을 계속 처리한다 (`apps/api/src/schedules/regular-agency-subscription.schedule.ts:34`) | 반복문 안 `catch` 가 실패 적재까지 하고 삼킨다. 폴백 결과가 저장소 안에 남는 유일한 축이다 |
| billing:regular-subscription | 실행 시각이 아침·저녁 어느 쪽도 아니면 두 회차 구분값 중 하나로 진행하며 로그만 남긴다 | 건별 실패를 로그로만 남기고 다음 건을 계속 처리한다 (`apps/api/src/schedules/regular-subscription.schedule.ts:39`~`:41`) | 기업 축과 달리 실패가 테이블에 남지 않는다 — 같은 형태의 두 배치가 실패 기록에서 갈린다 |
| billing:settlement-agency | 회차 기준일이 환경변수로 지정되면 전월 대신 그 날짜가 기준이 된다 | 관측된 폴백 없음 | 기준일 계산이 `??` 한 줄이고 호출에 `catch` 절이 없다 |
| billing:settlement-client | 관측된 예외 없음 | 관측된 폴백 없음 | 기준일이 전월 고정이고 호출에 `catch` 절이 없다 |

## 부록

이 절의 정책은 **현행 발효 여부가 판정되지 않아** 본문에서 격리한다 — 판정에 필요한 입력
(EventBridge 규칙 · ECS task-def)이 저장소 밖에 있고, 정산 축은 회차 기준일까지 저장소 밖이다.
그 상태로 본문에 두면 독자가 확정된 청구 주기로 읽는다.

**여기 실린 어떤 정책도 배치의 활성을 부정하지 않는다.** 미판정과 부정은 다르다. 저장소는
활성을 긍정할 근거도 부정할 근거도 갖고 있지 않다.

각 블록은 **규칙의 조건과 결과**를 코드 심볼 없이 적는다. 좌표는 프론트매터 `anchors` 와
`## 근거 대조표` 가 진다.

### billing:disable-subscription

구독 상태가 해지이고 다음 결제일이 지난 구독에 대해 해지 완료일을 박는다. 경계는 **전날**이다 —
오늘 기준으로 하루를 뺀 날짜와 비교해 대상을 고른다.

사용자에게 일어나는 일: 해지를 신청한 구독이 다음 결제일을 넘긴 뒤 완료 상태로 정리된다. 그
시점까지는 해지 신청 상태로 남는다.

발효 판정에 남은 입력은 `04-runtime-topology.md#schedule:disable-subscription` 이 진다.

### billing:expired-money

유효기간이 만료된 적립·지급 머니의 잔액을 소멸 처리한다. **기간 상수가 없다** — 각 머니 건에
기록된 만료 시각이 지났는가만 본다. 그래서 이 규칙에는 창이 없고 도래만 있다.

사용자에게 일어나는 일: 만료 시각이 지난 머니는 잔액에서 빠진다. 어떤 원천이 언제 만료되는지는
그 원천을 만든 시점의 규칙이 정하며 이 배치는 그 결과를 집행할 뿐이다.

발효 판정에 남은 입력은 `04-runtime-topology.md#schedule:expired-money` 가 진다.

### billing:regular-agency-subscription

구독 상태가 구독 또는 변경인 기업FC 구독 중 **결제일이 오늘인** 건을 골라 정기결제와 주문
처리를 한다. 기간 상수가 없다 — 다음 결제일과 오늘의 일치가 기준이다.

사용자에게 일어나는 일: 결제일에 자동 결제가 이뤄지고 주문이 생성된다. 실패하면 그 건은 구독실패
기록으로 남고 나머지 건의 처리는 이어진다.

발효 판정에 남은 입력은 `04-runtime-topology.md#schedule:regular-agency-subscription` 이 진다.

### billing:regular-subscription

구독 상태가 구독 또는 변경인 일반FC 구독 중 **결제일이 오늘인** 건을 골라 정기결제와 주문
처리를 한다. 기간 상수가 없다.

이 배치는 하루 두 회차로 나뉘고 그 구분을 **실행 시각**으로 정한다 — 저녁 회차인지 아침 회차인지를
호출된 시각으로 판단해 결제 처리에 넘긴다. 어느 시각에 호출되는지는 저장소 밖의 규칙이 정하므로,
두 회차의 실제 구분도 그 입력에 걸려 있다. 그 밖의 시각에 들어오면 두 회차 어느 쪽도 아닌 값으로
진행하며 로그만 남긴다.

기업 축과 달리 건별 실패가 저장소 안에 남지 않는다. 같은 형태의 두 배치가 실패 기록에서 갈린다.

발효 판정에 남은 입력은 `04-runtime-topology.md#schedule:regular-subscription` 이 진다.

### billing:settlement-agency

기업 전체를 조회해 기업별 구독 건수에 따른 정산 데이터를 만든다. 집계 대상 회차는 **전월**이고,
생성되는 정산의 상태는 미정산이다. 활성 계약의 과금 방식이 무상인 기업은 집계에서 빠지며, 전월
정산에서 이월된 주문은 신규 정산에 편입돼 정산 수와 금액에 포함된다.

**회차 기준일에 환경변수 손잡이가 있다.** 지정되지 않으면 전월이고, 지정되면 그 날짜가 기준이
된다. 소스 주석은 그 손잡이를 로컬·수동 테스트용이라고 적지만 그 값이 운영에서 설정돼 있는지는
저장소가 말하지 않는다. 그래서 이 정책은 *배치가 도는가* 와 *어느 회차를 잡는가* 가 함께
미확인이다.

`## 근거 대조표` 의 `1개월` 은 이 집계 회차이고 `1일` 은 docblock 이 말하는 호출 시점이다. 둘은
다른 축이라 서로 대조되지 않는다.

발효 판정에 남은 입력은 `04-runtime-topology.md#schedule:settlement-agency` 가 진다.

### billing:settlement-client

고객사(클라이언트) 정산 데이터를 만든다. 집계 대상 회차는 **전월**이고 기준일에 손잡이가 없다 —
이 축은 회차가 코드로 고정돼 있다.

기업 정산과 이 배치의 docblock 제목이 둘 다 `기업 정산 스케줄러` 다. 파일명과 호출하는 정산
서비스로만 갈리며, 그 표기 중복 자체가 관측이다.

발효 판정에 남은 입력은 `04-runtime-topology.md#schedule:settlement-client` 가 진다.

## 연혁

릴리스 태그가 존재하지 않는다. 릴리스 열은 `—` 로 두고 커밋 SHA 로 추적한다. 행은
`docs/_meta/index/commits.server.jsonl` 이 이 문서의 앵커 경로 접촉으로 기록한 커밋 집합 안에서
**회차 규칙이 바뀐 시점**만 골랐다. 그 집합 전체(96건)를 싣지 않는다 — 이 계수는 문서의
주장이 아니라 게이트 `#9b` 가 인덱스에서 다시 세는 값이다.

| 릴리스 | 날짜 | SHA | 변경 요약 |
|---|---|---|---|
| — | 2024-12-16 | `6ebfd552` | 일반FC 정기구독 결제 배치 파일 신설 |
| — | 2024-12-19 | `b8a7fe27` | 정기구독 배치에 아침·저녁 두 회차 분기 도입 |
| — | 2024-12-27 | `8e1366c0` | 해지처리 배치 파일 신설 |
| — | 2024-12-30 | `e4b9bf01` | 해지 완료 처리 규칙 신설 |
| — | 2025-03-31 | `6ca55059` | 기업FC 정기구독 결제 배치 신설 — 실패 적재 포함 |
| — | 2025-04-02 | `9d653454` | 정산 배치 2종의 현재 파일명 확정 |
| — | 2025-11-24 | `34bc5264` | 머니 유효기간 판정 규칙 개정 |
| — | 2025-11-27 | `d64cae43` | 머니 소멸 배치 신설 |
| — | 2026-07-11 | `d4513a79` | 기업 정산 회차 기준일에 환경변수 손잡이 도입 |

**정산 배치 2종의 신설 시점은 이 표에 행이 없다.** 커밋 인덱스에서 두 파일에 대해 잡히는 가장
이른 접촉이 2025-04-02 의 파일명 변경이고, 그 이전 이름의 파일은 이 문서의 앵커 경로에 없어
경로 접촉 기준으로 해석되지 않는다. 없는 행을 만들지 않고 그 사실을 여기 적는다.
