ES /docs

[Quote][F43544W7U8] Quote application failed: Billable already has a scheduled quote: 870

RCA: [Quote][F43544W7U8] Quote application failed: Billable already has a scheduled quote: 870

Overview#

What Happened#

2026-07-22 00:58 KST, cupixworks-api (ap-southeast-2, tenant cupix) 의 admin quote apply 엔드포인트에서 [Quote][F43544W7U8] Quote application failed: Billable already has a scheduled quote: 870 에러가 발생. Workspace 530 에 이미 예약된 quote 870 이 존재하는 상태에서 다른 미래일자 quote (F43544W7U8, 내부 id 878) 를 apply 하려다 schedule_apply! 의 가드 조건에 걸려 Cupix::Errors::Billing (code: BILL10000) 이 raise 됨. 동일 원인의 실패가 15:54 ~ 16:08 UTC 사이 총 7건 발생하며 status-board 인시던트 2026-07-21-svc-cupixworks-api--unknown-3 로 그룹핑됨.

Quick Facts#

Field Value
exception.class Cupix::Errors::Billing
exception.message Billable already has a scheduled quote: 870
exception.code BILL10000
top_frame app/models/quote.rb:168 (schedule_apply!)
endpoint PUT /api/v1/admin/quotes/878/apply
env production, ap-southeast-2, tenant cupix

Affected Teams#

Team / Domain Error Count Impact
Admin billing (cupixworks-api Quote apply) 7 (같은 워크스페이스, 15:54~16:08 UTC) Workspace 530 에 미래일자 quote 를 apply 하려는 admin 조작이 반복 실패. billing schedule 이 이미 설정된 상태이므로 사용자(고객) 청구/서비스 영향은 없음.

Timeline#

  1. 2026-07-22 00:54 KST[Quote][GYVGXJU55F] (quote 872) apply 시도 → 동일 에러 (첫 발생, 클러스터 79f45086)
  2. 2026-07-22 00:54 KST[Quote][M9X89PV6G6] (quote 874) apply 시도 → 동일 에러 (0e9768a3)
  3. 2026-07-22 00:55 KST[Quote][ZT5TKAT9CA] (quote 876) apply 시도 → 동일 에러 (79a9ed15)
  4. 2026-07-22 00:58 KST[Quote][F43544W7U8] (quote 878) apply 시도 → 동일 에러 (본 클러스터 ffe05413)
  5. 2026-07-22 01:06 KST — quote 880, 884 apply 시도 → 동일 에러 (d00e1f4b, 45bcf1d2)
  6. 2026-07-22 01:08 KST — quote 886 apply 시도 → 동일 에러 (fdedbc62), status-board 마지막 이벤트

Error Log#

Datadog Logs

text
[Quote][F43544W7U8] Quote application failed: Billable already has a scheduled quote: 870

Impact#

  • Service: cupixworks-api
  • Region: ap-southeast-2
  • Tenant: cupix
  • 발생 횟수: 1 (본 클러스터). 동일 원인/워크스페이스로 status-board 상 총 7 클러스터.
  • 최초 발생: 2026-07-22 00:58 KST
  • 최근 발생: 2026-07-22 00:58 KST

Root Cause Summary#

origin/developapp/models/quote.rb#schedule_apply! (TSLA-12975 에서 도입) 는 미래일자로 지정된 quote 를 apply 할 때 billable 의 scheduled_quote_id 가 이미 다른 quote 로 채워져 있으면 Cupix::Errors::Billing (BILL10000) 을 raise 하도록 의도된 가드 로직이다. Workspace 530 에는 이전 admin 조작으로 quote 870 이 이미 scheduled_quote 로 연결되어 있었고, 그 상태에서 admin 이 새로운 미래일자 quote (872, 874, 876, 878, 880, 884, 886) 를 순차적으로 apply 시도했다. 각 시도마다 billable.scheduled_quote_id(=870) 가 새 quote id 와 다르므로 가드가 raise 되어 apply 가 실패했다. 즉, 이는 코드 버그가 아니라 의도된 정합성 가드가 정상 동작한 사례이며, 실제 원인은 "이미 예약된 quote 를 정리(취소/활성화)하지 않은 상태에서 새 미래일자 quote 를 계속 apply 하려 한 운영 흐름"이다.

Technical Analysis#

Code Path#

Entry point: PUT /api/v1/admin/quotes/{id}/applyAdmin::QuoteRepository#applyQuote#apply!schedule_apply! (미래일자 분기).

Datadog info 로그가 이를 뒷받침한다:

text
[400] PUT /api/v1/admin/quotes/878/apply (Api::V1::Admin::QuotesController#apply)
error.class=Cupix::Errors::Billing
error.code=BILL10000
error.reason="Billable already has a scheduled quote: 870"

Repository layer:

app/repositories/admin/quote_repository.rb:45-50ruby
def apply
  raise Cupix::Errors::Billing.new(code: 'BILL10000', reason: 'Permission denied operation: Only accounting deparment can apply the quote') unless Pundit.policy(self.current_user, @model).apply?

  @model.apply!
  @model
end

Model apply!:

app/models/quote.rb:80-95 (origin/develop, TSLA-12975)ruby
def apply!
  Cupix::Logger.info("[Quote][#{number}] Quote application begins #{billable.class.name} #{billable.id}")
  check_appliable

  _billing_started_at, _billing_expires_at = extract_billing_dates
  # ...
  ActiveRecord::Base.transaction do
    if _billing_started_at > Time.zone.today
      schedule_apply!(_billing_started_at)
    else
      activate_apply!(_billing_started_at, _billing_expires_at)
    end
  end

Failure point (가드 자체):

app/models/quote.rb:164-180 (origin/develop, TSLA-12975)ruby
def schedule_apply!(billing_started_at)
  if billable.scheduled_quote_id.present? && billable.scheduled_quote_id != id
    raise Cupix::Errors::Billing.new(
      code: 'BILL10000',
      reason: "Billable already has a scheduled quote: #{billable.scheduled_quote_id}"
    )
  end

  Cupix::Logger.info("[Quote][#{number}] Schedule quote application for #{billing_started_at} on #{billable.class.name} #{billable.id}")

  billable.update!(
    scheduled_quote: self,
    scheduled_billing_started_at: billing_started_at
  )

  applied_state! if state_paid?
end

Rescue chain (에러 메시지 형태 확인):

app/models/quote.rb:98-103 (origin/develop)ruby
rescue Cupix::Errors::Parameter => e
  Cupix::Logger.error("[Quote][#{number}] Invalid Parameter : #{e.message}", ...)
  raise e
rescue StandardError => e
  Cupix::Logger.error("[Quote][#{number}] Quote application failed: #{e.message}")
  raise e

기대 동작: billable.scheduled_quote_id 가 이미 다른 quote 로 세팅된 상태라면 새 미래일자 quote 를 apply 하기 전에 (a) 기존 예약 quote 를 활성화(activate_scheduled!)하거나 (b) 예약을 명시적으로 해제한 뒤 apply 하는 것이 정상 흐름.

실제 동작: 운영자가 (a)/(b) 없이 quote 872 → 874 → 876 → 878 → 880 → 884 → 886 을 연속 apply 하여 매번 동일 지점에서 raise. schedule_apply! 는 이 상황을 명시적으로 차단하도록 설계되어 있으므로 예상 대로 동작한 것.

관련 컨텍스트: 미래일자 스케줄된 quote 는 Cupix::Cron::ScheduledQuote.activate_for cron 이 scheduled_billing_started_at 시각 도달 시 자동 활성화한다:

lib/cupix/cron/scheduled_quote.rb:7-23ruby
def activate_for(billable_class)
  candidates = billable_class.scheduled_quote_activation_candidate
  Cupix::Logger.info("Activating scheduled quotes for #{billable_class.name}: #{candidates.size} candidate(s)", ...)

  candidates.find_each do |billable|
    quote = billable.scheduled_quote
    next if quote.nil?

    ActiveRecord::Base.transaction do
      quote.activate_scheduled!
    end
    Cupix::Logger.info("Activated scheduled quote #{quote.number} on #{billable_class.name} #{billable.id}", ...)
  rescue StandardError => e
    Cupix::Logger.error("Failed to activate scheduled quote on #{billable_class.name} #{billable.id}: #{e.message}", ...)
  end
end

Log Evidence#

Datadog 쿼리 (본 클러스터):

text
service:cupixworks-api "F43544W7U8"

Datadog 쿼리 (동일 워크스페이스 상의 모든 apply 시도):

text
service:cupixworks-api "Quote application begins" "Workspace 530"

Datadog 쿼리 (전체 유사 실패):

text
service:cupixworks-api "scheduled quote"

핵심 로그 (본 클러스터, F43544W7U8 / quote 878):

text
2026-07-22 00:58:15 [info]  [Quote][F43544W7U8] Quote application begins Workspace 530
2026-07-22 00:58:15 [info]  [400] PUT /api/v1/admin/quotes/878/apply (Api::V1::Admin::QuotesController#apply)
    error.class=Cupix::Errors::Billing
    error.code=BILL10000
    error.reason="Billable already has a scheduled quote: 870"
    error.message="Billable already has a scheduled quote: 870"
2026-07-22 00:58:15 [error] [Quote][F43544W7U8] Quote application failed: Billable already has a scheduled quote: 870

같은 워크스페이스에서 반복 실패한 다른 시도 (동일 status-board 인시던트):

text
2026-07-22 00:54:03 [info]  [Quote][GYVGXJU55F] Quote application begins Workspace 530
2026-07-22 00:54:03 [error] [Quote][GYVGXJU55F] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 00:54:19 [error] [Quote][M9X89PV6G6] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 00:55:27 [error] [Quote][ZT5TKAT9CA] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 01:06:13 [error] [Quote][VA8VQHH64X] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 01:06:49 [error] [Quote][N7KE58TH44] Quote application failed: Billable already has a scheduled quote: 870
2026-07-22 01:08:37 [error] [Quote][U5XQ1208JW] Quote application failed: Billable already has a scheduled quote: 870

Status-board (bun run cli/incident-board.ts for-cluster ...) 결과에서 확인된 그룹핑:

text
scope: svc:cupixworks-api::unknown
active.id: 2026-07-21-svc-cupixworks-api--unknown-3
cluster_ids: [79f45086, 0e9768a3, 79a9ed15, ffe05413, d00e1f4b, 45bcf1d2, fdedbc62]  # 7건, 모두 동일 에러
started_at: 2026-07-21T15:54:03.770Z
last_event_at: 2026-07-21T16:08:37.954Z

7건이 모두 동일 워크스페이스(530), 동일 기존 scheduled quote id (870), 동일 에러 메시지를 갖는 것이 결정적 근거.

미확인 사항 (uncertain -- needs verification):

  • quote 870 을 최초로 scheduled 상태로 만든 apply/조작의 로그는 Datadog 14일 retention 내에는 있을 것으로 보이나, scheduled_quote_id=870 로 세팅되는 시점을 정확히 특정하지는 못했다. 필요시 service:cupixworks-api "Schedule quote application for" 530 로 확인 가능.
  • 왜 admin 이 이미 예약된 상태를 인지하지 못하고 다른 quote 를 반복 apply 했는지 (UI 상 상태 미노출 여부) 는 프런트엔드/UX 조사 필요.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Admin 이 이미 예약된 quote (870) 를 정리하지 않은 채 다른 미래일자 quote 를 반복 apply 하여 schedule_apply! 가드가 raise 됨 Datadog: 동일 워크스페이스 530 에 대해 7개 서로 다른 quote(872~886) 가 모두 ... : 870 로 실패. 코드: app/models/quote.rb:165-170 의 명시적 가드. Confirmed
H2 Quote#apply! 로직에 회귀 버그가 생겨 정상 apply 흐름도 실패 만약 회귀라면 모든 워크스페이스에서 에러가 발생해야 함 Datadog: 최근 3일간 다른 워크스페이스 (24, 130, 2424, 5866, 5867, 5868, 5869, 5870) 의 apply 는 Quote application begins 만 남고 실패 로그 없음. Workspace 530 에 국한됨. Rejected
H3 Cupix::Cron::ScheduledQuote.activate_for cron 이 실패하여 오래된 scheduled quote 가 해소되지 않아 이후 apply 가 실패 cron 실패 시 동일 증상 가능. Datadog 상 Failed to activate scheduled quote 로그가 최근 3일 내 조회되지 않음 (검색: service:cupixworks-api "Failed to activate scheduled quote"). 또한 billing_started_at 가 아직 미래일 수 있어 cron 이 정상적으로 skip 중일 가능성이 높음. Rejected (증거 부족 → 대체 가설로 배제)
H4 특정 리전(ap-southeast-2) 의 데이터 오염/마이그레이션 누락 클러스터 region 이 ap-southeast-2 에러 원인이 코드 가드(BILL10000)이고 스택/타입이 리전 특이 요소를 보이지 않음. 다른 리전에서 동일 유즈케이스가 발생하지 않았을 뿐. Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 코드 변경 불필요. app/models/quote.rb:165-170 의 가드는 정합성 유지를 위해 의도적으로 도입된 것이며(BILL10000, TSLA-12975), 정상 동작 중이다.
  • 운영 조치: Workspace 530 의 현재 scheduled_quote_id (=870) 를 확인하고, 다음 중 하나를 결정한다:
    1. quote 870 이 정말 이번에 적용되어야 할 것이라면 그대로 두고, 반복 실패한 apply 시도는 취소.
    2. quote 870 이 잘못 예약된 것이라면 admin 툴/DB 로 workspaces.scheduled_quote_idscheduled_billing_started_at 을 nil 로 해제한 뒤 원하는 quote 를 다시 apply.
  • 협업 필요: 어느 quote 가 최종 적용되어야 하는지는 accounting/영업 컨텍스트에 달려 있으므로 자동화된 code-fix 로 처리하지 말 것.

단기 개선 (1주 이내)#

  • 에러 로그 레벨 재검토: schedule_apply! 가드로 인한 실패는 "코드 결함" 이 아니라 "운영 상태 충돌" 이므로 Cupix::Logger.error 대신 warn 으로 로깅하거나, Cupix::Errors::Billing (BILL10000) 을 rescue Cupix::Errors::Billing 분기로 분리하여 warn 처리하는 것이 적절하다. 현재는 rescue StandardError => e 하나로 모두 error 레벨 처리되어 error-sweeper 가 반복적으로 클러스터를 생성하고 status-board 인시던트를 발동시킨다. 관련 파일: app/models/quote.rb:98-104.
  • Admin API 응답 개선: PUT /api/v1/admin/quotes/{id}/apply 400 응답에 "이미 예약된 quote id" 를 클라이언트가 파싱 가능한 필드로 명시(현재도 error.reason 에 포함되어 있으나 클라이언트 UX 관점에서 별도 필드 권장). 프런트엔드 UI 협의 필요.

장기 개선 (재발 방지)#

  • Admin UI 에 "scheduled quote" 상태 노출: Workspace/Team/Facility admin 화면에서 현재 scheduled_quote_id, scheduled_billing_started_at 을 표시하고, "이미 예약된 quote 가 있으면 새 미래일자 quote apply 를 disable" 하는 프리체크를 추가. 프런트엔드 담당자와 협의 대상.
  • 문서화: docs/license-creation-flow.md (TSLA-12975 로 추가됨) 에 "이미 scheduled 상태인 billable 에 새 미래일자 quote 를 적용하려면 먼저 기존 예약을 해제(또는 활성화)해야 한다" 는 운영 가이드 명시 여부 확인. 없다면 추가.

Monitoring#

Datadog 대시보드 timeseries widget 용 쿼리 (writing-datadog-monitoring-queries 규칙 준수: monitor-only 문법 미사용):

BILL10000 scheduled quote 가드 발동 빈도:

text
logs("service:cupixworks-api \"Billable already has a scheduled quote\"").index("*").rollup("count").by("env").last("1d")

Quote apply 실패 전체 (모든 원인):

text
logs("service:cupixworks-api \"Quote application failed\"").index("*").rollup("count").by("env").last("1d")

Admin quote apply 400 응답 비율:

text
logs("service:cupixworks-api \"PUT /api/v1/admin/quotes\" \"[400]\"").index("*").rollup("count").by("env").last("1d")

알림 아이디어 (운영 판단 하에):

  • 같은 워크스페이스/팀에서 5분 내 3회 이상 Billable already has a scheduled quote 반복 시 admin 알림 (UI 프리체크가 도입되기 전 임시 대응).

Risk Assessment#

  • Risk level: low. 사용자 청구/서비스에는 영향 없음(가드가 정합성 유지). 실패한 apply 는 이후 재시도 가능.
  • 예상 복잡도: trivial. 코드 변경이 필요하지 않음. 로깅 레벨 재조정만 진행할 경우 standard.