Skip to content

잠정정산(provisional settlement) — BE 핸드오프

2026-07-18 as-built. product owner는 service-charge이며 정기 부과(actual) 4단계 workflow와 분리된 단건 중간정산 계약이다.

경계

필수 상품은 service-charge, 필수 대상은 (company_id, office_id, property_space_id)public.property_spaces다. lease_contracts, 임대 권리·상태, service_charge_stage_workflows, confirm_service_charge_facts를 참조하지 않는다. 선택 원본은 source_subject_type/key/label 문자열 세 칼럼뿐이고 FK·lookup이 없다.

정본 migration은 20260718170000_service_charge_provisional_settlement_events.sql이다.

데이터 모델

  • service_charge_provisional_settlements: mutable draft. 관리코드·등록코드·프로퍼티 공간 scope는 생성 후 불변이다. 이름·사유·기준일·lookback·buffer·명시적 계산 입력·서버 snapshot·합계·선택 원본 문자열·revision을 가진다.
  • service_charge_provisional_settlement_confirmations: settlement당 1개인 immutable terminal Fact. 확정 revision, calculation version/snapshot/total, actor/time/request key를 보존한다.
  • service_charge_provisional_settlement_events: created/updated/confirmed/cancelled append-only 감사행.
  • private.service_charge_provisional_settlement_commands: command별 request payload/response ledger. (company_id, command_name, request_key)가 exact retry를 보장한다.

상태는 draft → confirmed 또는 draft → cancelled뿐이다. 두 terminal 상태는 update와 재전이를 모두 거부한다. 삭제·reopen·재확정 버전은 이 계약에 없다.

계산 계약 v1

private.calculate_service_charge_provisional_snapshot_v1가 브라우저와 무관하게 계산한다. 입력은 1120개 operator-owned line이며 각 line은 고유 code, name, taxable|exempt, area|usage, 136개의 JSON number 단가 이력, 0 이상 quantity를 가진다.

  • 평균 단가: 최근 lookback_months개 산술평균
  • 추산 단가: 평균 × (1 + buffer_basis_points / 10000)
  • area: round(추산 단가 × quantity × cutoff day / 실제 month days)
  • usage: round(추산 단가 × quantity), 일할 없음
  • tax: taxable 공급가액 합 × 정확한 정수 basis points, 기본 1000

area의 day/monthDays는 client 값을 신뢰하지 않는다. cutoff_date에서 윤년과 30/31일을 서버가 파생하고 전달 값이 exact integer로 같아야 한다. 세율·day의 fractional number, 숫자 문자열, 중복 code, 빈 history, history 36개 초과는 안정 오류 service_charge_provisional_calculation_input_invalid 또는 ...rate_history_required로 거부한다.

create/update 때 서버 snapshot과 total을 저장하고 confirm 때 같은 입력을 다시 계산해 stored snapshot/total과 exact 비교한다. 불일치는 service_charge_provisional_snapshot_mismatch다.

RPC와 보안

  • list_service_charge_provisional_settlements_v1(company, office){settlements, spaces}
  • create_..._v1(...), update_..._v1(...)
  • confirm_..._v1(...), cancel_..._v1(...)

모든 command는 auth.uid(), Office membership/role, service-charge entitlement, company/office scope를 서버에서 확인한다. update/terminal command는 expected revision을 행 잠금 안에서 검사한다. 공개 테이블은 RLS를 켰지만 authenticated direct table privilege를 회수했고, browser에는 public RPC execute만 준다. definer 함수는 search_path='', private helper는 PUBLIC/anon/authenticated execute를 회수한다.

관리코드를 비운 create retry는 요청 payload에 nullable 요청값을 기록한 뒤 기존 command를 먼저 조회하고, 신규 insert에서만 코드를 생성한다. 따라서 동일 request key 재시도는 같은 id/code/response를 반환한다. 같은 key의 다른 payload는 conflict다.

선택 연결

확정 Fact와 domain event가 먼저 커밋된다. 그 뒤 platform_integration_eventsservice-charge.provisional-settlement-confirmed.v1 중립 이벤트를 best-effort enqueue한다. accounting/e-document delivery를 요청하거나 자동 전기·자동 발행하지 않는다. event enqueue 예외는 Fact를 롤백하지 않는다.

검증

  • 실제 PostgreSQL rollback: supabase/tests/service_charge_provisional_settlement_smoke.sql
  • 구조: serviceChargeProvisionalSettlementMigration.spec.js
  • repo: serviceChargeProvisionalSettlementRepo.spec.js

smoke는 manager success, unauthorized/cross-Office denial, 빈 관리코드 retry, request conflict, optimistic conflict, label-only source 거부, malformed/fractional 계산 입력, 윤년, terminal 전이, direct write, Fact append-only와 optional event를 실행한다.