Skip to content

관리비 계약채권 수납 — BE 참고

Wave25A 원장 + Wave26 subject projection as-built. 원장은 20260718210000_service_charge_receivable_receipt_ledger.sql, v3 원자·projection은 20260718220000_service_charge_subject_projections.sql이며 각 SQL smoke가 rollback 검증한다.


1. 제품 경계와 진실원

  • product owner: service-charge
  • grain: v2는 기존 계약별 installment, 신규 v3는 계약×프로퍼티 공간별 원자 installment
  • accounting은 선택적 전달 대상이다. 회계 테이블은 FK나 계산 진실원으로 참조하지 않는다.
  • 수납·배분·정정은 원행을 수정하지 않는 append-only 사실이다.
  • Company·Office·service-charge entitlement가 모든 조회/명령의 공통 경계다.

외부 상품 테이블(accounting_*, tax_invoice_*, crm_*, lease_*, hospitality_*)에 직접 의존하지 않는다. 선택적 회계 연동은 플랫폼 outbox를 통해서만 요청한다.


2. v1/v2/v3 청구 계약 경계

private.validate_service_charge_invoice_payload(jsonb)는 기존 확정 사실과 exact retry를 깨지 않기 위해 v1/v2 검증을 그대로 허용한다. 실제 신규 확정은 service-charge-invoice.v3를 사용한다.

  • public.service_charge_invoice_versions.edition = 'actual'
  • invoice_payload.documentContractVersion = 'service-charge-invoice.v2'
  • period.managementCode가 fact의 period_management_code와 일치
  • period.startsOn <= period.endsOn; period.dueOn은 유효한 날짜이며 월중 납기도 허용
  • invoiceKey는 중복되지 않고 snapshot.contractManagementCode와 일치
  • 각 snapshot의 totalAmount = supplyAmount + taxAmount, 모두 정수 금액
  • payload totals가 invoice snapshot 합계와 일치

materialize_service_charge_receivable_installments는 v2와 v3 실제 청구를 물질화한다. v3의 각 invoice는 계약×공간 원자이며 invoiceKey = contractManagementCode#propertySpaceId다. snapshot의 propertySpaceId, memberPartyId, memberAssignmentId를 현재 Company·Office의 property_spaces, master_parties, office_party_assignments 및 member/ar_customer role과 대조한 뒤에만 insert한다. 또한 바로 앞의 관리비 소유 부과 확정 Fact allocation에 동결된 동일 계약·공간·멤버 원자와 공급가액 합계가 정확히 일치해야 한다. 따라서 별도 임대차관리 상품 없이도 관리비 부과 Fact가 관계 SSOT가 되며, 해소되지 않은 ID·fake ID·부과 뒤 오귀속 변경은 확정을 차단한다.

v3 금액은 원자마다 taxableSupplyAmount + exemptSupplyAmount + zeroRatedSupplyAmount = supplyAmount, totalAmount = supplyAmount + taxAmount, 비음수 정수 조건을 만족해야 한다. 계약별 세액 합계는 과세 공급가액 합계의 10% 반올림과 같아야 하며 공간 UUID 오름차순 잔차 배분 뒤에도 음수 세액을 허용하지 않는다.

계약 합계 VAT를 공간마다 독립 반올림하지 않는다. 기존 계약 총 VAT를 먼저 고정하고 공간 UUID 오름차순을 tie-break로 잔여 1원을 배분한다. 음수 residual은 tax가 양수인 UUID순 atom에서만 차감하여 atom 세액이 음수가 되지 않으며, 모든 atom 공급가·세액 합은 기존 계약 합계와 정확히 같다. 수납 명령은 원자 installment를 대상으로 하므로 다공간 계약의 부분수납을 임의 비례 배분하지 않는다.

레거시 v2 installment의 subject UUID 컬럼은 null로 남긴다. 코드 문자열을 이용한 추측 backfill은 금지한다.


3. 테이블

공개 read model/facts

테이블역할주요 불변식
public.service_charge_receivable_installments확정 v2/v3 청구에서 생성된 계약채권 원금v3는 property space·party·assignment·subject snapshot version 고정, v2는 nullable legacy, (invoice_version_id, invoice_key) 유일
public.service_charge_receipts실제 수납 사실양수 금액, 방법 bank_transfer/cash/card/other, Office 안 관리코드 유일
public.service_charge_receipt_allocations수납 1건을 installment에 연결양수 금액, (receipt_id, installment_id) 유일, 현재 Wave는 한 수납을 한 계약채권에 배분
public.service_charge_receipt_reversals수납 정정 사실사유 1~500자, receipt_id 유일이라 한 번만 정정 가능

네 테이블 모두 private.reject_service_charge_collection_fact_mutation()을 사용하는 before update or delete 트리거가 있다. 정정은 receipt/allocation 삭제가 아니라 reversal insert다.

비공개 명령 원장

private.service_charge_collection_commands(company_id, request_key)를 유일키로 보유하며 다음을 기록한다.

  • command_name: record_receipt_v1 또는 reverse_receipt_v1
  • 정규화한 request_payload
  • 최초 성공의 response_data

동일 요청키·동일 payload는 최초 응답을 그대로 재생하고, 동일 요청키·다른 payload는 service_charge_collection_request_key_payload_conflict로 거절한다. pg_advisory_xact_lock으로 동시 exact retry도 직렬화한다.


4. RPC 계약

list_service_charge_receivables_v1

sql
public.list_service_charge_receivables_v1(
  p_company_id uuid,
  p_office_id uuid,
  p_as_of date default current_date
) returns jsonb

응답은 { companyId, officeId, asOf, receivables[] }다. 각 행은 installment snapshot, collectedAmount, outstandingAmount, 수납 이력을 포함한다.

asOf 계산 규칙:

  • 청구 확정은 한국시간 기준 p_as_of 종료 시각까지 반영
  • 수납은 received_on <= p_as_of인 allocation만 합산
  • 정정은 한국시간 기준 reversed_at 날짜가 p_as_of 이내일 때 해당 수납을 제외
  • 같은 workflow에서 p_as_of까지 확정된 더 최신 invoice version이 있으면 구버전 installment를 제외
  • 잔액 0은 paid, 잔액이 있고 due_on < p_as_ofoverdue, 나머지는 current

화면의 label·badge는 공통 billingReceivableStatusMeta로 렌더하지만 이 RPC의 상태와 as-of 계산이 정본이다. 표시 공통화는 관리비 수납 권한·충당·정정 명령을 다른 Billing 상품으로 위임하지 않는다.

금액·상태·수납 이력은 모두 동일한 asOf 시점으로 잘라 한 응답 안에서 일치한다.

유효한 수납 배분이 하나라도 남아 있는 workflow는 청구 단계를 재오픈하거나 새 실제 청구 버전을 만들 수 없다. 먼저 잘못된 수납을 reversal로 정정해야 하며, DB는 각각 service_charge_invoice_reopen_after_collection_forbidden, service_charge_invoice_revision_after_collection_forbidden으로 차단한다.

list_service_charge_receivable_projections_v1

sql
public.list_service_charge_receivable_projections_v1(
  p_company_id uuid,
  p_office_id uuid,
  p_axis text, -- unit | member
  p_as_of date
) returns jsonb

기존 installment/receipt/allocation/reversal만 읽는 projection이며 별도 세대·멤버 수납 fact를 만들지 않는다. 응답의 projections[]는 subject별 청구·수납·잔액 합계와 exact receivables[] drilldown을 포함한다. 청구 확정, 수납일, 정정일, 최신 invoice version을 모두 같은 KST p_as_of 종료 시점으로 자른다.

v3 exact subject snapshot이 없는 레거시는 unavailableCount에 포함하고 projection에서 제외한다. 계약 목록에서는 계속 조회된다. p_axisunit/member 외 값이면 service_charge_projection_axis_invalid다.

record_service_charge_receipt_v1

sql
public.record_service_charge_receipt_v1(
  p_company_id uuid,
  p_office_id uuid,
  p_installment_id uuid,
  p_expected_outstanding_amount bigint,
  p_amount bigint,
  p_received_on date,
  p_method text,
  p_payer_name text,
  p_memo text,
  p_request_key text
) returns jsonb
  • operator 권한과 상품 entitlement를 확인한다.
  • installment를 for update로 잠근다.
  • 더 최신 invoice version이 있으면 service_charge_receivable_superseded.
  • 서버 현재 잔액과 p_expected_outstanding_amount가 다르면 service_charge_receivable_revision_conflict.
  • 금액이 현재 잔액보다 크면 service_charge_receipt_overallocation.
  • 성공 시 receipt와 allocation을 추가하고 갱신된 position 및 recordedReceiptId를 반환한다.

reverse_service_charge_receipt_v1

sql
public.reverse_service_charge_receipt_v1(
  p_company_id uuid,
  p_office_id uuid,
  p_receipt_id uuid,
  p_expected_outstanding_amount bigint,
  p_reason text,
  p_request_key text
) returns jsonb
  • receipt와 installment를 잠그고 expected balance를 다시 검증한다.
  • 이미 reversal이 있으면 service_charge_receipt_already_reversed.
  • 원행을 변경하지 않고 reversal을 추가한 뒤 갱신된 position과 reversalId를 반환한다.

두 command RPC 모두 요청키 exact replay를 mutation 검증보다 먼저 수행한다. 클라이언트는 사용자 의도 1회마다 새 요청키를 만들고 네트워크 재시도에는 같은 키·같은 payload를 사용해야 한다.


5. 인증·RLS·Data API

  • private.can_read_service_charge_office(office_id): 활성 Office, 상품 entitlement, 활성 Office/Company membership 확인
  • private.can_manage_service_charge_office(office_id): 위 조건에 더해 Office office_admin/operator/accountant 또는 Company owner/contract_admin/company_admin 역할 요구
  • 네 public 테이블 모두 RLS 활성화, authenticated에는 read policy만 제공
  • authenticated의 테이블 직접 write는 revoke되어 있다. insert는 security-definer RPC만 사용한다.
  • private.service_charge_collection_commands는 Data API에 노출하지 않는다.
  • 원장 RPC 세 개와 projection RPC만 authenticated/service_role에 execute를 grant한다.

Company와 Office를 동시에 조건에 넣고 composite FK로 묶어 교차 Company 참조를 막는다. 클라이언트가 보낸 product key는 권한 근거가 아니며 서버는 항상 'service-charge'를 직접 확인한다.


6. 플랫폼 outbox와 선택적 연결

수납 기록과 정정은 각각 다음 provider-neutral 이벤트를 enqueue한다.

event typeschema
service_charge.receipt_recordedservice-charge.receipt-recorded.v1
service_charge.receipt_reversedservice-charge.receipt-reversed.v1

Office에 accounting 상품이 있으면 accounting-source-event-v1 delivery를 요청한다. accounting이 없거나 delivery 요청이 실패해도 service-charge 원장 커밋은 유지된다. 따라서 accounting은 느슨한 선택 연결이며 Wave25A의 수납 성공 조건이 아니다.

고지·문자·메일·은행 대사 같은 외부 전달/수신은 이 마이그레이션의 outbox 계약에 포함되지 않는다.


7. 테스트와 스모크

파일검증 범위
src/composables/__tests__/serviceChargeCollectionLedgerMigration.spec.jsproduct owner, 네 테이블, append-only, v1/v2 경계, RPC·RLS·grant·outbox 문자열 계약
supabase/tests/service_charge_receivable_receipt_ledger_smoke.sql실제 DB 권한·물질화·조회·명령·정정·outbox
src/composables/__tests__/serviceChargeSubjectProjectionMigration.spec.jsv3 identity·KST cutoff·grant 정적 계약
supabase/tests/service_charge_subject_projections_smoke.sqlv3 2공간 원자, 공간/멤버 합계, 일부수납, 권한·axis guard

스모크는 transaction 뒤 rollback하며 다음을 검증한다.

  • v2 실제 청구 확정 → installment 물질화
  • accounting 상품 없이도 목록 조회·수납·정정 성공
  • 동일 요청 retry 응답 동일, 다른 payload 충돌
  • 초과 수납·이중 정정 방지
  • authenticated 직접 write 차단과 교차 Company RLS
  • append-only update 차단
  • command ledger 2건 및 outbox event 2건
  • accounting 미구독 Office에는 delivery 행이 생기지 않음

8. 다음 범위

  • 연체료 원장·충당 순서
  • 은행 입금 대사·자동 매칭
  • 고지/미납 안내·외부 채널 delivery
  • 다중 installment에 대한 한 receipt 분할 배분 UI/명령

이 기능을 추가할 때도 service-charge가 원장을 소유하고 다른 상품은 outbox/공개 계약으로만 연결한다.