# 도메인·호스팅 인트라넷 데이터 모델 합의문 - 상태: 합의됨 - 날짜: 2026-10-06 - 서명: Grok, Claude Code, ChatGPT 게스트가 같은 문안에 서명했다. 12절은 같은 세 명이 2026-10-06에, 13절부터 31절까지는 2026-10-07에, 32절부터 35절까지는 2026-10-08에 추가로 서명했다 - 함께 읽을 문서: [처음 보는 사람을 위한 설명](02-처음-보는-사람을-위한-설명.md) 이 문서는 구현 기준이다. 합의문과 충돌하는 이전 초안이 있으면 이 문서를 따른다. ## 1. 풀려는 문제 기존 시스템은 도메인 행의 id를 옵션의 부모로 썼다. ```text domains.id = 123 domain_options.domain_id = 123 ``` 그 구조에서 실제로 생긴 장애는 다음이다. - 같은 도메인에 같은 옵션이 여러 번 붙는다. - 옵션 가격을 바꾸면 과거 주문 금액까지 흔들린다. - 갱신 때 어떤 옵션이 포함됐는지 주문서에서 찾을 수 없다. - 이전과 명의변경 때 옵션이 도메인을 따라간다. - 주문 당시의 옵션과 지금 켜져 있는 옵션이 구분되지 않는다. - 도메인의 레지스트리 상태와 고객이 산 서비스의 상태가 한곳에 섞인다. - 환불할 때 어디까지가 범위인지 도메인 id만으로는 알 수 없다. ## 2. 원칙 1. 지금 상태와 거래 당시 상태를 다른 테이블에 둔다. 2. 상품(Product)과 고객이 보유한 서비스(Service)는 다르다. 3. 주문 기록과 현재 사용 중인 서비스는 한 테이블에 두지 않는다. 4. 도메인은 자원이다. 주문이나 옵션의 부모가 아니다. 5. 주문 당시의 상품명, 단가, 기간, 원가는 주문 라인에 스냅샷으로 남고, 결제 이후 고치지 않는다. 한 줄로 쓰면 이렇다. ```text Product → Order → Order Item → Service → Resource ``` 주문 라인과 서비스는 1:1이 아니다. 등록 라인이 서비스를 만들고, 이후의 갱신·변경·차감 라인은 같은 `service_id`를 가리킨다. ## 3. 네 겹 | 겹 | 질문 | 테이블 | 고칠 수 있나 | |---|---|---|---| | 카탈로그 | 지금 무엇을 얼마에 파나 | `products`, `prices` | 앞으로의 판매만 바뀐다 | | 거래 | 그때 무엇을 얼마에 청구했나 | `orders`, `order_items` | 결제 후 금액과 기간은 불변 | | 권리 | 이 고객이 지금 무엇을 쓸 수 있나 | `services` | 상태, 수량, 다음 만료만 바뀐다 | | 자원 | 레지스트리와 서버에 실제로 무엇이 있나 | `domains`, `hosting_accounts`, `certificates`, `dns_zones` 와 메일·서버·DB·보안 식별 행 | 개통과 동기화만 갱신한다 | 유료 항목의 현재 권리는 서비스다. 도메인 행에 옵션을 매달지 않는다. ## 4. 관계 ```mermaid erDiagram customers ||--o{ orders : places customers ||--o{ services : holds orders ||--|{ order_items : contains products ||--o{ order_items : "sold as" products ||--o{ prices : priced order_items }o--o| services : "creates or extends" services ||--o{ services : "addon child" services ||--o| domain_services : "when kind=domain" domain_services }o--|| domains : links services ||--o| hosting_services : "when kind=hosting" hosting_services }o--|| hosting_accounts : links hosting_accounts }o--|| hosting_servers : runs_on services ||--o| ssl_services : "when kind=ssl" ssl_services }o--|| certificates : links services ||--o| mail_services : "when kind=mail" services ||--o| server_services : "when kind=server" services ||--o| dbms_services : "when kind=dbms" services ||--o| security_services : "when kind=security" services ||--o| dns_zones : "when kind=dns" domains ||--o| dns_zones : "sponsored zone" dns_zones ||--o{ dns_records : contains dns_zones ||--o| zone_publish : publishes payments ||--o{ payment_allocations : splits order_items ||--o{ payment_allocations : settled_by order_items ||--o{ refund_lines : refunded_by refunds ||--|{ refund_lines : contains customers ||--o{ customer_balance_entries : ledger order_items ||--o| domain_reservations : "may hold" order_items ||--o{ provisioning_jobs : provisions domains ||--o{ registry_events : "matched later" ``` `order_items.target_id` 같은 다형 FK는 쓰지 않는다. 자원 연결은 `domain_services`, `hosting_services`, `ssl_services`, `mail_services`, `server_services`, `dbms_services`, `security_services`, `dns_zones`를 사용한다. ## 5. 테이블 금액은 `bigint` 원 단위다. 통화는 `char(3)`이고 기본은 `KRW`다. 상품과 주문, 도메인은 물리 삭제하지 않는다. 거래 테이블의 FK는 `ON DELETE RESTRICT`다. 아래 컬럼은 합의를 구현하는 최소 집합이다. `created_at` 같은 공통 감사 시각은 모든 테이블에 둔다. ### 5.1 카탈로그 ```text products id code unique -- COM_REG, WHOIS_PRIVACY product_type -- domain | hosting | ssl | addon -- | mail | server | dbms | security | dns name billing_model -- one_time | recurring status -- active | retired tld_id null prices id product_id → products action -- register | renew | transfer | restore | upgrade -- | addon_add | owner_change | usage period_unit -- year | month | once period_count currency cost_amount sell_amount effective_from effective_to null ``` 등급 단가가 생기기 전에는 `price_books`를 두지 않는다. 프리미엄 도메인의 실제 원가는 카탈로그가 아니라 그 주문의 `order_items.cost_amount`에 고정한다. ### 5.2 주문 주문 헤더의 개통 상태는 저장하지 않는다. 30줄 중 2줄만 실패할 수 있으므로 진실은 라인에 있고, 헤더 상태는 라인에서 파생한다. ```text orders id order_no unique -- 고객에게 보이는 청구 번호 customer_id → customers source -- customer | staff | system currency status -- 파생값. 아래 규칙 ordered_at idempotency_key null unique -- 시스템 갱신 배치의 중복 방지 order_items id order_id → orders parent_order_item_id null → order_items product_id → products service_id null → services -- 등록·명의변경은 실행 때 채움. 갱신은 처음부터 있음 action -- register | transfer_in | renew | restore -- | add | change | owner_change | credit | usage status -- pending | paid | fulfilled | failed -- | cancelled | refunded product_name -- 스냅샷 period_unit period_count period_start null period_end null quantity unit_price -- 스냅샷 supply_amount -- 공급가 스냅샷 vat_amount -- 부가세 스냅샷 cost_amount -- 원가 스냅샷 cost_currency amount -- 이 라인이 정산되어야 할 금액 settled_amount -- 아래 공식. 저장 컬럼 currency requested_name null -- 개통 전 도메인명. 소문자 A-label from_product_id null → products -- 상품이 바뀔 때만 from_service_id null → services -- action=owner_change 일 때만. 옛 서비스 to_customer_id null → customers -- action=owner_change 일 때만. 받는 고객 auth_code null -- transfer_in 진행 중에만. 완료·취소 시 null usage_id null → service_usage -- action=usage 일 때만. 16절 config jsonb null -- OS, 네임서버 등 무과금 변경의 요청 스냅샷 price_id null → prices ``` `settled_amount` 공식: ```text settled_amount = Σ payment_allocations.amount (이 라인) + Σ |customer_balance_entries.amount| (이 라인의 order_item_id 이고 amount < 0) ``` `confirmed` 가 아닌 배분은 24절이 뺀다. 예치금을 라인에 쓰면 `payment_allocations` 행을 만들지 않는다. `amount` 가 0인 줄은 생성 즉시 `paid` 다. `amount` 가 0보다 큰 줄은 `settled_amount = amount`가 되는 순간 `paid` 가 되고, 그때만 개통한다. 같은 주문의 미정산 라인은 `pending`으로 남는다. `orders.status` 파생: | 조건 | 헤더 상태 | |---|---| | 모든 라인이 `cancelled` | `cancelled` | | 정산된 라인이 없고 취소도 아님 | `awaiting_payment` | | 일부 라인만 `fulfilled` | `partially_fulfilled` | | 정산된 라인이 있고 아직 개통이 남음 | `paid` | | 모든 살아 있는 라인이 `fulfilled` | `fulfilled` | 조건이 겹치면 24절의 순서로 하나만 고른다. `paid` 이후 `product_name`, `unit_price`, `supply_amount`, `vat_amount`, `cost_amount`, `amount`, `period_start`, `period_end`는 트리거로 수정을 막는다. 정정은 차감 라인(`action=credit`)이나 환불로 한다. ### 5.3 돈 청구 번호는 `orders.order_no`다. 부가세 분해, 예치금과 `action=credit`의 구분, 세금계산서와 현금영수증은 13절이다. ```text payments id customer_id → customers method -- card | bank_transfer status -- pending | confirmed | failed amount currency pg_tid null unique payment_method_id null → payment_methods -- 저장된 카드로 낼 때만. 16절 paid_at null payment_allocations id payment_id → payments order_item_id → order_items amount customer_balance_entries id customer_id → customers amount -- 과입금·환불 적립은 양수, 사용은 음수 payment_id null → payments order_item_id null → order_items refund_id null → refunds reason -- 고객별 잔액 합은 0 이상. 앱 또는 트리거 -- 한 행이 payment_id, order_item_id, refund_id 중 원인을 하나만 가진다 refunds id customer_id → customers payment_id null → payments status -- requested | approved | rejected | completed method -- balance | bank | card amount reason requested_by approved_by null refund_lines id refund_id → refunds order_item_id → order_items amount ``` 입금이 라인 합계보다 크면 라인에는 `amount`까지만 배분하고, 나머지는 `payment_id`만 있는 양수 예치금 행으로 남긴다. 그 잔액은 다음 갱신 라인에 음수 행으로 사용한다. 환불 FK는 `refund_lines.order_item_id`로 유지한다. 증빙 문서도 주문 헤더가 아니라 주문 줄을 가리킨다. ### 5.4 서비스 ```text services id customer_id → customers -- 생성 후 불변. 명의변경은 새 행 product_id → products -- 현재 상품. change 때 여기만 바뀜 parent_service_id null → services kind -- domain | hosting | ssl | addon -- | mail | server | dbms | security | dns status -- pending | active | past_due | suspended -- | expired | terminated | failed quantity -- 기본 1. 추가 디스크 등 auto_renew cancel_at null -- 해지 예약 시각. 상태값으로 대체하지 않음 suspend_reasons text[] -- unpaid | abuse | legal | customer recurring_amount null -- null 이면 다음 갱신 때 prices 를 따른다 discount_percent null -- 1..99. 27절 discount_until null -- 이 시각 앞의 주문에만 할인을 쓴다. 27절 currency started_at null next_due_at null -- 다음 청구 시각. expires_at 과 다를 수 있다 expires_at null -- 지금 보유한 권리의 끝 included_traffic_bytes null -- hosting·server 만. null 이면 측정하지 않음 suspended_at null terminated_at null unique (id, kind) check (kind <> 'addon' or parent_service_id is not null) check ((status = 'suspended') = (suspend_reasons <> '{}')) check (discount_percent is null or (discount_percent >= 1 and discount_percent <= 99)) check (discount_percent is null or recurring_amount is null) check (discount_until is null or discount_percent is not null or recurring_amount is not null) ``` 서비스의 `kind` 는 그 상품의 `product_type` 과 같다. 자식 서비스를 만드는 기준은 하나다. 자기 가격이 있고, 부모를 해지하지 않은 채 그 항목만 해지하고 환불할 수 있으면 자식 서비스다. | 항목 | 두는 곳 | |---|---| | WHOIS Privacy, Premium DNS, 유료 추가 디스크 | 자식 `services`. 수량은 `quantity` | | OS 템플릿 | `order_items.config` 스냅샷. 현재값은 `hosting_accounts.os_template` | | 네임서버, DNS 레코드, 파킹, URL 포워딩 | 요청은 `order_items.config`. 현재값은 15절 | | 자동 연장 | `services.auto_renew`. 상품이 아니다 | | 도메인에 포함된 기본 DNS | 도메인 서비스가 활성화될 때의 자원. 옵션 행이 아니다 | `services (id, kind)`를 서브타입 테이블이 복합 FK로 참조한다. 호스팅 서비스가 `domain_services`에 들어가지 못한다. ### 5.5 도메인 자원 도메인 행은 레지스트리 객체가 아니라 우리가 스폰서인 한 구간이다. transfer out 뒤에 다시 들어오면 ROID가 같아도 새 행이다. 내부 명의변경은 같은 `domains.id`다. ```text domains id domain_name -- 소문자 A-label. 전역 유니크가 아님 tld_id → tlds roid null -- 유니크 키가 아님 registry_account_id lifecycle_status -- active | expired_grace | redemption -- | pending_delete | pending_transfer -- | transferred_out | deleted epp_statuses text[] -- clientHold 등. lifecycle 과 별개 registrant_lock_until null -- registrant 변경 성공 시각부터 60일 registered_at null expires_at -- 레지스트리 만료 registrar_renew_policy -- follow_service | always | never registry_autorenewed_at null predecessor_domain_id null → domains -- customer_id 없음 domain_services service_id pk → services kind -- 항상 domain. 복합 FK (service_id, kind) domain_id → domains linked_at unlinked_at null domain_reservations id requested_name -- 소문자 A-label order_item_id → order_items expires_at -- TTL released_at null ``` 연락처는 고객 계정이 아니라 그 도메인에 등록된 registrant, admin, tech, billing 이다. 컬럼과 변경 절차는 14절이다. ### 5.6 호스팅과 SSL ```text hosting_servers id, hostname, panel_type, status hosting_accounts id server_id → hosting_servers panel_account_id username status os_template null -- 무과금 구성의 현재값 hosting_services service_id pk → services -- kind=hosting 복합 FK hosting_account_id → hosting_accounts certificates id, common_name, issuer, serial, expires_at, status dcv_method null -- email | dns | http ssl_services service_id pk → services -- kind=ssl 복합 FK certificate_id → certificates ``` 서버 이전은 `hosting_accounts.server_id`만 바꾸고 서비스 id는 유지한다. 이전 사실은 `provisioning_jobs`의 작업 종류로 남긴다. 별도 이전 테이블은 두지 않는다. 유료 상품 변경은 `action=change` 라인에 스냅샷을 남기고 `services.product_id`만 새 상품으로 바꾼다. ### 5.7 개통과 레지스트리 수신 ```text provisioning_jobs id order_item_id → order_items idempotency_key unique job_type -- epp_create | epp_renew | epp_delete -- | epp_update_contact | epp_update_ns -- | epp_update_status | epp_transfer_reject -- | epp_authinfo | epp_update_ds -- | epp_transfer | zone_sync -- | privacy_on | privacy_off | migrate status attempts last_error null registry_events id registry_account_id message_id domain_id null → domains -- 매칭 전이면 null event_type payload jsonb processed_at null unique (registry_account_id, message_id) ``` 고객 결제로 생긴 개통은 주문 트랜잭션 밖에서 돈다. EPP가 실패한 재시도는 새 주문 라인을 만들지 않고 같은 `provisioning_jobs` 행의 `attempts`를 올린다. 도메인 renew 라인의 이행 조건은 "EPP renew 명령이 성공했는가"가 아니다. `domains.expires_at >= order_items.period_end`이면 그 라인은 EPP 없이 `fulfilled`다. 부족한 연수만 renew 한다. 레지스트리가 먼저 자동연장한 뒤 같은 기간에 renew를 한 번 더 넣으면 2년이 연장되므로, 그 판단에 `registry_autorenewed_at`을 쓴다. ## 6. 제약 PostgreSQL 부분 유니크 인덱스를 쓴다. MySQL은 같은 조건을 생성 컬럼의 UNIQUE로 흉내 낸다. 미입금 라인은 아래 슬롯을 잡지 못한다. ```sql create unique index uq_open_addon on services (parent_service_id, product_id) where parent_service_id is not null and status in ('pending', 'active', 'past_due', 'suspended'); create unique index uq_paid_period on order_items (service_id, period_start) where action in ('register', 'renew', 'transfer_in') and status in ('paid', 'fulfilled') and period_start is not null; create unique index uq_paid_register_name on order_items (requested_name) where action in ('register', 'transfer_in') and status = 'paid'; create unique index uq_live_domain_name on domains (domain_name) where lifecycle_status not in ('transferred_out', 'deleted'); create unique index uq_current_domain_link on domain_services (domain_id) where unlinked_at is null; create unique index uq_open_reservation on domain_reservations (requested_name) where released_at is null; create unique index uq_current_domain_contact on domain_contacts (domain_id, role) where linked_at is not null and unlinked_at is null; create unique index uq_pending_domain_contact on domain_contacts (domain_id, role) where linked_at is null; create unique index uq_domain_nameserver on domain_nameservers (domain_id, host_name); create unique index uq_auto_payment_notice on payment_notice_mails (service_id, anchor_on, offset_days) where trigger = 'auto'; create unique index uq_open_owner_change on order_items (from_service_id) where action = 'owner_change' and status in ('pending', 'paid'); create unique index uq_zone_domain on dns_zones (domain_id) where domain_id is not null; create unique index uq_zone_service on dns_zones (service_id) where service_id is not null; create unique index uq_open_external_zone_name on dns_zones (zone_name) where service_id is not null and closed_at is null; create unique index uq_default_payment_method on payment_methods (customer_id) where status = 'active' and is_default; create unique index uq_active_billing_key on payment_methods (billing_key_digest) where status = 'active'; create unique index uq_customer_login_id on customers (login_id); create unique index uq_customer_email on customers (email); create unique index uq_usage_period on service_usage (service_id, meter, period_start); create unique index uq_usage_line on order_items (usage_id) where usage_id is not null and status <> 'cancelled'; ``` `domain_name`과 `requested_name`은 소문자 A-label이라는 CHECK를 건다. 한글 도메인은 유니크 비교 전에 퓨니코드로 정규화한다. 이름 선점의 순서: 1. 등록·이전 주문이 결제 대기면 `domain_reservations` 한 행이 이름을 잡는다. 2. TTL이 지나거나 결제가 실패하면 `released_at`을 채운다. 다른 주문이 같은 이름을 선점할 수 있다. 3. 결제가 끝나 라인이 `paid`가 되면 `uq_paid_register_name`이 이름을 잠근다. 선점 행은 해제해도 된다. 4. 개통이 끝나면 `uq_live_domain_name`이 살아 있는 도메인 행을 잠근다. 앱에서만 지킬 규칙: - addon의 부모는 한 단계다. 손자 서비스는 만들지 않는다. - 자식 `expires_at`은 부모 `expires_at`을 넘지 않는다. 호스팅과 그 자식을 한 날짜로 늘리는 절차는 12절이다. 같은 주문에서 그 부모 줄의 끝까지 옵션을 붙이는 예외는 22절이다. - 한 라인의 환불 합계는 그 라인의 `amount`를 넘지 않는다. - 고객 예치금 잔액은 0 이상이다. - 도메인 서비스가 `active`가 되는 트랜잭션에는 `domain_services` 행이 있다. - 살아 있는 도메인 이름과 같은 열린 외부 존은 만들지 못한다. 열린 외부 존과 같은 이름의 살아 있는 도메인도 만들지 못한다. 네임서버 개수와 글루는 15절이다. ## 7. 해지와 명의변경 | 상황 | 하는 일 | |---|---| | 고객이 도메인 갱신을 그만둠 | 도메인 서비스는 `terminated`로 보내지 않는다. `auto_renew=false`, `cancel_at`만 둔다. 우리가 아직 스폰서이기 때문이다 | | WHOIS만 해지·환불 | 자식 서비스만 `terminated`. 부모 도메인 서비스와 `domains` 행은 유지. `refund_lines`는 그 자식의 주문 라인만 가리킨다 | | 내부 명의변경 | `domains.id` 유지. 기존 `domain_services.unlinked_at`을 채우고, 옛 도메인 서비스와 그 자식 addon을 같은 트랜잭션에서 `terminated`. 새 고객의 서비스를 만들어 다시 연결하고, `expires_at`, `next_due_at`, `auto_renew` 를 복사한다. 레지스트리 연락처와 호스팅은 바꾸지 않는다. 옛 프라이버시는 따라가지 않는다. 기간 복사는 14절, 금액과 받는 고객은 15절 | | transfer out 후 재등록 | 옛 도메인은 `transferred_out`. 재등록은 새 `domains.id` | | 미납 정지와 abuse 정지가 겹침 | `suspend_reasons`에 둘 다 둔다. 입금은 `unpaid`만 뺀다 | | clientHold, 이전 금지 | `domains.epp_statuses`에 둔다. `lifecycle_status` 하나로 합치지 않는다 | `terminated`를 금지하는 대상은 열린 `domain_services`에 연결된 `kind=domain` 서비스뿐이다. 호스팅, 메일, 서버, DBMS, 보안, DNS의 고객 해지 예약도 `auto_renew=false` 와 `cancel_at` 이다. 그 트랜잭션에서 `terminated` 로 만들지 않는다. 도메인 서비스의 고객 해지도 같다. 그 예약이 환불을 만들지 않는 것은 24절이다. ## 8. 시나리오 금액은 설명용이다. ### S1. 도메인 30개를 한 번에 갱신 시스템 주문 1건, `action=renew` 라인 30개. 각 라인은 이미 있는 `service_id`를 가리킨다. 입금 1건을 30개 라인에 `payment_allocations`로 나눈다. 30줄 모두 `settled_amount = amount`이면 30개 개통 작업이 돈다. 도메인 행은 복제하지 않는다. ### S2. 주문 3건을 한 번에 내고 2,000원이 더 입금됨 `payments` 1건. 각 주문의 라인에 필요한 만큼만 `payment_allocations`. 2,000원은 `customer_balance_entries.amount = +2000`, `payment_id`만 채운다. 다음 갱신 때 그 고객의 라인에 `amount = -2000`, `order_item_id`를 채운다. 이 사용은 `payment_allocations`를 만들지 않는다. ### S3. 30개 중 25개 금액만 입금 25개 라인만 `paid` 후 개통한다. 나머지 5개는 `pending`으로 남고 개통 작업을 만들지 않는다. 주문 헤더를 하나의 "결제 완료"로 올리지 않는다. ### S4. WHOIS만 중도 해지하고 일부 환불 프라이버시 자식 서비스를 `terminated`로 둔다. 도메인 서비스와 도메인 행은 그대로다. 환불은 프라이버시를 산 라인(또는 마지막으로 그 자식을 갱신하며 돈을 받은 라인)만 `refund_lines`에 넣는다. 권리가 줄면 기존 라인을 수정하지 않고 `action=credit` 라인을 추가한다. 차감 라인은 기간 유니크에 걸리지 않는다. 그 금액은 25절이다. ### S5. OS는 Ubuntu, 디스크는 40GB 뒤에 60GB OS는 호스팅 라인의 `config`에 남고, 현재값은 `hosting_accounts.os_template`이다. 디스크는 자식 서비스 1행, `quantity`가 40이었다가 `action=change` 라인 이후 60이 된다. 디스크 서비스 행이 두 개면 `uq_open_addon`이 막는다. ### S6. 등록 결제 실패 후 재주문, 갱신 EPP 실패 결제 실패 시 선점의 `released_at`을 채우면 같은 이름으로 새 주문이 가능하다. `paid`가 된 등록 라인은 이름을 잠근다. 갱신 EPP가 실패하면 라인은 `paid`인 채로 남고, 같은 `provisioning_jobs.idempotency_key`로 재시도한다. 두 번째 결제는 없다. ### S7. 레지스트리가 먼저 만료를 늘림 `domains.expires_at`이 이미 `period_end` 이상이면 renew 명령을 보내지 않고 라인을 `fulfilled`로 둔다. 부족한 기간만 EPP renew 한다. ### S8. 명의변경과 재등록 내부 명의변경은 7절 표와 같다. 같은 도메인 행, 새 서비스, 옛 서비스와 옛 프라이버시는 그 트랜잭션에서 종료되고, 남은 기간은 14절대로 복사한다. transfer out 후 다른 고객의 재등록은 새 도메인 행이다. 옛 주문과 옛 서비스는 옛 도메인 행을 계속 가리킨다. ## 9. 만들지 않는 것 | 제안되었던 것 | 결정 | |---|---| | `domain_options` | 만들지 않는다. 옵션의 부모가 도메인 id가 된다 | | `order_item_options` | 만들지 않는다. 유료 항목은 주문 라인, 무과금 구성은 `config` | | `service_entitlements` | 만들지 않는다. 유료 현재 상태는 자식 서비스 | | `service_periods` | 만들지 않는다. 기간 원장은 이행된 주문 라인 | | `invoices`, `invoices.order_id` | 두지 않는다. 증빙은 `tax_documents`이고 주문 헤더 FK는 없다 | | 국세청 전송 클라이언트 | 두지 않는다. 승인번호는 외부 결과를 채운다 | | 도메인 메일 포워딩, 메일 계정 전달 규칙 | 테이블을 두지 않는다. `kind=mail` 서비스는 15절 | | SMTP 업체 지정 | 두지 않는다. 발송 결과는 `payment_notice_mails.status` 에 남긴다 | | `payments.method=credit` | 두지 않는다. 예치금 사용은 음수 잔액 행이다 | | `orders.provisioning_status` | 두지 않는다. 라인 상태에서 파생한다 | | `domains.customer_id` | 두지 않는다. 소유자는 서비스다 | | `order_items.target_id` | 두지 않는다 | | 복식부기 원장 | 두지 않는다 | | `price_books` | 등급 단가가 생기기 전에는 두지 않는다 | | `hosting_migrations` | 두지 않는다. 작업 이력으로 충분하다 | ## 10. 이 합의가 정하지 않은 것 직원, 권한, 고객 문의, 일반 감사 로그는 이 문서의 고객·주문·서비스·도메인에 FK를 걸면 된다. 테이블 모양은 이번 합의에 없다. 감사 로그는 "누가 무엇을 바꿨는가"이고, `registry_events`는 "레지스트리가 무슨 사건을 보냈는가"다. 둘을 한 테이블로 합치지 않는다. ## 11. 상태 조합 서비스 상태와 도메인 상태는 일부러 어긋날 수 있다. | 서비스 | 도메인 | 의미 | |---|---|---| | `past_due` | `active` | 청구는 밀렸고 레지스트리 만료는 아직 아니다 | | `active`, `cancel_at` 설정 | `active`, `clientHold` | 해지 예약과 레지스트리 잠금은 동시에 성립한다 | | `active` | `expires_at`이 서비스보다 김 | 레지스트리 자동연장이 고객 결제보다 앞섰다 | | `expired` | `redemption` | 권리는 끝났고 복구 기간이 남아 있다. 복구는 `action=restore`이며 새 도메인 행이 아니다 | | `terminated` (unlink 됨) | `active` (새 서비스에 연결) | 내부 명의변경이 끝난 뒤의 옛 서비스 | ## 12. 호스팅과 옵션의 만료일 맞춤 kind=hosting 인 부모와 그 자식 addon 에만 적용한다. 도메인과 그 자식은 기존 규칙을 유지한다. 새 action, 새 테이블, 새 상태값은 없다. 줄은 `action=renew` 이거나, 옵션을 처음 붙일 때만 `action=add` 다. `order_items.period_unit` 은 `year`, `month`, `day` 를 가질 수 있다. `day` 는 주문 라인에만 있고 `prices.period_unit` 은 `year`, `month`, `once` 그대로다. ### 12.1 기준일 한 주문의 기준일 anchor 는 그 주문에서 부모 renew 라인의 `period_end` 다. 부모 라인이 없으면 부모의 현재 `expires_at` 이다. 라인은 `expires_at` 이 anchor 보다 이른 서비스에만 만든다. anchor 와 같은 서비스는 라인을 만들지 않는다. anchor 보다 늦은 서비스가 하나라도 있으면 주문을 만들지 않는다. 권리를 줄이는 일은 `action=credit` 과 환불이다. 그 환불 금액은 23절이다. 비례 금액이 0이면 라인을 만들지 않는다. ### 12.2 기간 renew 의 `period_start` 는 그 서비스의 현재 `expires_at` 이고 `period_end` 는 anchor 다. 같은 주문의 자식 renew 들은 `period_end` 가 서로 같고, 부모 라인이 있으면 그 `period_end` 와 같다. 부모 라인이 없으면 자식 `period_end` 는 부모의 현재 `expires_at` 과 같다. add 의 `period_start` 는 주문 생성 때 확정한다. Asia/Seoul 주문 생성일의 날짜에 부모 `expires_at` 의 시각을 붙인다. 개통이 늦어도 바꾸지 않는다. add 의 기본 `period_end` 는 부모의 현재 `expires_at` 이다. 그보다 이른 끝은 허용하고, 그보다 늦은 끝은 금지한다. 기간의 진실은 `period_start` 와 `period_end` 다. 같은 주문의 부모 register 또는 renew 줄의 `period_end` 를 add 의 끝으로 쓰는 예외는 22절이다. renew 가 `paid` 로 바뀌는 트랜잭션에서 `period_start` 는 그 서비스의 `expires_at` 과 같아야 한다. 다르면 정산하지 않고 `cancelled` 로 둔다. add 는 이 비교를 하지 않는다. ### 12.3 금액 `price_id` 는 renew 이면 `prices.action=renew`, add 이면 `prices.action=addon_add` 이고 `period_unit` 이 `month` 또는 `year` 다. `once` 로는 맞출 수 없다. 개월 수 M 은 `month` 이면 그 행의 `period_count`, `year` 이면 `period_count * 12` 다. `add_months` 는 Asia/Seoul 달력이다. 대상 월에 그 일이 없으면 그 달의 말일이고, 시각은 유지한다. `period_end` 가 `add_months(period_start, N)` 이면 정수 나눗셈이다. ```text R(P) = (N * P + M/2) / M ``` 그렇지 않으면 whole 은 `add_months(period_start, whole) <= period_end` 인 가장 큰 정수다. boundary 는 그 시각이다. num 은 `period_end - boundary` 의 초, den 은 `add_months(boundary, 1) - boundary` 의 초다. ```text R(P) = (whole * den * P + num * P + M * den / 2) / (M * den) ``` add 는 `recurring_amount` 를 쓰지 않는다. renew 도 `recurring_amount` 가 null 이면 P 는 `sell_amount`(수량 1)이고 `unit_price = R(P)`, `amount = unit_price * quantity` 다. `discount_percent` 가 있고 27절의 기한 안이면 P 는 27절의 할인 단가다. renew 이고 `recurring_amount` 가 null 이 아니면, 그 값은 현재 수량을 포함한 `price_id` 한 기간(M개월)의 금액이다. `amount = R(recurring_amount)` 이고 quantity 를 다시 곱하지 않는다. `quantity` 는 `services.quantity` 스냅샷이다. `unit_price` 는 `sell_amount` 에 같은 비례를 적용한 수량 1 금액이며 `amount` 를 다시 계산하지 않는다. 달력 월로 떨어지면 라인은 `period_unit=month`, `period_count=N` 이다. 아니면 `period_unit=day`, `period_count` 는 Asia/Seoul 날짜 차이다. `supply_amount` 와 `vat_amount` 는 13절의 절사로 그 `amount` 를 나눈다. ### 12.4 이행 자식 라인을 판정하는 트랜잭션은 부모 서비스 행을 잠그고, 같은 트랜잭션에 부모 renew 라인이 함께 `paid` 이면 그 부모 라인을 먼저 이행한다. 그 다음 부모 `expires_at` 이 자식 `period_end` 이상이면 자식을 `fulfilled` 한다. 같은 주문의 부모 라인이 아직 `pending` 이어도 이 비교가 참이면 이행한다. 이 이행은 그 서비스의 `expires_at` 과 `next_due_at` 을 `period_end` 로 둔다. 다른 사건으로 두 시각은 다시 달라질 수 있다. 같은 주문의 부모 라인이 이행 전에 `cancelled` 또는 `refunded` 이고, 그때 부모 `expires_at` 이 자식 `period_end` 보다 이르면 미이행 자식을 환불한다. 부모 `expires_at` 시각이 지났는데도 부모 `expires_at` 이 자식 `period_end` 보다 이르면, 만료 처리 트랜잭션이 미이행 자식을 먼저 환불한다. `pending` 인 이 절의 renew 라인은, 그 서비스의 `expires_at` 이 `period_start` 와 달라지면 `cancelled` 로 둔다. 이 취소만으로는 자식을 환불하지 않는다. `paid` 이고 아직 `fulfilled` 나 `refunded` 가 아니며 `period_end` 가 서비스 `expires_at` 보다 큰 라인이 있으면, 그 서비스는 자기 `expires_at` 이 지났다는 이유만으로 `expired`, `suspended`, `terminated` 가 되지 않는다. 부모 `expires_at` 을 자식 `expires_at` 보다 이르게 만드는 credit 은, 같은 트랜잭션에서 자식도 그 끝 이하로 credit 하거나 부모 credit 을 거부한다. 호스팅 계정 행과 패널은 이 이행에서 바꾸지 않는다. ### 12.5 이후 맞춤 주문은 `auto_renew` 와 `recurring_amount` 를 바꾸지 않는다. 다음 자동 연장의 기간은 맞춤 라인의 `period_unit`, `period_count` 가 아니라, 그때 고른 `prices` 행의 `period_unit`, `period_count` 다. 금액은 `recurring_amount` 가 null 이 아니면 그 값이고, null 이면 그 `prices` 행이다. 그 기간이 부모와 자식에서 같으면 다음 자동 연장 뒤에도 만료일이 같다. 부모만 연장되면 만료일은 다시 어긋날 수 있다. 자동 renew 가 고르는 가격 행과 자식 끝의 상한은 24절이다. ### 12.6 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | S-A | 호스팅 2027-06-01, 디스크 2027-03-01, anchor 2028-06-01 | 주문 1건, renew 2줄. 호스팅 12개월은 정가. 디스크가 월가격이면 15 × 단가 × quantity. `recurring_amount` 가 수량 포함 1년 48000 이면 `amount` 는 60000 이고 quantity 를 다시 곱하지 않는다 | | S-B | 디스크만 부모의 현재 만료에 붙임 | 부모 라인 없음. 부모 행을 잠그고 디스크는 바로 `fulfilled` | | S-C | anchor 2027-04-01, 호스팅 만료 2027-06-01 | 주문을 만들지 않는다 | | S-D | S-A 에서 디스크만 입금되고 호스팅 라인은 `pending`, 호스팅 만료는 anchor 보다 이르다 | 디스크는 `paid` 이고 `fulfilled` 가 아니다 | | S-D2 | 그 사이 다른 주문이 호스팅을 anchor 까지 연장 | 디스크는 `fulfilled`. 이 주문의 부모 라인은 `cancelled`. 디스크는 환불하지 않는다 | | S-E | 호스팅 라인이 이행 전에 `cancelled` 이고 호스팅 만료가 anchor 보다 이르다 | 디스크 라인은 환불 | | S-F | 맞춤 이후 자동 연장 | 맞춤 라인의 15개월이나 `day` 를 다음 기간으로 쓰지 않는다. 카탈로그 기간이 같으면 만료일이 유지된다 | | S-G | 그 뒤 호스팅만 연장 | 만료일이 다시 갈라져도 된다 | | S-H | 부모를 자식보다 짧게 credit | 자식을 먼저 그 끝 이하로 credit 하거나 부모 credit 을 거부한다. 자식이 부모 만료를 넘는 이행은 없다 | | S-I | 주문 생성일이 호스팅 만료의 3달력월 전 같은 일 | add 한 줄. 그 시각부터 호스팅 만료까지, 3개월 비례, 서비스 행은 하나. 개통이 늦어도 `period_start` 는 그대로다 | ## 13. 부가세, 예치금, 세금계산서, 현금영수증 `amount` 는 부가세 포함 공급대가다. `vat_amount` 는 `amount` 의 부호를 유지한 채 `|amount|` 를 11 로 나눈 몫이다. 원 미만은 절사다. `supply_amount = amount - vat_amount` 이므로 둘의 합은 `amount` 다. 0원이면 둘 다 0이다. 12절 비례의 반올림은 그대로 두고, 그 결과 `amount` 에 이 절사를 적용한다. 공급가와 부가세를 각각 비례하지 않는다. 문서 헤더의 세 금액은 자기 줄의 합이고, 헤더 합계에 11 나눗셈을 다시 하지 않는다. 예치금은 `customer_balance_entries` 뿐이다. 예치금을 쓰는 일은 음수 예치금 행이고 결제 행이 아니다. `refunds.method=balance` 는 환불금을 예치금 양수 행으로 넣으며 그 행은 `refund_id` 를 가진다. `action=credit` 은 권리를 줄이는 주문 줄이다. 예치금 행을 만들지 않고, 돈을 돌려주는 일은 `refunds` 다. ### 13.1 고객 세금 프로필 회원 표시 칸은 16절이다. 이 프로필과 서로 복사하지 않는다. ```text customer_tax_profiles customer_id pk → customers buyer_kind -- individual | business legal_name business_no null representative_name null address null email null business_type null -- 업태 business_item null -- 종목 evidence_preference -- none | tax_invoice | cash_receipt cash_receipt_purpose null -- personal | business cash_receipt_identifier null -- 17절 암호문 ``` `individual` 은 `tax_invoice` 를 고르지 못한다. `tax_invoice` 는 `buyer_kind=business` 이고 `business_no`, `representative_name`, `address`, `email`, `business_type`, `business_item` 이 있을 때만 고를 수 있다. `cash_receipt` 는 `cash_receipt_purpose` 와 `cash_receipt_identifier` 가 있을 때만 고를 수 있다. 식별자의 저장 형식은 17절이다. 프로필이 없어도 결제는 된다. 프로필을 고쳐도 이미 발행된 문서는 바꾸지 않는다. `business_no` 의 자릿수와 다른 사업자번호로의 교체는 20절이다. 현금영수증 식별자의 자릿수와 용도·식별자 교체는 21절이다. ### 13.2 문서 대상 문서 대상 금액은 확정된 `bank_transfer` 배분 합 + 그 라인의 음수 예치금 절대값 - 완료된 환불 중 `method` 가 `bank` 또는 `balance` 인 `refund_lines` 합이다. 한 라인에서 `method` 가 `bank` 또는 `balance` 인 `refund_lines` 누적 합은 그 라인의 확정 `bank_transfer` 배분 합 + 음수 예치금 절대값을 넘지 못한다. `method` 가 `card` 인 `refund_lines` 누적 합은 그 라인의 확정 `card` 배분 합을 넘지 못한다. 환불을 만들 때 이 제약을 검사한다. 그 누적 합에는 `status` 가 `rejected` 가 아닌 환불을 모두 포함한다. 문서 대상에서 빼는 것은 `status=completed` 뿐이다. 그래서 문서 대상은 0 이상이다. `method=card` 인 환불은 문서 대상을 바꾸지 않는다. 카드 배분은 문서 대상이 아니다. 카드 전표는 `payments.pg_tid` 다. 예치금 충전 양수 행은 공급이 아니므로 문서 대상이 아니다. 대상이 0이면 문서를 만들지 않는다. ### 13.3 문서 국세청 전송 클라이언트는 만들지 않는다. 승인번호는 외부 결과가 돌아오면 채운다. ```text tax_documents id customer_id → customers doc_type -- tax_invoice | cash_receipt status -- issued | void supply_amount vat_amount amount written_on -- 생성 시각의 Asia/Seoul 날짜. 이후 불변 approval_no null -- 한 번 채우면 불변 buyer_legal_name buyer_business_no null buyer_representative null buyer_address null buyer_email null business_type null business_item null cash_receipt_purpose null cash_receipt_identifier null -- 17절 암호문. 프로필 암호문의 바이트 복사 adjusts_document_id null → tax_documents tax_document_lines id tax_document_id → tax_documents order_item_id → order_items supply_amount vat_amount amount ``` 한 문서의 헤더 세 금액은 자기 줄의 합이다. 한 문서의 줄은 같은 고객의 라인만 가진다. 줄의 부가세는 그 줄의 `amount` 에 이 절의 절사를 적용한 값이다. `tax_invoice` 는 사업자 스냅샷 여섯 개(`buyer_business_no`, `buyer_representative`, `buyer_address`, `buyer_email`, `business_type`, `business_item`)가 필수이고 현금영수증 식별자는 null 이다. `buyer_legal_name` 도 채운다. `cash_receipt` 는 `cash_receipt_purpose` 와 `cash_receipt_identifier` 가 필수이고 사업자 스냅샷은 null 이다. 세금계산서는 영수다. 청구 세금계산서는 없다. 살아 있는 문서는 `status=issued` 다. `void` 는 발행분 합계에서 빠진다. 한 라인은 살아 있는 세금계산서와 살아 있는 현금영수증에 동시에 들어가지 못한다. ### 13.4 발행과 수정 최초 문서는 라인이 `paid`, `fulfilled`, `refunded` 중 하나이고 문서 대상이 0보다 클 때만 만든다. 고객의 `evidence_preference` 가 그 `doc_type` 과 같아야 한다. 최초 문서에 넣는 그 라인의 `amount` 는 그 시점의 문서 대상과 같다. 한 번 발행된 라인은, 이후 문서 대상이 바뀌는 트랜잭션에서 차이만큼의 새 문서를 같이 넣어 발행분 합계를 문서 대상과 같게 만든다. 차액도 같은 절사로 분해한다. 차액 문서의 `doc_type`, 공급받는자 스냅샷, `cash_receipt_purpose`, `cash_receipt_identifier` 는 원본 문서에서 복사한다. 그 시점의 `customer_tax_profiles` 와 `evidence_preference` 를 읽지 않는다. 기존 문서의 금액은 고치지 않는다. 승인번호가 없는 `issued` 문서는 `void` 로 바꿀 수 있다. 승인번호가 있으면 `void` 하지 못하고 차액 문서로 고친다. 차액 문서는 `adjusts_document_id` 로 그 원본을 가리킨다. ### 13.5 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | S1 | `amount` 9001 | `vat_amount` 818, `supply_amount` 8183 | | S2 | `amount` 11000 | `vat_amount` 1000, `supply_amount` 10000 | | S3 | `amount` 6 또는 5 | `vat_amount` 0, 나머지는 `supply_amount` | | S4 | 12절 결과 `amount` 252040 | `vat_amount` 22912, `supply_amount` 229128. 수량을 부가세에 다시 곱하지 않는다 | | S5 | 카드로만 정산 | 세금계산서도 현금영수증도 없다 | | S6 | 카드 4000, 무통장 6000 | 문서 줄 `amount` 6000, `vat_amount` 545, `supply_amount` 5455. 라인 스냅샷의 부가세와 달라도 된다 | | S7 | 과입금 2000 | 양수 예치금에는 문서가 없다. 다음 라인이 그 2000 을 쓰면 그 사용분은 문서 대상이다 | | S8 | 개인이 `tax_invoice` 를 고름 | 프로필을 저장하지 못한다. 사업자번호가 없어도 무통장 라인은 `paid` 가 된다. 그 상태에서는 세금계산서 행을 만들지 못한다 | | S9 | 같은 라인의 두 증빙 | 살아 있는 세금계산서와 살아 있는 현금영수증은 동시에 있지 못한다 | | S10 | 승인번호가 있는 문서를 환불 | 원본 금액은 두고 같은 트랜잭션에 차액 문서를 넣는다. 공급받는자는 원본 스냅샷이다. 승인번호가 없으면 `void` 할 수 있다 | | S11 | `action=credit`, `payments.method=credit` | 예치금 행을 만들지 않는다. `credit` 결제 수단은 저장하지 못한다 | | S12 | 발행 뒤 사업자번호 변경 | 문서의 `buyer_business_no` 는 그대로다 | | S13 | 무통장 6000 에 거절되지 않은 bank 환불 6000 이 있음 | 또 다른 `bank` 또는 `balance` 환불은 만들지 못한다 | ## 14. 메일, 명의변경, 도메인 정보, 기관 이전 회원 표시 칸은 16절이다. `customers.email` 만 입금요청 메일의 수신 주소로 정한다. ### 14.1 무과금 변경 연락처, 네임서버, 레코드, 파킹, URL 포워딩은 `amount` 0 인 `action=change` 다. 들어오는 기관 이전은 `transfer_in`, 계정 이동은 `owner_change` 다. 상품이 바뀌지 않으면 `from_product_id` 는 비운다. 0원 줄은 13절 문서 대상이 0 이라 세금계산서와 현금영수증이 없다. 연락처·네임서버·레코드·파킹·포워딩·기관 이전 작업이 실패하면 줄은 `paid` 인 채 같은 `provisioning_jobs` 를 재시도한다. `owner_change` 는 작업이 없다. 금액이 0일 때와 0보다 클 때의 실행 시점은 15절이다. ### 14.2 입금요청 메일 주소가 null 이면 자동 메일도 수동 재발송도 만들지 못한다. ```text payment_notice_mails id customer_id → customers service_id → services trigger -- auto | manual anchor_on -- next_due_at 의 Asia/Seoul 날짜. -- next_due_at 이 null 이면 expires_at 의 날짜 offset_days null -- auto 는 14 또는 1. manual 은 null to_address subject body status -- queued | sent | failed sent_at null last_error null resend_of_id null → payment_notice_mails ``` 자동 메일은 기준일 14일 전과 1일 전에 한 통씩이다. 대상은 `status` 가 `active` 또는 `past_due` 이고 `cancel_at` 이 null 인 서비스다. `trigger=auto` 인 행은 `(service_id, anchor_on, offset_days)` 가 유일하다. 수동 재발송은 기존 메일의 제목과 본문을 복사한다. `to_address` 는 지금 `customers.email` 이고 `trigger=manual`, `resend_of_id` 는 그 메일이다. 횟수 제한은 없다. SMTP 업체는 정하지 않는다. 워커가 `queued` 를 `sent` 또는 `failed` 로 바꾼다. ### 14.3 계정 이동 도메인 명의변경은 7절을 따른다. 같은 트랜잭션에서 새 도메인 서비스의 `expires_at`, `next_due_at`, `auto_renew` 는 옛 값을 복사한다. `domains.expires_at` 과 레지스트리 연락처는 바꾸지 않는다. 자식 addon 은 종료하고 복사하지 않는다. 호스팅 서비스는 이 트랜잭션에서 바꾸지 않는다. 호스팅을 옮기려면 그 호스팅 서비스의 `owner_change` 를 따로 한다. `hosting_accounts.id` 는 유지한다. 옛 호스팅 서비스와 그 자식 addon 은 `terminated` 되고 자식은 복사되지 않는다. 새 서비스가 같은 계정을 가리킨다. 기간과 `auto_renew` 도 복사한다. 종료되지 않은 호스팅 서비스가 같은 `hosting_account_id` 를 둘 이상 가리키지 못한다. 금액, 받는 고객, `expires_at` 이 비어 있는 dns 의 예외는 15절이다. 도메인과 호스팅의 기간 복사와 자원 유지는 이 절과 7절을 유지한다. ### 14.4 레지스트리 연락처 이 변경은 `services.customer_id` 와 `domains.id` 를 바꾸지 않는다. ```text contacts id customer_id null → customers name -- 영문 name_loc null -- 국문 contact_kind -- individual | organization organization null email phone mobile null street city state null postal_code country_code registry_contact_id null identity_kind null -- birth_date | business_no. 16절 identity_value null -- 평문 검사는 16절, 저장은 17절 암호문 domain_contacts id domain_id → domains role -- registrant | admin | tech | billing contact_id → contacts linked_at null -- null 이면 대기 unlinked_at null ``` 한 도메인의 같은 role 에 `linked_at` 이 있고 `unlinked_at` 이 null 인 현재 행은 하나다. `linked_at` 이 null 인 대기 행도 하나다. 변경은 대기 행을 넣고, EPP 가 성공한 트랜잭션에서만 새 행의 `linked_at` 과 옛 행의 `unlinked_at` 을 같은 시각으로 채운다. 실패하면 대기 행을 지우고 옛 행을 유지한다. 작업은 `epp_update_contact` 다. `contact_kind` 가 `organization` 이면 `organization` 이 필수다. `individual` 이면 `organization` 은 null 이다. registrant 변경이 성공한 뒤의 이전 잠금은 15절이다. ### 14.5 네임서버, 레코드, 파킹, URL 포워딩 ```text our_nameservers host_name pk domain_nameservers id domain_id → domains host_name -- 소문자 sort_order ipv4 null ipv6 null ``` 레코드와 파킹의 현재값은 도메인 id가 아니라 15절의 존에 둔다. `dns_records.domain_id` 와 `domain_publish` 는 두지 않는다. 네임서버 행은 이 표대로 `domains` 에 둔다. 개수, 글루, 외부 네임서버, DNAME, 타사 존의 절차는 15절이다. ### 14.6 기관 이전 들어오는 `transfer_in` 줄은 완료 전까지 `auth_code` 가 필수다. 완료 전에는 성공 트랜잭션이나 취소 트랜잭션이 아니면 `auth_code` 를 null 로 만들지 않는다. 취소 트랜잭션은 `auth_code` 를 null 로 지운다. `registry_events.payload` 에는 `auth_code` 를 넣지 않는다. 성공은 한 트랜잭션이다. 새 `domains.id` 를 만들어 `lifecycle_status=active` 로 두고 `expires_at` 을 채우고 `domain_services` 를 연결하고 줄을 `fulfilled` 로 두고 `auth_code` 를 null 로 지운다. 하나라도 실패하면 새 도메인, 새 연결, `fulfilled`, `auth_code` 삭제를 남기지 않는다. 진행 중에는 도메인 행을 만들지 않는다. 작업은 `epp_transfer` 다. 나가는 이전이 완료되면 `lifecycle_status=transferred_out`, `unlinked_at` 을 채우고, 도메인 서비스와 자식 addon 을 `terminated` 한다. 새 `domains.id` 는 없다. 나가는 인증코드는 저장하지 않는다. ### 14.7 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | G1 | user1 도메인을 user2 로 명의변경 | `domains.id` 유지. 새 서비스가 옛 `expires_at` 을 복사. registrant 유지. 프라이버시는 종료되고 복사되지 않음. 호스팅은 user1 에 그대로 | | G2 | 호스팅만 user2 로 명의변경 | `hosting_accounts.id` 유지. 디스크는 종료되고 복사되지 않음. 도메인은 그대로 | | G3 | admin 이메일만 변경 | 대기 행만 생긴다. EPP 성공 전에 현재 admin 링크는 유지. 서비스 주인과 `domains.id` 는 그대로 | | G4 | 입금요청 메일 | 자동은 `offset_days` 14 와 1 이 각 하나. 같은 키는 둘일 수 없다. `cancel_at` 이 있으면 자동 메일이 없다. 수동 재발송은 본문이 같고 `resend_of_id` 가 있다 | | G5 | 외부 네임서버, 파킹 | 외부로 바꾸는 성공 트랜잭션에서 그 스폰서 존의 레코드만 비운다. 자식 존의 레코드는 남는다. 파킹 중 `@` A 는 레코드에 없고, 파킹 중 외부 네임서버 변경은 성공이 아니다 | | G6 | 들어오는 기관 이전 성공 | 한 트랜잭션이 새 `domains.id`, `active`, 연결, `expires_at`, `fulfilled`, `auth_code=null` 을 함께 만든다 | | G7 | 기관 이전으로 나감 | `transferred_out`, 서비스 종료, 새 도메인 행 없음 | | G8 | 0원 변경 줄 | 생성 즉시 `paid`. 세금계산서 없음. 1원 이상인 미입금 줄은 `settled_amount` 가 0 이어도 `paid` 가 아니다 | ## 15. 마이페이지에 있던 기능 레거시 마이페이지의 고객 화면을 이 모델에 맞춘 결과다. 화면마다 테이블을 만들지 않는다. 거래, 스폰서 구간, 지금 자원의 식별만 둔다. 이 절이 5절, 7절, 9절, 14절의 같은 주제보다 우선한다. 12절의 일할 계산과 13절의 부가세 산식은 그대로다. 국세청 전송 클라이언트는 계속 두지 않는다. ### 15.1 이미 있는 규칙으로 되는 것 - 도메인 연장과 만료 재등록. 구제 기간의 재등록은 같은 `domains.id` 의 `action=restore` 다. `deleted` 또는 `transferred_out` 뒤의 등록은 새 `domains.id` 다. - 호스팅 월·반기·연 연장. 반기는 `period_unit=month`, `period_count=6` 이다. `prices.period_unit` 에 새 값을 두지 않는다. - WHOIS 정보보호는 유료 자식 서비스다. 스폰서 도메인의 Premium DNS 자식도 그대로다. Premium DNS 는 `kind=dns` 가 아니다. - 예치금 사용과 과입금, 13절 발행·스냅샷. 발행된 문서의 사업자번호를 고치는 경로는 없다. 승인번호가 없으면 `void`, 있으면 차액 문서다. - 카드는 `method=card` 다. 무통장 통보와 가상계좌 입금 대기는 `method=bank_transfer` 다. 대기는 `status=pending`, 확인은 `confirmed` 다. `virtual_account` 라는 method 는 저장하지 못한다. - 등록확인서, 청구서, 거래명세서, 카드 영수증은 기존 행의 출력이다. 카드 영수증은 `payments.pg_tid` 다. PDF 테이블은 없다. - 리눅스·윈도우·이미지·미디어 호스팅은 `product_type=hosting` 인 상품 코드다. kind 를 나누지 않는다. - 12절은 `kind=hosting` 부모와 그 addon 자식만이다. mail, server, dbms, security, dns 에는 12절을 적용하지 않는다. 그 중도 환불 금액은 25절이다. ### 15.2 명의변경 `prices.action` 의 `owner_change` 는 `period_unit=once` 다. renew 가격을 명의변경 금액으로 쓰지 않는다. `owner_change` 는 addon 에 만들지 못한다. 부모를 옮기면 자식 addon 은 그 실행 트랜잭션에서 `terminated` 되고 복사되지 않는다. amount 는 그 상품의 현재 `action=owner_change` 가격의 `sell_amount` 다. 현재 가격이 없으면 0 이다. 22000 을 고정하지 않는다. 공급가와 부가세는 13절이다. 수량은 1 이다. 0 이면 줄을 만드는 트랜잭션에서 서비스 교체와 `fulfilled` 가 함께 끝난다. 0 보다 크면 줄은 `pending` 이고 서비스는 그대로다. `settled_amount=amount` 가 되는 트랜잭션에서 교체와 `fulfilled` 가 함께 끝난다. 작업 행은 없다. 교체 트랜잭션이 실패하면 새 서비스와 `fulfilled` 를 남기지 않는다. 조건이 실패해 `cancelled` 가 된 pending 줄은 그 상태로 남고 배분은 없다. `from_service_id` 는 옛 서비스다. 실행 전에는 `service_id` 가 null 이다. `to_customer_id` 는 `owner_change` 에만 있고 필수다. 다른 action 에서는 null 이다. 주문의 `customer_id` 는 옛 서비스의 `customer_id` 와 같다. `to_customer_id` 는 그 고객과 달라야 한다. 새 서비스의 `customer_id` 는 `to_customer_id` 다. 줄을 만들 때와 실행 트랜잭션에서 옛 서비스 행을 잠근다. `status` 가 `active` 또는 `past_due` 가 아니거나 이미 `terminated` 이면, 새 줄은 만들지 않고 교체도 하지 않는다. 이미 있던 `pending` 줄은 `cancelled` 가 되고 그 줄에는 배분하지 않는다. `kind` 가 `dns` 가 아닌데 `expires_at` 이 null 이면 같다. `kind=dns` 이고 `status=active` 이면 `expires_at` null 을 새 서비스에 복사할 수 있다. 실행이 끝나면 옛 서비스는 `terminated` 다. 도메인, 호스팅, ssl, mail, server, dbms, security, dns 가 모두 그렇다. 새 서비스는 `expires_at`, `next_due_at`, `auto_renew` 를 복사하고, 줄의 `service_id` 는 그 새 서비스다. 대기 중인 줄은 서비스마다 하나다. 인덱스는 `uq_open_owner_change` 다. 도메인 실행은 14.3의 복사 규칙을 유지한다. `domains.id` 와 레지스트리 연락처와 `domains.expires_at` 은 그대로다. 호스팅과 메일 서비스는 따라가지 않는다. 존과 레코드는 존 id 가 그대로이므로 남는다. 호스팅 실행은 `hosting_accounts.id` 를 유지한다. 종료되지 않은 호스팅 서비스가 같은 계정을 둘 이상 가리키지 못한다. dns 실행은 `dns_zones.id` 를 유지하고 `service_id` 만 새 서비스로 바꾼다. `closed_at` 은 비운 채로 둔다. ssl 은 같은 `certificate_id` 를 새 `ssl_services` 가 가리킨다. mail, server, dbms, security 는 같은 식별 컬럼을 새 서브타입 행에 복사한다. 종료되지 않은 ssl 서비스가 같은 인증서를 둘 이상 가리키지 못한다. 명의변경 줄과 registrant 변경 줄은 다른 줄이다. 명의변경은 `registrant_lock_until` 을 쓰지 않는다. ### 15.3 이전 잠금과 인증코드 registrant 역할의 `epp_update_contact` 가 성공한 트랜잭션에서 `registrant_lock_until` 을 그 시각부터 60일(`interval '60 days'`)로 두고, `epp_statuses` 에 `clientTransferProhibited` 가 없으면 넣는다. 연락처 교체와 이 값은 한 트랜잭션이다. admin 변경은 이 시각을 쓰지 않는다. 그 시각 이전에는 `clientTransferProhibited` 를 빼는 `epp_update_status` 줄을 만들지 못한다. 넣는 줄은 만들 수 있다. 시각이 되면 빼는 줄을 만들 수 있다. 성공한 트랜잭션에서만 `epp_statuses` 를 바꾼다. 실패하면 상태를 유지하고 같은 작업을 재시도한다. `epp_authinfo` 는 나가는 인증코드를 발급한다. 코드 문자열은 `order_items`, `registry_events.payload`, `provisioning_jobs.last_error`, `auth_code` 를 포함한 어느 컬럼에도 쓰지 않는다. `auth_code` 는 들어오는 `transfer_in` 전용이다. 잠금 중에도 발급 줄은 만들 수 있다. `epp_transfer_reject` 는 `lifecycle_status=pending_transfer` 일 때만 만든다. 성공은 그 상태를 `active` 로 되돌린다. 새 `domains.id` 는 없다. 실패하면 `pending_transfer` 를 유지하고 같은 작업을 재시도한다. 이 세 작업의 줄은 `amount` 0 인 `action=change` 다. 성공하면 `fulfilled` 다. ### 15.4 존, 레코드, 파킹 ```text dns_zones id zone_name -- 소문자 A-label domain_id null → domains -- 스폰서 에이펙스 service_id null → services -- kind=dns parent_zone_id null → dns_zones closed_at null check ((domain_id is null) <> (service_id is null)) dns_records zone_id → dns_zones host, record_type, ttl, priority, value record_type -- A | AAAA | CNAME | MX | TXT | CAA | SRV | DNAME zone_publish zone_id pk → dns_zones mode -- delegation | parking | http_forward forward_url null forward_keep_url -- true 는 http_forward 일 때만 parking_title null parking_note null inquiry_enabled -- true 는 parking 일 때만 ``` `dns_services` 테이블은 없다. 타사 도메인과 2차 도메인은 `domains` 행이 아니다. `kind=dns` 서비스와 `service_id` 가 채워진 존이다. 그 존에는 `domain_nameservers`, `epp_transfer`, `auth_code` 가 없다. 2차 도메인의 `parent_zone_id` 는 비울 수 있다. 부모가 있으면 그 부모의 `parent_zone_id` 는 null 이다. 자식 존의 레코드는 자식 `zone_id` 에 있다. 자식의 명의변경은 부모를 옮기지 않는다. 살아 있는 도메인 이름과 같은 `zone_name` 의 열린 외부 존은 만들지 못한다. 열린 외부 존과 같은 이름의 살아 있는 도메인도 만들지 못한다. 존의 `service_id` 를 새 서비스로 바꾸지 않은 채 그 dns 서비스를 종료하면 그 트랜잭션에서 `closed_at` 을 채운다. 명의변경은 `service_id` 를 새 서비스로 바꾸므로 `closed_at` 을 비운 채로 둔다. 스폰서 존은 `domain_id` 만 채운다. 없어도 된다. 없으면 모드는 delegation 이고 레코드는 없다. `zone_publish` 행이 없으면 delegation 이다. delegation 이면 `forward_url` 은 null 이고 `forward_keep_url` 은 false 이며 파킹 컬럼은 비고 `inquiry_enabled` 는 false 다. `http_forward` 의 `forward_url` 은 `http` 또는 `https` 로 시작한다. parking 이면 `forward_url` 은 null 이다. 스폰서 존은 그 도메인의 네임서버가 2개 이상 4개 이하이고 전부 `our_nameservers` 일 때만 레코드를 둔다. 외부 존은 EPP 확인 없이 레코드를 둔다. 같은 host 에 CNAME 과 다른 타입은 없다. CNAME 은 `@` 에 없다. DNAME 은 host 마다 하나이고, 그 host 에는 다른 타입이 없다. DNAME 은 `@` 에 둘 수 있다. `epp_update_ns` 성공 트랜잭션에서만 스폰서 도메인의 네임서버를 갈아끼운다. 결과 개수가 2, 3, 4 가 아니면 성공이 아니고 기존 네임서버와 레코드가 남는다. 호스트가 그 도메인과 같거나, `.` 뒤에 그 도메인이 붙은 이름이면 글루다. `evilexample.com` 은 `example.com` 의 글루가 아니다. `ns1.example.com` 은 글루다. 글루는 `ipv4` 또는 `ipv6` 가 있어야 한다. 글루가 아니면 둘 다 null 이다. 어기면 성공이 아니다. 외부 네임서버가 하나라도 있으면 그 성공 트랜잭션에서 그 스폰서 존의 `dns_records` 만 지운다. `parent_zone_id` 가 그 존인 자식 존의 레코드는 지우지 않는다. parking 또는 http_forward 인 스폰서 존은 외부 네임서버 변경을 성공으로 두지 못한다. parking 과 http_forward 동안 그 존의 `dns_records` 에 host 가 `@` 또는 `www` 인 행은 없다. 그 값은 `zone_sync` 가 만들며 레코드 테이블에 저장하지 않는다. 외부 존도 이 저장 금지를 따른다. 외부 존의 parking 과 http_forward 는 EPP 없이 둘 수 있다. 레코드와 파킹의 현재값은 `zone_sync` 성공 트랜잭션에서만 `config` 로 갈아끼운다. ### 15.5 메일, 서버, DBMS, 보안 운영 자식 테이블은 없다. 비밀번호, 인증코드, OTP, 터미널 녹화는 컬럼이 아니다. 이 금지는 메일·서버·DB·보안 자원이다. 회원 로그인 해시는 18절이다. ```text mail_services service_id pk → services -- kind=mail quota_bytes zone_id null → dns_zones server_services service_id pk → services -- kind=server hostname ipv4 dbms_services service_id pk → services -- kind=dbms host port db_name security_services service_id pk → services -- kind=security security_kind -- waf | vpn | nms ``` 서버의 하위도메인 신청이 기존 존에 A 레코드를 넣는 일이면 `amount` 0 인 `action=change` 와 `zone_sync` 다. 그 신청으로 `domains` 행을 만들지 않는다. CSR 본문은 `order_items.config` 다. `certificates.dcv_method` 는 5.6절이다. ### 15.6 카드 예치금 충전 주문 줄이 없는 카드 결제는 충전이다. 통화는 KRW 이고 `amount` 는 1000 이상이다. 1000 미만은 결제 행을 만들지 않는다. `confirmed` 가 된 뒤에만 `payment_id` 만 있는 양수 예치금 행을 하나 만들고, 그 금액은 결제 금액과 같다. 배분 행은 없다. 뒤에 배분을 붙이지 못한다. 이 양수 행은 공급이 아니므로 세금계산서와 현금영수증이 없다. 주문 줄을 배분하는 결제의 남은 과입금은 기존 규칙이다. 1000 미만이어도 양수 예치금이 된다. 무통장 과입금도 그렇다. 충전은 `method=card` 만 된다. ### 15.7 테이블로 만들지 않는 것 10절의 직원·권한·고객 문의·일반 감사 로그는 계속 미정이다. - 화면, 다국어, 다크 모드, 투어, 채널톡, 공지, FAQ, 세션 화면. - NICE, OTP, 소셜 로그인, 법인전환 서류, 해외 IP 로그인 차단, 활동 로그. 회원 표시 칸은 16절이고, 전화·주소의 저장 형식은 17절이다. - 1:1 문의, 서버 작업 요청서. - 방화벽 IP, 백업, 웹로그, 사이트 편집, Let's Encrypt 발급, FTP 로그, FTP 비밀번호. Let's Encrypt 는 `certificates` 행이 아니다. - 서버 명령, 웹 터미널, 자원 사용량 차트. - 메일 계정, 전달 규칙, 스팸 격리, 추적, 접근 목록, 용량 알림, 월간 보고서. - WAF·VPN·NMS 의 목록 식별 외 상세. - 연락처 변경과 명의변경의 이메일 인증 코드, 관리자 SMS 수신 동의, or.kr 비영리 서류 파일. - CMS 출금계좌와 `payments.method=cms`. method 는 `card` 와 `bank_transfer` 만이다. 기기 이전 테이블 `hosting_migrations` 는 계속 없다. 서버 이전 화면의 별도 절차는 정하지 않는다. ### 15.8 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | M1 | 타사 example.com | `domains` 행이 없다. `kind=dns` 와 `dns_zones.service_id` 만 있다. 레코드 수정과 명의변경만 된다. `domain_nameservers`, `epp_transfer`, `auth_code` 는 없다 | | M2 | 스폰서 네임서버 1개와 3개 | 1개는 성공이 아니고 기존 네임서버가 남는다. 3개는 개수 조건만 보면 성공이다 | | M3 | 명의변경 가격 | 현재 가격이 있으면 amount 는 그 `sell_amount` 다. 없으면 0 이다. 22000 을 고정하지 않는다. 가격이 있으면 정산 전에 서비스와 연락처와 호스팅이 그대로다. 정산 트랜잭션이 기간을 복사한다 | | M4 | registrant 변경 성공 | 그 시각부터 60일 미만에는 `clientTransferProhibited` 를 빼는 줄을 만들지 못한다. 성공 트랜잭션이 그 값을 상태 배열에 넣는다. 계정 명의변경만으로는 `registrant_lock_until` 이 생기지 않는다 | | M5 | 카드 1000원 충전, 999원, 무통장 과입금 500원 | 1000원은 배분 없는 card 결제와 양수 예치금이고 세금계산서가 없다. 999원은 결제 행이 없다. 500원 과입금은 양수 예치금이다 | | M6 | 발행된 세금계산서의 사업자번호 | 고치는 경로가 없다 | | M7 | 웹 터미널, 스팸 격리, 방화벽 IP, 1:1 문의 | 테이블이 없다 | | M8 | DNAME, AAAA, CAA | DNAME 은 host 마다 하나이고 다른 타입은 없다. `@` 의 DNAME 은 된다. `@` 의 CNAME 은 안 된다. AAAA 와 CAA 도 저장된다 | | M9 | 가상계좌 입금 대기 | `bank_transfer` 와 `pending` 이다. `virtual_account` method 는 없다 | | M10 | 메일 | 계정 전달 규칙 테이블은 없다. `kind=mail` 행은 있다 | | M11 | example.com 의 글루 | `evilexample.com` 에 IP가 있으면 교체는 성공이 아니다. `ns1.example.com` 은 IP가 있어야 한다. 도메인 밖 호스트의 IP도 성공이 아니다 | | M12 | 파킹 | 제목과 주소 고정은 `zone_publish` 에 있다. 파킹 중 `@` A 는 `dns_records` 에 없다 | | M13 | 나가는 인증코드 | 어느 컬럼에도 없다 | | M14 | pending_transfer 거절 | 새 `domains.id` 없이 `active` 로 되돌린다 | | M15 | 결제 수단 | `card` 와 `bank_transfer` 뿐이다. CMS 계좌 테이블은 없다 | | M16 | 리눅스 호스팅 | `product_type=hosting` 이다 | | M17 | 같은 서비스의 대기 명의변경 | `pending` 줄은 하나다 | | M18 | 외부 네임서버 | 스폰서 존의 레코드만 지운다. 자식 존 레코드는 남는다. 스폰서 레코드는 네임서버가 전부 우리 것일 때만 있다 | | M19 | 호스팅 반기 | `month` 와 `period_count=6` 이다 | | M20 | 호스팅 해지 예약 | `cancel_at` 이다. 서비스는 `terminated` 가 아니다 | | M21 | 이름 충돌 | 살아 있는 도메인과 같은 이름의 열린 외부 존은 없다 | | M22 | 연락처 이름 | 국문은 `name_loc`, 영문은 `name` 이다. 법인은 `contact_kind=organization` 이고 조직명이 있다 | | M23 | Premium DNS | `kind=dns` 가 아니다 | | M24 | 이미 종료된 서비스의 명의변경 | 0원이면 줄을 만들지 않는다. 정산 전에 `terminated` 된 pending 줄은 `cancelled` 로 남고 배분이 없다 | | M25 | 메일 명의변경 | 옛 메일 서비스는 `terminated` 다. 새 서비스가 `expires_at` 을 복사한다. 줄의 `service_id` 는 새 서비스이고 `to_customer_id` 는 받는 고객이다. 주문의 고객은 옛 서비스의 고객이다 | | M26 | 받는 고객이 옛 고객과 같음 | 명의변경 줄을 만들지 못한다 | | M27 | 타사 존 명의변경과 종료 | 명의변경은 존 id 를 유지하고 `closed_at` 은 null 이다. 후임 없이 dns 서비스만 종료하면 `closed_at` 이 채워진다 | ## 16. 회원, 자동결제, 트래픽, DNSSEC, 등록인 식별 다른 호스팅 회사 장부에 흔하고 이 문서에 없던 것 가운데, 회원 표시 정보, 카드 자동결제 수단, 트래픽 초과, DNSSEC DS, 등록인 식별값만 정한다. 12절 일할과 13절 부가세 산식은 그대로다. 국세청 전송 클라이언트, CMS method, 15절에서 뺀 패널·문의·화면은 계속 두지 않는다. 쿠폰, 리셀러 단가, 문자 발송, 계정 담당자, 추가 IP 는 이번 절에 없다. ### 16.1 회원 표시 `customers.email` 은 14절 그대로다. 아래는 회원 표시 칸이다. 레지스트리 `contacts` 와 `customer_tax_profiles` 와 서로 복사하지 않는다. ```text customers login_id -- 소문자. 가입 뒤 불변. 18절 password_hash -- argon2id. 18절 email -- 소문자. 필수. 18절 name -- 표시 이름. 필수. 18절 phone null -- 17절 암호문 mobile -- 17절 암호문. 필수. 18절 postal_code null -- 17절 암호문 address null -- 17절 암호문 address_detail null -- 17절 암호문 terms_version -- 18절 terms_accepted_at -- 가입 트랜잭션 시각. 18절 ``` `email`, `name`, `mobile` 은 18절에서 필수이고 가입 뒤 비우지 못한다. `phone`, `postal_code`, `address`, `address_detail` 은 null 일 수 있고, 비어 있지 않으면 17절 암호문이다. `mobile` 은 항상 17절 암호문이다. `email` 과 `name` 과 `login_id` 는 평문이다. 이 칸을 고쳐도 연락처 행, 세금 프로필, 발행된 문서는 바뀌지 않는다. 세금 프로필이 없어도 결제는 된다. 세금계산서를 고를 수 있는지는 13절의 `buyer_kind` 만 본다. ### 16.2 카드 자동결제 수단 `payments.method` 는 `card` 와 `bank_transfer` 만이다. ```text payment_methods id customer_id → customers method -- card status -- active | revoked billing_key -- 17절 암호문. 평문 토큰이 아님 billing_key_digest -- 평문 토큰 HMAC-SHA256 소문자 hex 64. 17절 card_last4 null -- 숫자 4자리 card_issuer null is_default ``` `payments.payment_method_id` 는 저장된 카드로 낼 때만 채운다. `billing_key` 의 평문 토큰이 비거나, 숫자만으로 13자리에서 19자리면 행을 만들지 못한다. 저장 값은 17절 암호문이다. `card_last4` 가 null 이 아닌데 숫자 4자리가 아니면 행을 만들지 못한다. `is_default` 가 true 이면 `status=active` 다. 해제 트랜잭션은 `revoked` 와 `is_default=false` 를 같이 둔다. 고객당 `active` 이면서 `is_default` 인 행은 하나다. `active` 인 같은 평문 토큰의 digest 도 하나다. 저장 형식은 17절이다. 자동 청구는 `orders.source=system` 이고 줄이 하나인 `action=renew` 에만 한다. 주문의 고객은 그 서비스의 고객이다. 서비스 `expires_at` 이 null 이면 이 주문을 만들지 못한다. `idempotency_key` 는 `renew:` 와 서비스 id 와 `:` 와 `period_start` 의 UTC ISO-8601 을 이 순서로 잇는다. 같은 키가 있으면 새로 만들지 않는다. 고객에게 `active` `is_default` 수단이 있을 때만 카드 결제를 만든다. 그 주문에 `pending` 또는 `confirmed` 인 카드 결제가 있으면 새로 만들지 않는다. `failed` 만 있으면 새 결제 행을 하나 만들 수 있다. 결제 금액은 그 줄의 `amount` 와 같다. 예치금을 먼저 쓰는 것은 24절이다. `payment_method_id` 를 채운다. 성공하면 기존 배분 규칙으로 `confirmed` 다. 실패하면 그 결제는 `failed` 이고 배분이 없으며 줄은 `pending` 이다. 예치금 행은 만들지 않는다. 수단이 없으면 자동 카드 결제를 만들지 않는다. 14절 메일은 그대로 보낸다. 고객이 만든 주문과 `usage` 줄은 자동 청구하지 않는다. `billing_key` 는 메일 본문, `order_items.config`, `registry_events.payload`, `provisioning_jobs.last_error` 에 쓰지 않는다. ### 16.3 트래픽 초과 `services.included_traffic_bytes` 는 null 이거나 0 이상의 바이트다. `kind` 가 `hosting` 또는 `server` 가 아니면 null 이다. null 이면 그 서비스의 사용량 행을 만들지 못한다. addon 은 null 이다. `usage` 가격의 `period_unit` 은 `once` 다. ```text service_usage id service_id → services meter -- traffic_bytes period_start period_end included_bytes -- 넣을 때의 services.included_traffic_bytes used_bytes billed_bytes -- used 가 included 보다 크면 차이, 아니면 0 ``` `used_bytes` 와 `included_bytes` 는 0 이상이다. `period_end` 가 `period_start` 보다 늦지 않으면 사용량 행을 만들지 못한다. 1 GiB 는 1073741824 바이트다. 청구 단위는 `billed_bytes` 를 이 크기로 나눈 올림이다. 0 이면 줄을 만들지 않는다. 현재 `action=usage` 가격이 없으면 사용량 행만 두고 줄은 만들지 않는다. 현재 가격은 `effective_from` 이 지금 이전이고 `effective_to` 가 비었거나 지금보다 늦은 것이다. 그 가격이 둘 이상이면 `effective_from` 이 가장 늦은 것을 쓴다. 그래도 둘이면 줄을 만들지 않는다. 가격이 있고 점유 중인 줄이 없으면 `pending` 줄을 하나 만든다. 줄의 `service_id` 는 측정한 서비스이고 `price_id` 는 그 가격이다. `amount` 는 그 `sell_amount` 곱하기 단위 수다. 부가세는 13절이다. 점유 중인 줄이 `pending` 인 동안 바이트를 고치면 같은 트랜잭션에서 그 줄의 `price_id` 판매가로 단위와 `amount` 와 13절 부가세를 다시 계산한다. 단위가 0 이 되면 그 줄을 `cancelled` 로 둔다. 사용량 행은 남는다. `usage` 줄은 `expires_at`, `next_due_at`, 서비스 `quantity` 를 바꾸지 않는다. 작업 행은 없다. `settled_amount=amount` 인 트랜잭션에서 `fulfilled` 가 된다. 정산된 줄이 있으면 그 사용량 행의 세 바이트 칸은 고치지 못한다. 정정은 새 `action=credit` 줄이다. 그 금액은 25절이다. 서비스의 포함량을 나중에 바꿔도 이미 있는 사용량 행의 `included_bytes` 는 그대로다. `uq_paid_period` 는 `usage` 에 적용하지 않는다. ### 16.4 DNSSEC DS 는 `dns_records` 타입이 아니다. 외부 존에는 없다. ```text domain_ds_records id domain_id → domains key_tag -- 0 이상 65535 이하 algorithm -- 1 이상 255 이하 digest_type -- 2 만 digest -- 소문자 16진수 64자 ``` 한 도메인의 DS 는 8개 이하다. 같은 `key_tag`, `algorithm`, `digest_type`, `digest` 가 둘이면 성공이 아니다. 변경은 `amount` 0 인 `action=change` 이고 작업은 `epp_update_ds` 다. 성공 트랜잭션에서만 `config` 의 목록으로 전부 갈아끼운다. 빈 목록이면 그 도메인의 DS 를 모두 지운다. 실패하면 기존 DS 가 남고 같은 작업을 재시도한다. 개수나 형식이 어긋나면 성공이 아니다. 외부 네임서버로 바꿔 존 레코드를 지워도 DS 는 지우지 않는다. ### 16.5 등록인 식별값 `tlds.identity_required` 가 true 일 때만 등록인 식별이 필수다. `identity_value` 에 저장되는 값은 17절 암호문이다. 아래 검사는 암호화 전 평문에 한다. `birth_date` 는 하이픈 없는 8자리이며 그레고리력의 실제 날짜다. `business_no` 는 숫자 10자리다. `identity_kind` 와 `identity_value` 는 둘 다 null 이거나 둘 다 유효하다. 하나만 있으면 연락처를 저장하지 못한다. `contact_kind=individual` 은 `birth_date` 만, `organization` 은 `business_no` 만 둔다. 어긋나면 그 연락처 변경 줄을 만들지 못한다. `identity_required` 인 TLD 의 등록인에 유효한 식별이 없으면 `epp_create` 와 등록인 `epp_update_contact` 는 성공이 아니다. 새 도메인 행을 만들지 않고, 현재 등록인 링크도 바꾸지 않는다. admin 역할과 `identity_required` 가 아닌 TLD 는 식별이 없어도 된다. 식별값은 `customers`, 세금 프로필, `order_items.config`, `registry_events.payload`, 메일 본문에 복사하지 않는다. 세금 프로필의 `business_no` 와 자동으로 맞추지 않는다. ### 16.6 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | P1 | 회원 이름 변경 | 등록인 `contacts.name` 은 그대로다 | | P2 | 세금 프로필 없음 | 결제할 수 있다. 세금계산서 가능 여부는 `buyer_kind` 만 본다 | | P3 | 카드 수단 | active 기본 수단은 고객당 하나다. 해제된 행은 기본이 아니다. 숫자 16자리 `billing_key` 는 저장하지 못한다 | | P4 | 자동 청구 실패 | 줄은 `pending` 이고 배분이 없다. 같은 `idempotency_key` 로 주문을 또 만들지 않는다. 예치금 행은 없다. 메일은 14절대로 있다. `billing_key` 는 메일 본문에 없다 | | P5 | 포함 10 GiB 보다 1바이트 많음 | 청구 단위는 1이다. 딱 같으면 줄이 없고 사용량 행은 있다 | | P6 | 정산된 사용량 | `used_bytes` 는 고치지 못한다. `expires_at` 은 그대로다 | | P7 | DS 교체 | 성공하면 요청한 개수만 남는다. 실패하면 예전 DS 가 남는다. 외부 네임서버 성공으로 DS 가 지워지지 않는다. 대문자 digest 는 성공이 아니다 | | P8 | 식별이 필수인 TLD | 등록인 식별이 없으면 도메인 행이 생기지 않는다. admin 변경은 식별 없이 된다 | | P9 | 식별값의 위치 | `customers` 와 `registry_events.payload` 에 없다. 세금 프로필 사업자번호와 따로다 | | P10 | CMS | `payments.method=cms` 는 저장하지 못한다 | | P11 | 메일 서비스 | `included_traffic_bytes` 는 null 이다. 사용량 행은 없다 | | P12 | 빈 DS 목록 | 성공하면 그 도메인의 DS 는 0개다 | | P13 | `expires_at` 이 null | 시스템 갱신 주문을 만들지 않는다 | | P14 | 이미 pending 인 카드 결제 | 자동 청구를 더 만들지 않는다. `failed` 다음에는 새 결제 행을 만들 수 있다 | | P15 | 식별 반쪽 | `identity_kind` 만 있으면 연락처를 저장하지 못한다 | | P16 | 사용 기간과 갱신 키 | `period_end` 가 `period_start` 와 같으면 사용량 행이 없다. 키는 `renew:` 와 서비스 id 와 `:` 와 UTC ISO-8601 이다 | | P17 | 청구 전 사용량 감소 | 포함량과 같아지면 pending 줄은 `cancelled` 다. 사용량 행은 남고 `expires_at` 은 그대로다 | ## 17. 저장 시 암호화 이미 있는 칸의 저장 시 암호화만 정한다. 키 테이블, 화면 마스킹, 접속 기록, 직원 권한은 만들지 않는다. 10절은 그대로다. 12절 일할, 13절 부가세 산식, 국세청 전송 클라이언트, CMS method, 15절에서 뺀 패널·문의·화면은 그대로다. ### 17.1 전송과 디스크 개인정보 또는 빌링키 평문이 지나는 웹, 결제사, EPP, 메일 제출 구간은 TLS 다. 데이터베이스 디스크는 암호화한다. 둘 다 테이블이 아니다. ### 17.2 암호문 알고리즘은 AES-256-GCM 하나다. 저장할 때마다 nonce 12바이트를 새로 뽑는다. 암호문 형식은 `v1:` + key_id + `:` + base64(nonce 12바이트 || 암호문 || 태그 16바이트) 다. base64 는 한 줄이고 개행이 있으면 형식이 아니다. key_id 는 영문 대소문자, 숫자, `_`, `-` 로 1자 이상 32자 이하다. 평문은 UTF-8 바이트로 암호화한다. 복호화는 그 암호문의 key_id 에 해당하는 키로만 한다. 그 키가 없으면 복호화 실패다. AES 키와 HMAC pepper 는 서로 다른 값이고 데이터베이스에 없다. 키 테이블은 없다. 이미 저장된 암호문의 키는 폐기하지 않는다. 기존 행이나 발행된 문서를 다시 암호화하는 작업은 없다. 데이터베이스 함수로 암호화하지 않는다. 애플리케이션이 평문을 검사한 뒤 암호문만 SQL 에 넣는다. 키와 pepper 와 대상 칸의 평문은 SQL 문에 없다. null 칸은 암호문을 만들지 않고 null 이다. 평문이 빈 문자열이면 넣지 못한다. 형식이 아닌 값은 그 칸에 넣지 못한다. ### 17.3 대상 다음 칸은 비어 있지 않으면 위 암호문만 둔다. - `customers.phone` - `customers.mobile` - `customers.postal_code` - `customers.address` - `customers.address_detail` - `contacts.identity_value` - `customer_tax_profiles.cash_receipt_identifier` - `tax_documents.cash_receipt_identifier` - `payment_methods.billing_key` `customers.email`, `customers.name` 은 평문이다. `customers.email` 은 14절의 입금요청 수신 주소다. 연락처의 이름, 이메일, 전화, 주소 칸은 평문이다. 변경 요청은 `order_items.config` 에 남고 EPP 성공 뒤에 그 칸으로 들어온다. 그 칸만 암호화하면 같은 평문이 config 에 남으므로 암호화하지 않는다. `identity_value` 는 config 에 남기지 않으므로 암호화한다. 세금 프로필의 상호, 사업자번호, 대표자, 주소, 이메일, 업태, 종목은 평문이다. 세금 문서의 공급받는자 스냅샷은 13절 그대로 평문이다. `card_last4` 와 `card_issuer` 는 평문이다. 금액, 도메인 이름, DS, 들어오는 `auth_code` 는 이 절의 대상이 아니다. 대상 칸의 평문과 암호문은 다른 칸에 저장하지 않는다. 예외는 둘뿐이다. 현금영수증 식별자 암호문을 프로필에서 문서 칸으로, 또는 문서에서 차액 문서 칸으로 바이트 복사하는 것. `billing_key_digest` 를 `payment_methods` 에 두는 것. 메일 본문, `order_items.config`, `registry_events.payload`, `provisioning_jobs.last_error` 에도 대상 칸의 평문과 암호문은 없다. 이 금지는 저장이다. 결제사 요청, 레지스트리 요청, 그 값을 고객에게 보여주는 응답을 만드는 동안 평문은 메모리에만 있다. 그 평문과 암호문을 그 뒤에 칸이나 메일 본문, config, payload, last_error 에 쓰지 않는다. 주민등록번호는 어느 칸에도 없다. 평문이 숫자 13자리이거나, 숫자 6자리·하이픈·숫자 7자리이면 암호화하지 않고 넣지 못한다. ### 17.4 빌링키 16절의 평문 규칙(빈 값 금지, 숫자만 13자리에서 19자리 금지)은 암호화 전에 적용한다. 저장되는 `billing_key` 는 평문 토큰이 아니다. 16절의 "active 인 같은 billing_key 도 하나"는 평문 토큰의 digest 가 활성 행에서 하나라는 뜻이다. `billing_key_digest` 는 필수다. 평문 토큰의 HMAC-SHA256 을 소문자 16진수 64자로 둔 것이다. pepper 는 이 절에서 바꾸지 않는다. digest 가 평문 토큰과 같으면 행을 만들지 못한다. `billing_key` 또는 `billing_key_digest` 를 쓰는 트랜잭션은, 암호문을 그 key_id 로 복호화한 평문의 HMAC 이 digest 와 같을 때만 저장한다. 다르면 행을 저장하지 못한다. 6절의 `uq_active_billing_key` 는 `billing_key_digest` 에 건다. `billing_key` 자체에는 유니크를 걸지 않는다. 같은 평문 토큰의 암호문은 nonce 때문에 달라도 digest 는 같다. 활성 행이 그 digest 를 이미 가지면 새 활성 행을 만들지 못한다. 해제된 행과 digest 가 같아도 새 활성 행은 만들 수 있다. 해제 트랜잭션은 `billing_key` 와 `billing_key_digest` 를 바꾸지 않고 복호화하지 않는다. 16절대로 `revoked` 와 `is_default=false` 를 같이 둔다. 복호화에 실패해도 해제는 된다. 자동 청구에서 복호화에 실패하거나 digest 와 평문이 다르면 결제 행을 만들지 않고 status 도 바꾸지 않는다. 배분도 없다. 줄은 `pending` 이다. 결제사가 거절한 뒤에만 16절대로 새 결제 행을 만들 수 있다. ### 17.5 식별값 16절의 생년월일·사업자번호 검사는 암호화 전의 평문에 한다. 저장 값은 8자리나 10자리가 아니다. `identity_kind` 와 암호문은 둘 다 null 이거나 둘 다 있다. null 이면 복호화하지 않는다. 복호화에 실패하면 그 연락처를 대상으로 한 `epp_update_contact` 는 성공이 아니다. 역할이 admin 이어도 같다. 등록인 식별을 복호화하지 못하면 `epp_create` 도 성공이 아니다. `identity_value` 가 있는데 복호화되지 않는 연락처를 포함하는 `epp_create` 도 성공이 아니다. 도메인 행과 현재 등록인 링크는 바뀌지 않는다. 14절대로 실패하면 대기 행을 지운다. 식별 평문을 레지스트리에 보낼 때는 메모리에서만 보내고 payload 에는 남기지 않는다. ### 17.6 현금영수증 식별자 프로필 칸과 문서 칸 모두 암호문이다. 새 문서의 식별자 암호문은 그 시점의 프로필 암호문을 바이트 그대로 복사한다. 차액 문서의 식별자 암호문은 원본 문서의 암호문을 바이트 그대로 복사한다. 다시 암호화하지 않고, 차액 문서는 그 시점의 프로필을 읽지 않는다. 13절의 나머지 스냅샷 복사는 그대로다. 프로필을 나중에 고쳐도 이미 발행된 문서의 암호문은 그대로다. 문서 칸의 암호문을 평문으로 덮어쓰지 못한다. ### 17.7 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | E1 | 같은 카드 토큰을 두 번 암호화 | 암호문은 다르고 digest 는 같다. 활성 행이 있으면 둘째 활성 행은 없다 | | E2 | 숫자 16자리 토큰 | 저장하지 못한다. `v1:` 으로 시작하지 않는 `billing_key` 도 저장하지 못한다 | | E3 | 복호화 실패로 자동 청구 | 결제 행이 없고 수단 status 도 그대로다. 줄은 `pending` 이고 메일에 토큰이 없다. 같은 실패로 해제는 된다 | | E4 | 회원 전화와 이메일 | 전화는 `v1:` 암호문이다. `customers.email` 은 평문이고 입금요청 `to_address` 가 된다. 전화 평문은 메일 본문에 없다 | | E5 | 회원 주소 변경 | 세금 문서의 `buyer_address` 는 평문 그대로다. 연락처 주소도 그대로다 | | E6 | 등록인 식별 | 평문 `19900101` 의 저장 값은 그 8자리가 아니다. payload 에도 없다. 복호화 실패면 도메인 행이 생기지 않고 대기 연락처 행도 지운다 | | E7 | 주민번호 형태 식별자 | 평문 `900101-1234567` 이면 프로필을 저장하지 못한다 | | E8 | 차액 문서 | 식별자 암호문은 원본 문서와 같은 바이트다. 프로필을 그 뒤에 바꿔도 원본과 차액은 그대로다. 그 암호문의 키를 폐기하지 않는다 | | E9 | 연락처 전화 | 평문이다. `identity_value` 만 암호문이다. 연락처 변경 config 에도 식별값 평문이 없다 | | E10 | 키의 위치 | 키 테이블은 없다. AES 키와 pepper 는 다른 값이다. 디스크 암호화와 TLS 는 테이블이 아니다 | | E11 | 해제된 카드 | 같은 digest 로 새 활성 행을 만들 수 있다. 활성끼리 같은 digest 는 못 만든다 | | E12 | 끝 네 자리와 주민번호 | `card_last4` 는 평문 4자리다. 숫자 13자리 평문은 회원 이름에도 없다 | | E13 | digest 불일치 | 암호문이 가리키는 토큰과 digest 의 토큰이 다르면 그 수단 행을 저장하지 못한다 | | E14 | admin 식별 복호화 실패 | 그 연락처 변경은 성공이 아니다. 식별 칸이 null 인 admin 변경은 16절대로 된다 | | E15 | 회원 주소와 config | 회원 주소 암호문을 `order_items.config` 에 넣지 못한다. 연락처 거리 평문은 config 에 남을 수 있다 | | E16 | 자동 청구 전송 | 빌링키 평문을 메모리에서 결제사로 보낸다. 결제 행과 메일에는 그 평문과 암호문이 없다 | | E17 | 등록 전송 | 식별값이 있는 등록인의 `epp_create` 는 평문을 메모리에서 레지스트리로 보낸다. payload 에는 없다. 그 복호화가 실패하면 도메인 행이 없다. 식별 복호화가 실패하는 admin 연락처를 포함하는 `epp_create` 도 도메인 행이 없다 | ## 18. 가입 필수 고객 행을 만드는 가입 필수만 정한다. 본인인증, 인증코드 표, 약관 본문, 비밀번호 재설정 표는 만들지 않는다. 12절 일할, 13절 부가세 산식, 17절 암호 알고리즘은 그대로다. ### 18.1 가입 트랜잭션 가입은 `customers` 한 행만 만든다. 주문, 연락처, 세금 프로필, 카드 수단, 예치금 행은 만들지 않는다. 필수 칸이 하나라도 없거나 검사에 실패하면 그 행을 만들지 않는다. 필수 칸은 `login_id`, `password_hash`, `email`, `name`, `mobile`, `terms_version`, `terms_accepted_at` 이다. 16절에서 null 이던 `email`, `name`, `mobile` 은 18절에서 필수다. 가입 뒤에도 그 셋과 `password_hash`, `terms_version`, `terms_accepted_at` 을 비우지 못한다. `phone`, `postal_code`, `address`, `address_detail` 은 계속 null 일 수 있다. 가입은 개인·사업자를 묻지 않는다. `customers.member_kind` 칸은 없다. 사업자번호와 세금 프로필 없이 가입된다. 프로필이 없어도 결제는 된다. 세금계산서 가능 여부는 13절의 `buyer_kind` 만 본다. 가입이 세금 프로필을 만들지 않는다. ### 18.2 로그인 아이디 `login_id` 는 필수이고 가입 뒤 바꾸지 못한다. 저장 값은 소문자다. 첫 글자는 영문 소문자이고 나머지는 영문 소문자 또는 숫자이며 전체 길이는 4자 이상 20자 이하다. `@` 가 있으면 가입이 아니다. 활성 여부와 관계없이 `login_id` 는 고객 전체에서 하나다. 6절의 `uq_customer_login_id` 가 그 유니크다. ### 18.3 비밀번호 평문 비밀번호는 8자 이상 64자 이하다. 공백만인 평문은 저장하지 못한다. 글자 종류는 따지지 않는다. 해시를 바꿀 때도 그 트랜잭션의 평문이 이 범위일 때만 저장한다. 평문 없이 해시 문자열만 저장하지 못한다. 저장 값은 Argon2id PHC 문자열이고 `$argon2id$v=19$m=19456,t=2,p=1$` 로 시작한다. 그 접두로 시작하지 않으면 행을 만들지 못한다. bcrypt 와 AES `v1:` 암호문은 비밀번호 칸이 아니다. 평문 비밀번호는 SQL 문에 없다. 평문과 해시는 메일 본문, `order_items.config`, `registry_events.payload`, `provisioning_jobs.last_error` 에 없다. 해시는 `customers.password_hash` 에만 있다. 나중에 해시를 다른 합격 해시로 바꿀 수 있다. 재설정 토큰 표는 없다. ### 18.4 이메일과 이름 `email` 은 필수이고 null 로 비우지 못한다. 저장 값은 소문자이고 길이는 254자 이하다. `@` 가 하나이고 앞뒤가 비어 있지 않으며 도메인에 점이 있다. 공백이 있으면 가입이 아니다. 고객 전체에서 이메일은 하나다. 6절의 `uq_customer_email` 이 그 유니크다. 다른 주소로 바꿀 수 있다. 바꾸면 14절 입금요청의 `to_address` 는 새 이메일이다. 연락처와 세금 프로필의 이메일은 그대로다. `name` 은 앞뒤 공백을 뺀 1자 이상 100자 이하다. 빈 문자열로 비우지 못한다. 17절의 주민등록번호 형태면 넣지 못한다. ### 18.5 휴대폰 `mobile` 은 필수이고 null 로 비우지 못한다. 공백, 하이픈, 맨 앞의 `+` 하나만 뺀 뒤 숫자만 8자리 이상 15자리 이하다. 그 숫자가 13자리면 가입이 아니다. 그 숫자를 17절 암호문으로 저장한다. 평문을 SQL 에 넣지 않는다. 휴대폰은 유니크가 아니다. `phone` 은 계속 선택이다. ### 18.6 약관 `terms_version` 은 1자 이상 32자 이하고 영문, 숫자, `.`, `_`, `-` 만 있다. `terms_accepted_at` 은 가입 트랜잭션 시각이다. 클라이언트가 보낸 시각으로 채우지 않는다. 약관 본문 표는 없다. 나중에 약관 글이 바뀌어도 이 두 칸은 그대로다. 재동의 표는 없다. ### 18.7 만들지 않는 것 소셜 계정만으로 가입 행을 만들지 못한다. 본인인증 결과 칸은 없다. 15.5의 서비스 운영 비밀번호 금지는 그대로다. ### 18.8 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | J1 | 이메일 없음 | 고객 행이 없다. 주문도 없다 | | J2 | `Login1` 과 `login1` | 한 고객이다. 저장 값은 `login1` 이다. 가입 뒤 `login_id` 를 바꾸지 못한다 | | J3 | 평문 비밀번호 또는 bcrypt | 저장하지 못한다. `$argon2id$v=19$m=19456,t=2,p=1$` 로 시작하는 해시만 저장한다. 그 해시와 평문은 메일에 없다 | | J4 | `010-1234-5678` | 숫자 11자리가 된 뒤 17절 암호문이다. `mobile` 을 null 로 두지 못한다 | | J5 | 개인·사업자 없음 | 가입된다. `member_kind` 칸은 없다. 세금 프로필 행은 없다. 결제는 된다 | | J6 | 주소와 일반 전화 없음 | 가입된다 | | J7 | 이메일 변경 | 입금요청 수신 주소는 새 이메일이다. 등록인 연락처 이메일은 그대로다 | | J8 | 이름이 숫자 13자리 | 가입이 아니다 | | J9 | 소셜 식별자만 | 고객 행이 없다 | | J10 | 약관 | `terms_version` 이 없으면 가입이 아니다. `terms_accepted_at` 을 클라이언트가 보낸 시각으로 채우면 가입이 아니다. 약관 본문 표는 없다 | | J11 | 짧은 비밀번호 또는 다른 파라미터 | 7자면 가입이 아니다. `m=19456,t=2,p=1` 이 아니면 해시를 저장하지 못한다 | | J12 | 같은 휴대폰, 같은 이메일 | 같은 휴대폰으로 두 고객이 가입할 수 있다. 같은 이메일의 둘째 고객은 없다 | | J13 | 13자리 휴대폰 | 가입이 아니다. 10자리와 14자리는 암호문으로 가입된다 | | J14 | 해시 문자열만 변경 | 저장하지 못한다. 공백 8자 비밀번호도 저장하지 못한다 | | J15 | 가입 뒤 비우기 | `email`, `name`, `mobile` 을 null 로 비우지 못한다. `postal_code` 는 null 일 수 있다 | ## 19. 가입 때 개인·사업자 구분 가입 때 개인·사업자 구분만 정한다. 12절 일할, 13절 부가세 산식과 `buyer_kind` 의 세금계산서 조건, 17절 암호 알고리즘, 18절의 로그인 아이디·비밀번호·이메일·이름·휴대폰·약관은 그대로다. 연락처의 `contact_kind` 와 `identity_kind` 도 그대로다. ### 19.1 구분 칸 가입은 개인과 사업자를 묻지 않는다. `customers.member_kind` 칸은 없다. null 로 남겨 두지도 않는다. 개인·사업자 구분은 `customer_tax_profiles.buyer_kind` 만이다. 그 칸과 어긋날 수 있는 둘째 구분 칸은 두지 않는다. 가입 필수 칸은 `login_id`, `password_hash`, `email`, `name`, `mobile`, `terms_version`, `terms_accepted_at` 이다. 사업자번호와 세금 프로필 없이 가입된다. 가입이 세금 프로필을 만들지 않는다. 프로필이 없어도 결제는 된다. 세금계산서 가능 여부는 13절의 `buyer_kind` 만 본다. 13절의 `buyer_kind` 값과 세금계산서 조건은 그대로다. `customers` 에 `member_kind` 컬럼을 적는 INSERT 나 UPDATE 는 저장되지 못한다. 연락처의 `contact_kind` 와 `identity_kind` 는 그대로다. 16.1의 `member_kind` 줄, P2, 17절 평문 문장, 18.1 필수 문장과 그 다음 문장, J5는 이 절의 문장으로 바뀌어 있다. ### 19.2 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | K1 | 개인·사업자 없이 가입 | 고객 행이 생긴다. `member_kind` 칸은 없다 | | K2 | `member_kind` 컬럼을 적은 INSERT | 저장되지 못한다 | | K3 | 사업자번호 없음 | 가입된다. 세금 프로필 행은 없다. 결제는 된다 | | K4 | `buyer_kind=individual` | `tax_invoice` 를 고르지 못한다. 13절 조건은 그대로다 | | K5 | 세금 프로필 없음 | 결제할 수 있다. 개인 회원이라는 이유로 결제를 막지 않는다 | | K6 | 연락처 종류 | `contact_kind` 와 `identity_kind` 는 그대로다 | | K7 | 가입 필수 | `member_kind` 는 필수가 아니다. 필수 칸은 `login_id`, `password_hash`, `email`, `name`, `mobile`, `terms_version`, `terms_accepted_at` 이다 | | K8 | 평문 목록 | `customers.member_kind` 는 없다. 평문은 `customers.email` 과 `customers.name` 이다 | ## 20. 세금계산서 서류와 사업자번호 교체 세금계산서 신청의 서류와, 같은 고객의 사업자번호 교체만 정한다. 12절 일할, 13절 부가세 산식과 문서 대상, 승인번호가 없을 때의 void, 승인번호가 있을 때의 금액 차액, 차액 문서가 원본 스냅샷을 복사하는 규칙, 17절 암호 알고리즘은 그대로다. 국세청 전송 클라이언트는 만들지 않는다. ### 20.1 서류 세금계산서를 고르는 입력에 파일은 없다. 사업자등록증 사본, 팩스, 이미지, 심사 상태 칸, 서류 표는 만들지 않는다. 15절이 뺀 법인전환 서류와 or.kr 비영리 서류 파일도 만들지 않는다. 도메인 명의변경 서류도 이 절이 만들지 않는다. 세금계산서는 13절 그대로 `buyer_kind=business` 이고 `business_no`, `representative_name`, `address`, `email`, `business_type`, `business_item` 이 있을 때만 고른다. `legal_name` 도 채운다. 그 칸은 `customer_tax_profiles` 한 행이다. 고객당 그 행은 하나다. ### 20.2 사업자번호 `business_no` 가 있을 때 저장 값은 숫자 10자리다. 공백과 하이픈만 뺀 뒤 숫자가 10자가 아니면 저장하지 못한다. 체크섬은 검사하지 않는다. 정수로 바꾸지 않는다. 다른 고객과 같은 번호여도 된다. `tax_invoice` 를 고르지 않은 프로필은 `business_no` 가 null 일 수 있다. 같은 고객의 다른 사업자로 바꾸는 일은 그 한 행의 `business_no` 를 다른 10자리로 고치는 것이다. 고객을 새로 만들지 않고, 프로필 행을 하나 더 만들지 않는다. 예전 번호의 이력 표는 없다. 예전 번호는 이미 발행된 문서의 `buyer_business_no` 에만 남는다. 문서가 없으면 덮어쓴 번호는 프로필에 남지 않는다. `tax_invoice` 를 고른 채로 여섯 칸 중 하나를 비우면 그 저장은 실패하고 프로필은 그대로다. `evidence_preference` 를 `none` 또는 `cash_receipt` 로 바꾸는 저장은 13절 조건만 통과하면 `business_no` 를 비울 수 있다. 최초 프로필은 13절을 통과할 때만 생긴다. 프로필을 저장해도 문서를 만들지 않고, 이미 있는 문서를 void 하지 않으며, 문서 칸을 고치지 않는다. 아직 문서가 없는 라인의 최초 세금계산서는 그 문서를 만드는 시점의 프로필을 스냅샷한다. 이미 있는 문서의 금액 차액은 13.4 그대로 원본 문서의 공급받는자를 복사한다. 그 차액은 바꾼 프로필을 읽지 않는다. 이미 발행된 문서의 공급받는자를 다른 사업자로 바꾸는 문서 쌍은 만들지 않는다. `business_no` 를 고쳐도 `contacts.identity_value` 와 `customers` 의 칸은 그대로다. 현금영수증 식별자와 17절 암호는 그대로다. ### 20.3 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | T1 | 파일 없이 여섯 칸과 상호 | `tax_invoice` 를 고를 수 있다. 사업자등록증 파일 칸은 없다 | | T2 | `123-45-67890`, 9자리, 11자리 | `1234567890` 으로 저장한다. 9자리와 11자리는 저장하지 못한다. 체크섬이 틀린 10자리는 저장한다 | | T3 | 발행된 `1111111111` 을 프로필 `2222222222` 로 교체 | 그 문서의 `buyer_business_no` 는 `1111111111` 이다. 고객 행은 그대로고 프로필 행은 하나다 | | T4 | T3 뒤 아직 문서가 없는 라인 | 최초 세금계산서 스냅샷은 `2222222222` 다 | | T5 | T3 뒤 옛 문서 라인 환불 | 금액 차액 문서의 공급받는자는 `1111111111` 이다. 프로필을 읽지 않는다 | | T6 | 승인번호 없는 문서가 있을 때 번호 교체 | 그 문서는 void 되지 않고 스냅샷도 바뀌지 않는다 | | T7 | 같은 시각의 두 사업자번호 | 최초 문서를 두 번호로 만들지 못한다. 둘째 프로필 행은 없다 | | T8 | 번호 교체와 등록인 식별 | `contacts.identity_value` 는 그대로다 | | T9 | `buyer_kind=individual` | 사업자번호가 있어도 `tax_invoice` 를 저장하지 못한다 | | T10 | 여섯 칸을 비움 | `tax_invoice` 인 채로 비우면 저장이 실패하고 저장 전 프로필은 그대로다. `none` 으로 바꾸며 사업자번호를 비우는 저장은 된다. `cash_receipt` 로 바꾸며 비우는 저장은 `cash_receipt_purpose` 와 `cash_receipt_identifier` 가 있을 때만 된다. 법인전환 서류 표는 생기지 않는다 | ## 21. 현금영수증 입력과 식별자 교체 현금영수증 신청의 입력과, 같은 고객의 용도·식별자 교체만 정한다. 12절 일할, 13절 부가세 산식과 문서 대상, 승인번호가 없을 때의 void, 승인번호가 있을 때의 금액 차액, 차액 문서가 원본의 용도와 식별자 암호문을 복사하는 규칙, 17절 암호 알고리즘, 20절의 사업자번호 교체는 그대로다. 국세청 전송 클라이언트는 만들지 않는다. ### 21.1 입력 현금영수증을 고르는 입력에 파일은 없다. 신분증 사본, 사업자등록증 사본, 팩스, 이미지, 심사 상태 칸, 서류 표는 만들지 않는다. 15절이 뺀 서류와 도메인 명의변경 서류도 이 절이 만들지 않는다. `cash_receipt` 는 13절 그대로 `cash_receipt_purpose` 와 `cash_receipt_identifier` 가 있을 때만 고른다. 저장 용도는 `personal` 또는 `business` 뿐이다. `personal` 은 소득공제용이고 `business` 는 지출증빙용이다. `buyer_kind` 가 `individual` 이어도 현금영수증을 고를 수 있다. `business` 용도는 `buyer_kind=business` 를 요구하지 않는다. 그 칸은 `customer_tax_profiles` 한 행이다. 고객당 그 행은 하나다. 식별자 자릿수는 암호화 전 평문에 검사한다. 공백과 하이픈만 뺀다. 뺀 뒤 숫자가 아니면 저장하지 못한다. `personal` 은 11자리이면서 `010`, `011`, `016`, `017`, `018`, `019` 중 하나로 시작하거나, 10자리이면서 `011`, `016`, `017`, `018`, `019` 중 하나로 시작해야 한다. `010` 으로 시작하는 10자리는 저장하지 못한다. `business` 는 10자리다. 체크섬은 검사하지 않는다. 정수로 바꾸지 않는다. 다른 고객과 같은 번호여도 된다. 주민등록번호 형태, 숫자 13자리, `0100001234`, 12자리 이상 카드번호는 저장하지 못한다. 통과한 평문만 17절 암호문으로 넣는다. 평문을 칸에 두지 않는다. `cash_receipt` 가 아니면 용도와 식별자는 null 일 수 있다. ### 21.2 교체 같은 고객의 다른 식별자로 바꾸는 일은 그 한 행의 용도나 식별자 암호문을 고치는 것이다. 새 평문은 새 nonce 로 다시 암호화한다. 고객을 새로 만들지 않고, 프로필 행을 하나 더 만들지 않는다. 예전 식별자의 이력 표는 없다. 예전 식별자는 이미 발행된 문서의 암호문에만 남는다. 문서가 없으면 덮어쓴 식별자는 프로필에 남지 않는다. `cash_receipt` 를 고른 채로 용도나 식별자를 비우거나, 새 용도와 식별자 쌍이 위 자릿수에 맞지 않으면 그 저장은 실패하고 프로필은 그대로다. `evidence_preference` 를 `none` 으로 바꾸는 저장은 용도와 식별자를 비울 수 있다. `tax_invoice` 로 바꾸는 저장은 13절 세금계산서 조건이 있을 때만 용도와 식별자를 비울 수 있고, 그 조건이면 둘을 남겨도 된다. 남겨도 그 프로필의 새 최초 문서는 세금계산서다. 최초 프로필은 13절을 통과할 때만 생긴다. 프로필을 저장해도 문서를 만들지 않고, 이미 있는 문서를 void 하지 않으며, 문서 칸을 고치지 않는다. 아직 문서가 없는 라인의 최초 현금영수증은 그 문서를 만드는 시점의 프로필 용도와 식별자 암호문을 바이트 그대로 복사한다. 발행 시점은 13.4 그대로다. 이 절은 발행 마감일을 만들지 않는다. 이미 있는 문서의 금액 차액은 13.4와 17.6 그대로 원본의 용도와 식별자 암호문을 복사한다. 그 차액은 바꾼 프로필과 `evidence_preference` 를 읽지 않는다. 이미 발행된 현금영수증을 다른 식별자로 바꾸는 문서 쌍은 만들지 않는다. 자진발급 문서를 만들지 않는다. 현금영수증 문서의 사업자 스냅샷은 13.3 그대로 null 이다. 식별자 10자리를 `business_no` 나 `buyer_business_no` 에 복사하지 않는다. 식별자나 용도를 고쳐도 `customers.mobile`, `contacts.identity_value`, `business_no` 는 그대로다. `customers.mobile` 암호문을 식별자 칸으로 복사하지 않는다. 국세청 전송 클라이언트는 만들지 않는다. ### 21.3 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | C1 | 파일 없이 `personal` 과 `01012345678` | `cash_receipt` 를 고를 수 있다. 신분증 파일 칸은 없다 | | C2 | 휴대폰 자릿수 | `010-1234-5678` 의 평문은 `01012345678` 이다. `011-123-4567` 은 `0111234567` 로, `011-1234-5678` 도 저장한다. `0101234567`, `01212345678`, 9자리, 12자리, 13자리, 16자리, `900101-1234567`, `0100001234` 는 저장하지 못한다 | | C3 | `business` 와 `123-45-67890` | 평문은 `1234567890` 이다. 체크섬이 틀린 10자리도 저장한다. 11자리는 저장하지 못하고 저장 전 프로필은 그대로다 | | C4 | 발행된 `01011112222` 를 프로필 `01099998888` 로 교체 | 그 문서 암호문은 그대로다. 고객 행은 그대로고 프로필 행은 하나다 | | C5 | C4 뒤 아직 문서가 없는 라인 | 최초 현금영수증 암호문은 그 시점 프로필 암호문과 같은 바이트다 | | C6 | C4 뒤 옛 문서 라인 환불 | 금액 차액의 용도와 식별자 암호문은 원본과 같다. 프로필을 읽지 않고 다시 암호화하지 않는다 | | C7 | 승인번호 없는 문서가 있을 때 식별자 교체 | 그 문서는 void 되지 않고 암호문도 바뀌지 않는다. 그 void 는 발행분 합계를 문서 대상보다 작게 만들고, 식별자 변경은 13.4의 새 문서를 만들지 않는다 | | C8 | 같은 시각의 두 식별자 | 최초 문서를 두 식별자로 만들지 못한다. 둘째 프로필 행은 없다. 다른 고객이 같은 11자리를 저장하는 것은 된다 | | C9 | 식별자 교체와 회원 휴대폰 | `customers.mobile`, `contacts.identity_value`, `business_no` 는 그대로다. 회원 휴대폰 암호문을 식별자 칸에 복사하지 않는다 | | C10 | `buyer_kind=individual` | `cash_receipt` 를 고를 수 있다. `business` 용도의 10자리는 `business_no` 를 채우지 않는다. 현금영수증 문서의 `buyer_business_no` 는 null 이다. `tax_invoice` 는 저장하지 못한다 | | C11 | 용도와 식별자를 비움 | `cash_receipt` 인 채로 비우면 저장이 실패하고 저장 전 프로필은 그대로다. `none` 으로 바꾸며 비우는 저장은 된다. `tax_invoice` 조건이 있으면 둘을 비우거나 남기고 바꿀 수 있다. 남긴 식별자로 그 새 최초 문서가 현금영수증이 되지는 않는다. 이미 현금영수증이 있는 라인은 세금계산서로 바뀌지 않고, 그 금액 차액도 현금영수증이다 | | C12 | 프로필 저장 | 취소 후 재발행 쌍은 생기지 않는다. 발행 마감일 칸과 자진발급 문서와 국세청 클라이언트는 없다 | ## 22. 호스팅과 옵션을 한 주문으로 신청 kind=hosting 부모와 그 자식 addon 을 한 주문으로 신청하는 형태만 정한다. 도메인과 그 자식은 이 절이 아니다. 12절의 나머지 문장, 산식, S-A부터 S-I는 고치지 않는다. 1개월을 30일로 두거나 12개월을 365일로 두지 않는다. ### 22.1 신청 새 action, 새 테이블, 새 상태값, 새 작업 종류는 없다. 금액은 12.3이다. 신청 기간 할인율, 하위 상품 변경, 월 단위 트래픽 옵션 상품은 만들지 않는다. 트래픽 초과는 16.3이고 이 절의 add 가 아니다. 상품 단계 변경과 수량 변경은 기존 `action=change` 다. 이미 열린 같은 부모·같은 `product_id` 자식이 있으면 add 를 만들지 못한다. 신청은 세 가지다. 모두 한 주문이고 줄은 나뉜다. 기간만. 부모 renew 와, 이미 있고 `expires_at` 이 anchor 보다 이른 자식 renew 다. 12절 그대로다. 새 add 는 없다. 옵션만. 그 주문에 그 부모의 register 또는 renew 줄이 없다. add 의 기본 `period_end` 는 부모의 현재 `expires_at` 이다. 그보다 이른 끝은 허용하고, 그보다 늦은 끝은 금지한다. `period_start` 는 12.2다. S-I는 그대로다. 부모 `expires_at` 이 없으면 이 형태로 add 를 만들지 못한다. 옵션과 기간. 그 주문에 그 부모의 register 또는 renew 줄이 하나이고, add 의 `parent_order_item_id` 가 그 줄이다. add 의 `period_end` 는 그 부모 줄의 `period_end` 이하다. 부모의 현재 `expires_at` 보다 늦어도 된다. 부모 `expires_at` 이 없어도 된다. 그 부모 줄의 `period_end` 보다 늦거나 그 줄에 `period_end` 가 없으면 주문을 만들지 않는다. 이른 끝은 허용한다. 그 부모의 register 또는 renew 줄이 둘이면 주문을 만들지 않는다. `parent_order_item_id` 가 비었거나, 다른 주문의 줄이거나, register 또는 renew 가 아니면 이 예외를 쓰지 못한다. 부모 `expires_at` 이 있으면 그때는 옵션만의 끝 규칙이다. 옵션과 기간에서 부모 줄이 renew 이면 add 의 `period_start` 는 12.2 그대로다. 그 renew 의 `period_start` 로 바꾸지 않는다. 부모 줄이 register 이면 두 줄의 `period_start` 시각은 같다. register 의 `period_start` 가 비어 있으면 둘 다 Asia/Seoul 주문 생성 시각으로 두고 그 값을 저장한다. add 의 기본 `period_end` 는 그 register 줄의 `period_end` 다. 시작을 그 시각보다 뒤로 미루지 못한다. 호스팅 서비스 행이 생기기 전의 첫 주문도 이 형태다. 개통 뒤에만 옵션을 받는 제한은 없다. add 는 부모 `expires_at` 을 스스로 늘리지 않는다. 부모 끝을 늘리는 것은 그 부모 줄의 이행뿐이다. ### 22.2 이행 아직 이행되지 않은 add 는 `services` 행을 만들지 않는다. `uq_open_addon` 슬롯을 잡지 않는다. 그 add 의 `service_id` 는 이행 때 채운다. 이행 트랜잭션은 부모 서비스 행을 잠그거나, 부모 행이 없으면 그 register 줄을 잠근다. 같은 트랜잭션에서 부모 register 또는 renew 줄이 함께 `paid` 이면 그 부모 줄을 먼저 이행한다. register 이행이 부모 서비스 행을 만들고 `expires_at` 을 그 줄의 `period_end` 로 둔다. 새로 만드는 서비스 행의 status 는 `active` 다. 이미 있는 부모의 status 는 바꾸지 않는다. 그 다음 부모 `expires_at` 이 add 의 `period_end` 이상이고, 같은 부모·같은 `product_id` 의 열린 자식이 없으면, 자식 서비스 행을 하나 만들고 `parent_service_id` 를 두고 `expires_at` 과 `next_due_at` 을 `period_end` 로 두고 줄의 `service_id` 를 채운 뒤 `fulfilled` 로 둔다. 부모 `expires_at` 이 그보다 이르거나 부모 서비스 행이 없거나 열린 자식이 이미 있으면 자식 행을 만들지 않는다. 열린 자식이 이미 있으면 그 add 는 환불한다. 부모 `expires_at` 이 이르거나 부모 행이 없으면 줄은 `paid` 인 채로 둔다. 12.4의 환불·취소 비교는 그대로다. 부모 서비스 행이 없는데 부모 register 줄이 이행 전에 `cancelled` 또는 `refunded` 이면 미이행 add 를 환불한다. 12.5는 그대로다. 이 주문의 긴 기간을 다음 자동 연장 기간으로 쓰지 않는다. 계정 개통과 패널은 register 이행이고 이 절이 작업 종류를 더하지 않는다. 무과금 구성은 `order_items.config` 다. 자식 `expires_at` 은 저장되는 순간 부모 `expires_at` 을 넘지 않는다. ### 22.3 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | A1 | 첫 호스팅과 디스크를 한 주문 | register `period_start` 는 2026-10-07 15:00 Asia/Seoul, `period_end` 는 2027-10-07 15:00 이다. 디스크 add 의 `parent_order_item_id` 는 그 register 이고 두 시각이 같다. 디스크 가격은 `addon_add`, `year`, `period_count` 1, `sell_amount` 12000, quantity 1 이다. 12.3의 정수 나눗셈 (12 × 12000 + 6) / 12 는 12000 이다. `amount` 는 12000 이다. add 줄은 달력 월이므로 `period_unit=month`, `period_count=12` 다. 결제 전에는 부모·자식 서비스 행이 없다 | | A2 | A1의 두 줄이 같이 `paid` | register 를 먼저 이행해 부모 행의 status 는 `active` 이고 `expires_at` 은 2027-10-07 15:00 이다. 그 다음 디스크 행 하나를 만들고 `fulfilled` 다. 자식 `expires_at` 은 부모를 넘지 않는다 | | A3 | 디스크만 `paid`, register 는 `pending` | 디스크 서비스 행은 없다. 줄은 `paid` 이고 `fulfilled` 가 아니다 | | A4 | A3 뒤 register 가 이행 전에 `cancelled` | 부모 서비스 행이 없다. 미이행 디스크 줄은 환불된다 | | A5 | 사용 중인 호스팅을 늘리며 디스크를 그 끝까지 | 호스팅 `expires_at` 은 2027-06-01 00:00 이다. 주문 생성일은 2027-03-01 이다. 부모 renew 의 `period_end` 는 2028-06-01 00:00 이다. 디스크 add 의 `parent_order_item_id` 는 그 renew 다. `period_start` 는 2027-03-01 00:00 이고 `period_end` 는 2028-06-01 00:00 이다. renew 의 `period_start` 인 2027-06-01 로 바꾸지 않는다. 월 가격 `sell_amount` 9000, `month`, `period_count` 1, quantity 1 이면 N=15, M=1 이고 `amount` 는 135000 이다 | | A6 | A5보다 디스크 끝만 이르게 | 디스크 `period_end` 만 2027-09-01 이다. 부모 줄의 끝보다 이르고 현재 `expires_at` 보다 늦다. 주문을 만든다. N=6 이고 `amount` 는 54000 이다 | | A7 | 디스크 끝이 부모 줄보다 늦음 | `period_end` 가 2028-07-01 이면 주문을 만들지 않는다 | | A8 | 부모 줄이 없거나 연결이 없음 | 부모 줄이 없고 디스크 `period_end` 가 2028-06-01 이며 현재 `expires_at` 이 2027-06-01 이면 주문을 만들지 않는다. 같은 주문에 renew 가 있어도 `parent_order_item_id` 가 비면 그 늦은 끝을 쓰지 못한다 | | A9 | 이미 있는 디스크의 기간만 | 디스크가 2027-03-01 에 끝나고 호스팅이 2027-06-01 이며 anchor 가 2028-06-01 이다. renew 두 줄이고 add 는 없다. S-A 그대로다 | | A10 | 같은 디스크를 또 add | 열린 디스크 자식이 있는데 같은 `product_id` 로 add 를 만들지 못한다. 수량을 40에서 60으로 바꾸는 것은 `action=change` 다. 이행 시점에 열린 자식이 이미 있으면 그 add 는 환불하고 자식 행을 만들지 않는다 | | A11 | 트래픽과 구성 | `usage` 줄은 `expires_at` 을 바꾸지 않는다. 이 절의 add 가 아니다. 월 단위 트래픽 옵션 상품, 신청 기간 할인율, 30일 달, 하위 상품 변경 경로는 없다. OS 는 `config` 다 | | A12 | A5에서 디스크만 입금 | 부모 `expires_at` 이 `period_end` 보다 이르므로 디스크는 `fulfilled` 가 아니다. add 가 부모 `expires_at` 을 먼저 밀지 않는다. 부모 renew 가 나중에 이행되면 그때 12.4대로 디스크를 이행한다 | | A13 | 기존 디스크 연장과 새 메일함을 한 주문 | 호스팅과 기존 디스크가 둘 다 2027-06-01 에 끝난다. 주문일은 2027-03-01 이고 anchor 는 2028-06-01 이다. 메일함은 아직 없다. 줄은 호스팅 renew, 디스크 renew, 메일함 add 다. 메일함의 `parent_order_item_id` 는 호스팅 renew 다. 메일함 `period_start` 는 2027-03-01 00:00 이고 `period_end` 는 2028-06-01 00:00 이다. 디스크 renew 의 `period_start` 는 2027-06-01 이다 | ## 23. 호스팅 중도 해지의 환불 금액 kind=hosting 부모와 그 자식 addon 의 이미 이행된 반복 과금 기간을 줄일 때의 환불 금액만 정한다. 도메인 등록·갱신·이전 줄, WHOIS, `usage` 줄, `kind=server` 는 이 절이 아니다. 그 금액은 25절이다. 12.3 산식과 22절은 고치지 않는다. 1개월을 30일로 두지 않는다. ### 23.1 금액 새 action, 새 테이블, 새 상태값은 없다. 나눗셈은 12.3이다. 잔액의 90%, 잔여의 80%, 10% 위약금, 할인 환수, 서버형 월 단위 올림은 만들지 않는다. 기간 할인표도 없다. 줄인 뒤 그 줄에 남는 구간을 유지 구간으로 둔다. 유지 금액은 24절이다. 지금 가격표를 읽지 않는다. 환불액은 그 줄의 13절 남은 환불 한도 안에서 (그 줄의 `amount` − 유지 금액) 이다. 유지 구간이 없으면 남은 한도 전부다. 유지 금액이 그 줄의 `amount` 이상이면 환불액은 0이다. 한도를 넘기지 못한다. `prices.period_unit=once` 이거나 그 줄의 가격이 `once` 인 줄은 환불액 0이다. 설치비를 반복 과금 줄의 `amount` 에 섞지 않는다. 서비스 `started_at` 이 있고, 새 끝이 그 시각의 Asia/Seoul 날짜에 7일을 더한 같은 시각보다 이르며, 그 시각 이후의 권리가 없으면 그 서비스의 반복 과금 줄은 이 일할을 쓰지 않고 남은 환불 한도 전부를 환불한다. `once` 줄은 그때도 0이다. `started_at` 이 없으면 이 7일 규칙을 쓰지 않는다. 서비스를 새 끝 이후로 남겨 두는 단축도 7일 규칙을 쓰지 않고 유지 금액만 뺀다. 7일은 신규 `started_at` 기준이고 갱신 시각으로 다시 세지 않는다. 차감 줄의 `amount` 는 0이다. 생성 즉시 `paid` 다. 12.1에서 비례 금액이 0이면 줄을 만들지 않는다는 문장은 그 절의 renew 와 add 다. 이 0원 차감에는 적용하지 않는다. 원 줄의 `amount`, `period_start`, `period_end` 는 고치지 않는다. 차감 줄의 `period_start` 는 새 끝이고 `period_end` 는 줄이기 전의 `expires_at` 이다. 이행은 `expires_at` 과 `next_due_at` 을 그 `period_start` 로 둔다. 12.4가 renew 와 add 의 `expires_at` 을 `period_end` 로 두는 문장은 차감에 쓰지 않는다. 돈은 `refund_lines` 다. 그 줄들은 차감 줄이 아니라 원래 낸 줄을 가리킨다. 차감과 같은 트랜잭션에 만든다. `action=credit` 은 예치금 행을 만들지 않는다. `refunds.method=balance` 일 때만 13절의 양수 예치금 행이 생긴다. 권리의 `expires_at` 변경은 환불 `status=completed` 를 기다리지 않는다. 한도 검사는 환불 행을 만들 때 한다. 지급 기한 칸은 없다. 부모를 자식보다 짧게 줄이는 순서와 거부는 12.4와 S-H 그대로다. 각 환불액은 자기 줄로 계산한다. 아직 이행되지 않은 유료 줄의 취소 환불은 그 줄의 남은 한도 전부이고, 이 절의 유지 금액 공제를 하지 않는다. 부가세는 13절이다. ### 23.2 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | R1 | 12개월 108000 중 9개월 유지 | 유지 금액은 81000 이고 환불액은 27000 이다. 24300 이 아니다 | | R2 | 2027-03-01 부터 2027-04-01, 9000원, 새 끝 2027-03-11 00:00 | 유지 금액은 2903 이고 환불액은 6097 이다. 30으로 나눈 사용분 3000 과 잔액 6000 이 아니다 | | R3 | 디스크 2027-03-01 부터 2028-06-01, 135000원, 새 끝 2027-06-01 | 환불액은 108000 이다 | | R4 | 시작 2027-03-01, 2027-03-05 에 해지 | 반복 과금 줄의 환불액은 남은 한도 전부다. `once` 설치비 11000 은 0 이다. 사용한 4일을 12.3으로 빼지 않는다 | | R5 | 같은 시각이지만 끝은 2027-06-01 로 남김 | 7일 전액을 쓰지 않고 유지 금액을 뺀다 | | R6 | `once` 줄 | 해지 시각과 관계없이 환불액 0 이다 | | R7 | 108000 중 이미 환불 27000 | 남은 한도는 81000 이다. 유지 금액이 0이어도 환불액은 81000 을 넘지 못한다 | | R8 | 차감 줄 | `amount` 는 0 이고 생성 즉시 `paid` 다. 원 줄의 기간과 금액은 그대로다. 예치금 행은 `method=balance` 가 아니면 생기지 않는다. 원 줄은 `refund_lines` 가 가리킨다 | | R9 | 부모를 자식보다 짧게 | 자식을 먼저 그 끝 이하로 줄이거나 부모 차감을 거부한다. 디스크 환불은 디스크 줄의 유지 금액으로 계산한다 | | R10 | 범위 밖 | `usage` 줄, 도메인 등록·갱신·이전 줄, WHOIS 줄은 이 절로 금액을 만들지 않는다. 서버 상품을 월 단위로 올려 받는 규칙도 없다 | | R11 | 가져오지 않는 환불 | 30일 고정분, 잔액의 90%, 잔여의 80%, 10% 위약금, 할인 환수, 기간 할인표, 15일 지급 기한 칸은 없다 | | R12 | 아직 이행되지 않은 디스크 | 남은 한도 전부다. 사용분 공제를 하지 않는다 | | R13 | `started_at` 이 없음 | 7일 전액을 쓰지 않는다. 유지 금액을 뺀다. 갱신 뒤에 7일이 지나지 않았어도 최초 `started_at` 으로부터 7일이 지났으면 전액을 쓰지 않는다 | ## 24. 검토에서 닫은 구현 구멍 이 절은 5.2의 헤더 표가 겹칠 때, 5.3의 정산 합이 대기 결제를 셀 때, 16.2가 카드로 줄 금액 전체를 낼 때, 23.1이 줄에 없는 `recurring_amount` 로 유지 금액을 계산할 때 우선한다. 12.3의 R(P) 문장은 고치지 않는다. 새 action, 새 테이블, 새 상태값은 없다. ### 24.1 상태 살아 있는 줄은 `status` 가 `pending`, `paid`, `fulfilled` 인 줄이다. `cancelled` 와 `refunded` 는 살아 있지 않다. `order_items.status=failed` 는 저장하지 못한다. 개통이 실패하면 줄은 `paid` 로 남고 같은 작업을 재시도한다. 주문 헤더는 이 순서로 처음 참인 것만 고른다. 살아 있는 줄이 없고 모든 줄이 `cancelled` 이면 `cancelled` 다. 살아 있는 줄이 없고 `refunded` 가 하나라도 있으면 `fulfilled` 다. 살아 있는 줄이 모두 `fulfilled` 이면 `fulfilled` 다. 살아 있는 줄 중 `fulfilled` 가 하나라도 있으면 `partially_fulfilled` 다. 살아 있는 줄 중 `paid` 가 하나라도 있으면 `paid` 다. 그 외는 `awaiting_payment` 다. 거절되지 않은 환불 합이 그 줄의 `amount` 와 같고 그 줄이 아직 `fulfilled` 가 아니면 `status=refunded` 다. `fulfilled` 인 줄은 환불이 있어도 `fulfilled` 로 남는다. 그래서 `uq_paid_period` 는 풀리지 않는다. `register` 또는 `transfer_in` 줄이 `fulfilled` 가 되는 트랜잭션은 `transferred_out` 과 `deleted` 가 아닌 `domains` 행을 함께 연결한다. 그 다음의 이름은 `uq_live_domain_name` 이 잠그고, `uq_paid_register_name` 은 `paid` 만 잠근다. ### 24.2 결제와 예치금 `settled_amount` 는 그 줄의 `payment_allocations` 가운데 `payment.status=confirmed` 인 금액의 합에, 그 줄의 음수 예치금 절대값 합을 더한 것이다. `pending` 결제의 배분은 줄을 `paid` 로 만들지 않는다. 결제를 `pending` 으로 만들면서 그 결제가 갚을 줄에 배분을 함께 둘 수 있다. 배분이 없는 `pending` 카드 결제는 15.6 충전이다. 주문에 달린 카드 결제인지는 그 주문의 줄로 배분이 있는지로 본다. `failed` 가 되는 트랜잭션은 그 결제의 배분을 지운다. `confirmed` 배분은 지우지 못한다. `renew` 줄을 만드는 트랜잭션은 그 고객의 예치금 잔액이 0보다 크면 음수 예치금 행으로 그 줄의 남은 `amount` 까지 먼저 쓴다. 한 주문에 `renew` 줄이 여럿이면 부모 줄부터 쓰고, 부모 줄이 아니면 줄 id 가 작은 쪽부터 남은 잔액만 쓴다. 16.2의 카드 결제 금액은 `amount` 에서 `settled_amount` 를 뺀 값이다. 0이면 카드 결제를 만들지 않는다. 카드가 `failed` 가 되어도 그 음수 행은 남고 새 예치금 행은 만들지 않는다. 줄이 `pending` 인 채 `cancelled` 가 되면 그 줄의 음수 예치금 행을 지운다. ### 24.3 자동 갱신 자동 `renew` 의 `price_id` 는 그 서비스에서 `period_end` 가 가장 늦은 `fulfilled` 인 `register`, `renew`, `transfer_in` 줄의 `price_id` 다. 그 행이 현재 가격이면 새 기간은 그 행의 `period_unit` 과 `period_count` 다. 맞춤 줄의 `period_count` 가 아니다. 현재 가격은 `effective_from` 이 지금 이전이고 `effective_to` 가 비었거나 지금보다 늦은 행이다. 그 행이 현재가 아니면 그 행과 같은 `action`, `period_unit`, `period_count` 의 현재 가격이 하나일 때만 쓴다. `price_id` 가 없으면 그 줄의 `period_unit` 이 `month` 또는 `year` 일 때만, 그 줄과 같은 `action`, `period_unit`, `period_count` 의 현재 가격이 하나일 때 쓴다. 그 줄의 `period_unit` 이 `day` 이고 `price_id` 가 없으면 자동 주문을 만들지 않는다. 후보가 없거나 둘이면 주문을 만들지 않는다. 자식의 그 새 `period_end` 가 부모 `expires_at` 보다 늦으면, `kind=hosting` 의 addon 만 끝을 부모 `expires_at` 으로 두고 금액은 12.3이다. 그 끝이 자식의 현재 `expires_at` 과 같거나 부모 `expires_at` 이 없으면 그 자동 주문을 만들지 않는다. 그 밖의 자식은 부모보다 늦은 자동 `renew` 를 만들지 않고, 12.3으로 자르지 않는다. ### 24.4 환불 23절의 유지 금액은 그 줄의 `amount` 만 비례한다. `price_id` 와 `services.recurring_amount` 와 지금 가격표는 읽지 않는다. 그 서비스에서 새 끝보다 `period_end` 가 늦은 이행된 반복 과금 줄마다 계산한다. 새 끝이 그 줄의 `period_start` 보다 이르거나 같으면 유지 금액은 0이다. 새 끝이 그 줄의 `period_end` 보다 늦거나 같으면 유지 금액은 그 줄의 `amount` 다. 그 줄이 정확히 N개월이고 새 끝도 그 시작부터 정확히 K개월이면 유지 금액은 (K * `amount` + N/2) / N 의 정수 나눗셈이다. 그렇지 않으면 num 은 유지 구간의 초, den 은 그 줄 전체의 초이고 유지 금액은 (num * `amount` + den/2) / den 의 정수 나눗셈이다. 환불액은 그 다음 23절의 한도 적용이다. R1 은 27000, R2 는 6097, R3 은 108000 그대로다. 환불액은 카드로 확인된 배분의 남은 한도까지 `method=card` 인 환불 행으로 두고, 나머지는 그 차감이 고른 `bank` 또는 `balance` 하나의 환불 행으로 둔다. 카드 몫이 0이면 카드 행은 없다. `refunds.amount` 는 그 행의 `refund_lines` 합과 같다. 23절 환불 행은 `credit` 과 같은 트랜잭션에서 `status=approved` 로 만든다. 그 트랜잭션에서 `credit` 을 `fulfilled` 하고 `expires_at` 을 바꾼다. 그 환불은 `rejected` 로 바꾸지 못한다. `completed` 는 지급이 끝난 표시다. `cancel_at` 과 `auto_renew=false` 만 두는 해지 예약은 `credit` 과 환불 행을 만들지 않고 `expires_at` 을 바꾸지 않는다. 서비스 행을 처음 만들 때 `started_at` 이 비어 있으면 그 줄의 `period_start` 로 둔다. 호스팅 `register` 이행은 `next_due_at` 을 `period_end` 로 두고 그 줄을 `fulfilled` 로 둔다. `renew` 와 `credit` 은 `started_at` 을 바꾸지 않는다. ### 24.5 수량, 도메인 옵션, 문서, 명의변경 `kind=hosting` addon 의 `quantity` 가 늘어나는 `action=change` 만 금액을 정한다. 주문 생성 시각부터 그 서비스의 현재 `expires_at` 까지가 기간이고, 늘어난 수량에 12.3을 적용한 값이 `amount` 다. 그 트랜잭션에서 `quantity` 를 바꾸고 `recurring_amount` 를 null 로 둔다. `discount_percent` 가 없으면 `discount_until` 도 null 로 둔다. `discount_percent` 가 있으면 `discount_percent` 와 `discount_until` 은 그대로다. `discount_percent` 가 있으면 그 단가는 27절이다. 수량을 줄이는 `change` 는 만들지 못한다. `product_id` 를 바꾸는 `change` 는 25절만 만든다. `from_product_id` 칸은 남는다. `kind=domain` 부모의 `register`, `transfer_in`, `renew` 와 그 addon 자식을 한 주문에 넣을 때 자식의 `parent_order_item_id` 는 그 부모 줄이다. 두 줄의 `period_start` 와 `period_end` 는 같다. 금액은 각 카탈로그 가격이고 12.3을 쓰지 않는다. 부모 줄을 먼저 이행하고, 부모 `expires_at` 이 자식 `period_end` 이상일 때만 자식을 이행한다. 최초 세금 문서는 줄이 `paid`, `fulfilled`, `refunded` 가 되는 트랜잭션에서 13.4 조건이 맞으면 함께 만든다. 그때 조건이 없어 못 만든 줄은, 프로필 저장이 아닌 발행 트랜잭션에서만 같은 규칙으로 만든다. 프로필 저장은 문서를 만들지 않는다. 명의변경으로 만든 새 서비스는 옛 `status`, `product_id`, `quantity`, `recurring_amount`, `included_traffic_bytes`, `started_at`, `discount_percent`, `discount_until` 을 복사한다. `cancel_at` 은 복사하지 않는다. 옛 서비스는 15.2대로 `terminated` 다. ### 24.6 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | H1 | 25줄이 `fulfilled` 이고 5줄이 `pending` | 헤더는 `partially_fulfilled` 다. `paid` 가 아니다 | | H2 | 모든 살아 있는 줄이 `fulfilled`. 또는 한 줄이 `paid` 이고 한 줄이 `pending` | 모두 `fulfilled` 이면 헤더는 `fulfilled` 다. `paid` 와 `pending` 이면 헤더는 `paid` 다 | | H3 | 모든 줄이 `refunded`. 또는 모든 줄이 `cancelled` | `refunded` 뿐이면 헤더는 `fulfilled` 다. `cancelled` 뿐이면 헤더는 `cancelled` 다 | | H4 | 무통장 결제가 `pending` 이고 그 줄에 배분이 있음 | 줄은 `pending` 이다. `confirmed` 가 된 뒤에야 `settled_amount` 에 들어간다. `failed` 가 되면 그 배분은 지워지고 줄은 `pending` 이다 | | H5 | `pending` 카드 결제 | 배분이 없으면 충전이다. 줄에 배분이 있으면 그 주문의 결제다 | | H6 | 예치금 2000, `renew` 줄 `amount` 10000. 또는 예치금 10000 | 줄을 만들 때 음수 2000 을 두고 카드는 8000 이다. 예치금이 10000 이면 카드 결제는 없다. 카드가 `failed` 여도 음수 2000 은 남고 새 예치금 행은 없다. 그 줄이 `pending` 인 채 `cancelled` 이면 음수 행은 지워진다 | | H7 | 한 주문에 부모 `renew` 10000 과 자식 `renew` 3000, 예치금 11000 | 부모가 10000 을 쓰고 자식이 1000 을 쓴다 | | H8 | 디스크의 마지막 `fulfilled` 줄의 `price_id` 가 월 1개월 | 자동 `renew` 는 1개월이다. 맞춤 줄의 15개월이 아니다. 그 끝이 부모 `expires_at` 보다 이르면 12.3으로 자르지 않는다 | | H9 | 그 1개월 끝이 부모 `expires_at` 보다 늦음. 또는 WHOIS 가 도메인보다 늦음 | 디스크 끝은 부모 `expires_at` 이고 금액은 12.3이다. 자식 `expires_at` 이 이미 부모와 같으면 자동 주문이 없다. WHOIS 자동 `renew` 가 도메인 `expires_at` 보다 늦으면 주문이 없고 12.3을 쓰지 않는다 | | H10 | 마지막 `fulfilled` 줄이 `renew`, `month`, `period_count` 1 이고 `price_id` 가 없음 | 현재 월 1개월 가격이 하나일 때 그 가격으로 자동 주문한다. 년 가격은 후보가 아니다. 현재 월 1개월 가격이 둘이면 자동 주문이 없다. `price_id` 가 없고 그 줄의 `period_unit` 이 `day` 이면 자동 주문이 없다 | | H11 | 줄 `amount` 9000, 2027-03-01 부터 2027-04-01, 새 끝 2027-03-11 00:00 | 유지 금액은 2903 이고 환불액은 6097 이다. 그 사이 `services.recurring_amount` 가 바뀌어도 같다 | | H12 | 12개월 108000 뒤에 12개월 108000, 새 끝이 앞 줄의 6개월 | 앞 줄 환불은 54000 이고 뒷 줄 환불은 108000 이다 | | H13 | 카드 배분 4000 과 무통장 배분 6000 | 환불액 6097 이면 카드 환불 행 4000 과 무통장 환불 행 2097 이다. 환불액 3000 이면 카드 행 3000 만 있다 | | H14 | `credit` 과 환불을 한 트랜잭션에 만듦. 또는 `cancel_at` 만 둠 | 환불 `status=approved` 이고 `credit` 은 `fulfilled` 다. 그 환불은 `rejected` 가 되지 못한다. `cancel_at` 만 두면 환불 행이 없고 `expires_at` 도 그대로다 | | H15 | 호스팅 `register` 의 `period_start` 가 2026-10-07 15:00 | 그 이행이 `started_at` 을 그 시각으로 두고 줄을 `fulfilled` 로 둔다. 다음 `renew` 는 `started_at` 을 바꾸지 않는다. 7일 전액은 그 처음 시각으로 센다 | | H16 | 미이행 `register` 전액 환불. 또는 `fulfilled` 인 `renew` 전액 환불 | 미이행 `register` 는 `refunded` 다. 도메인 행은 없다. `fulfilled` 인 `renew` 는 `fulfilled` 로 남는다 | | H17 | 디스크 월 `sell_amount` 100, `quantity` 40 에서 60, 남은 기간이 정확히 1개월 | `change` 의 `amount` 는 2000 이고 `recurring_amount` 는 null 이다. 수량을 줄이거나 `product_id` 를 바꾸는 `change` 는 없다 | | H18 | 도메인 등록 15000 과 WHOIS 3000 을 한 주문에 넣음 | 기간은 같고 금액은 그 카탈로그 가격이다. 12.3으로 3000 을 나누지 않는다. 도메인 줄을 먼저 이행한다 | | H19 | 프로필 없이 `paid` 된 줄 | 프로필을 저장해도 문서가 없다. 그 뒤 발행 트랜잭션에서 13.4 조건이 맞으면 최초 문서가 생긴다 | | H20 | 명의변경의 새 서비스 | 옛 `started_at` 을 복사하고 `cancel_at` 은 비어 있다. 옛 서비스는 `terminated` 다 | ## 25. 도메인, 비공개, 사용량, 그 밖 상품의 금액 이 절은 23절이 도메인·WHOIS·`usage`·`server` 금액을 비울 때, 16.3이 사용량 정정 금액을 비울 때, 24.5가 `product_id` 변경 줄을 막을 때 우선한다. 12.3의 R(P) 문장과 24절의 유지 금액 문장은 고치지 않는다. 새 action, 새 테이블, 새 상태값, 새 칸은 없다. 1개월을 30일로 두지 않는다. 잔액의 90%, 잔여의 80%, 10% 위약금, 기간 할인표, 설치비 차액은 없다. ### 25.1 도메인 미이행 `register`, `renew`, `transfer_in` 을 취소하면 환불액은 그 줄의 13절 남은 한도 전부다. 도메인 행은 만들지 않는다. 이행된 줄의 환불액은 이름 마지막 라벨로 갈린다. 마지막 라벨이 `kr` 또는 `xn--3e0b707e` 이면 대한민국 국가도메인이다. 그 줄의 `period_start` 로부터 Asia/Seoul 달력 7일 뒤 같은 시각까지(그 시각 포함) 환불액은 남은 한도 전부다. 그 시각보다 늦고 그 줄이 정확히 N년(`period_end` 가 `period_start` 에 12×N 달을 더한 시각)이면, 삭제 시각을 포함하는 가장 작은 해 수 K 를 유지 년으로 둔다. K 가 N 이상이면 환불액은 0이다. 아니면 유지 금액은 (K × `amount` + N/2) / N 의 정수 나눗셈이고 환불액은 남은 한도 안에서 (`amount` − 유지 금액) 이다. 정확히 N년이 아니면 7일 뒤 환불액은 0이다. 아직 시작하지 않은 줄(삭제 시각이 `period_start` 보다 이르면)은 유지 금액 0이다. 그 밖 마지막 라벨 가운데 ASCII 두 글자 라벨은 국가도메인이라 이행된 `register`, `renew`, `transfer_in` 모두 환불액 0이고, 두 글자가 아닌 라벨의 이행된 `register` 만 `period_start` 로부터 120시간 이내(그 시각 포함) 남은 한도 전부이며 그 뒤와 그 라벨의 `renew`, `transfer_in` 은 0이다. 환불액이 0이면 환불 행을 만들지 않는다. 등록 취소는 돈과 같은 트랜잭션에서 `lifecycle_status` 를 `deleted` 로 두고, 열린 `domain_services` 를 끊은 뒤 도메인 서비스와 그 프라이버시 자식을 `terminated` 로 둔다. 갱신 취소는 그 서비스에서 `period_end` 가 가장 늦은 이행된 `renew` 또는 `transfer_in` 한 줄만 된다. 대한민국 국가도메인이고 그 줄의 7일 이내이며 환불액이 남은 한도 전부일 때만 `expires_at` 과 도메인 `expires_at` 을 그 줄의 `period_start` 로 되돌린다. 도메인 행은 지우지 않는다. 그 외 갱신 취소는 만료일을 바꾸지 않는다. 환불 행이 생기면 24절과 같이 `credit` 과 같은 트랜잭션에서 `status=approved` 이고 거절하지 못한다. 카드 남은 한도를 먼저 `method=card` 로 두고 나머지는 `bank` 또는 `balance` 한 행이다. ### 25.2 WHOIS 도메인을 유지한 채 프라이버시 자식만 줄이면 24절 유지 금액으로 그 자식의 이행된 반복 과금 줄만 계산한다. 도메인 줄의 환불액은 0이다. 7일 전액과 12.3 가격표 재독은 쓰지 않는다. `credit` 의 `amount` 는 0이고 생성 즉시 `paid` 다. 이행은 그 자식의 `expires_at` 과 `next_due_at` 을 새 끝으로 두고 자식을 `terminated` 로 둔다. 도메인 서비스와 도메인 행은 유지한다. `refund_lines` 는 그 비공개 줄을 가리킨다. 등록 취소 트랜잭션에서 도메인 줄이 남은 한도 전부로 환불되면, 그 도메인의 이행된 프라이버시 줄도 남은 한도 전부다. 도메인 줄이 일부만 환불되면 프라이버시 줄은 삭제 시각을 새 끝으로 한 24절 유지 금액이다. Premium DNS 의 중도 해지 금액은 이 절이 정하지 않는다. ### 25.3 사용량 정산된 `usage` 줄의 정정은 `action=credit` 하나다. 단위 수는 `billed_bytes` 를 1073741824 로 나눈 올림이다. 고친 단위는 0 이상이고 그 단위 수보다 작은 정수이며 `credit` 줄의 `config.corrected_units` 에 적는다. 환불액은 (줄어든 단위 × 그 줄의 `amount`) / 원래 단위 의 정수 나눗셈을 13절 남은 한도로 자른 값이다. 원래 단위가 0이거나 `amount` 가 원래 단위로 나누어 떨어지지 않으면 줄을 만들지 못한다. 단위를 늘리는 정정은 없다. `billed_bytes` 와 `expires_at` 은 그대로다. `credit` 의 `amount` 는 0이고 그 트랜잭션에서 `fulfilled` 다. 23절의 `once` 환불 0 은 이 정정에 쓰지 않는다. `refund_lines` 는 그 `usage` 줄을 가리킨다. ### 25.4 메일, 서버, DBMS, 보안, DNS 이 다섯 `kind` 의 이미 이행된 반복 과금 줄을 줄이는 환불만 정한다. `cancel_at` 만 두는 예약은 환불도 `expires_at` 변경도 없다. `credit` 의 `amount` 는 0이다. 메일, DBMS, 보안, DNS 의 유지 금액은 24절과 같다. 7일 전액은 쓰지 않는다. `expires_at` 이 null 이면 줄을 만들지 못한다. 서버는 그 줄이 정확히 N개월일 때만 월로 올린다. 새 끝을 포함하는 가장 작은 달 수 K 가 유지 달이다. K 가 N 이상이면 환불액은 0이다. 아니면 유지 금액은 (K × `amount` + N/2) / N 의 정수 나눗셈이다. 정확히 N개월이 아니면 환불액은 0이다. 말일 자르기와 초 단위 유지는 서버에 쓰지 않는다. 환불액은 그 다음 23절 한도와 24절의 카드 우선 분배다. 환불 행은 `credit` 과 같은 트랜잭션에서 `approved` 이고 거절하지 못한다. 이행은 `expires_at` 과 `next_due_at` 을 유지 구간의 끝으로 둔다. ### 25.5 호스팅 상품 변경 `kind=hosting` 부모의 `product_id` 가 바뀌는 `action=change` 만 만든다. 기간은 주문 생성 시각부터 현재 `expires_at` 까지다. 그 끝이 시작보다 늦지 않으면 만들지 못한다. 옛 상품과 새 상품에서 `action=renew` 이고 `period_unit` 과 `period_count` 가 같은 현재 가격이 각각 하나일 때만 만든다. 차액은 새 `sell_amount` − 옛 `sell_amount` 다. 차액이 0 이하이면 만들지 못한다. `amount` 는 그 차액에 12.3을 적용한 값이다. 수량으로 다시 곱하지 않는다. 그 줄이 이행되는 트랜잭션에서 `product_id` 를 새 상품으로 두고 `from_product_id` 에 옛 상품을 두고 `recurring_amount` 를 null 로 둔다. `discount_percent` 와 `discount_until` 도 null 이다. 차액은 할인 전 `sell_amount` 다. `expires_at` 은 그대로다. 이후 자동 `renew` 의 `price_id` 는 이 줄의 `price_id` 가 24절의 마지막 `register`, `renew`, `transfer_in` 줄보다 우선한다. 그 행이 현재가 아니면 24절의 후보 규칙으로 하나일 때만 쓴다. 서버, 메일, DBMS, 보안, DNS, addon 의 `product_id` 변경 줄은 만들지 못한다. 수량을 줄이는 `change` 도 만들지 못한다. ### 25.6 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | F1 | 마지막 라벨 `kr`, `register` 시작 2026-10-07 15:00, 1년 15000, 2026-10-14 15:00 등록 취소 | 환불 15000. 도메인은 `deleted`. 연결과 도메인 서비스는 끊긴다 | | F2 | F1을 2026-10-14 15:01에 등록 취소 | 환불 0. 환불 행은 없다. 도메인은 `deleted` 다 | | F3 | `kr` 등록 3년 45000, 2026-10-07 15:00 부터 2029-10-07 15:00, 2026-11-07 등록 취소 | 유지 15000, 환불 30000 | | F4 | F3을 2027-11-07에 등록 취소 | 유지 30000, 환불 15000 | | F5 | `com` 등록, 시작부터 120시간 | 환불은 남은 한도 전부다. 120시간 1초 뒤는 남은 해가 있어도 0이다 | | F6 | `com` 의 이행된 `renew` | 시작 직후여도 환불 0. 만료일은 그대로다 | | F7 | `kr` `renew` 시작 2027-10-07 15:00, 15000, 2027-10-08 갱신 취소, 그 줄이 가장 늦은 이행 줄 | 환불 15000. `expires_at` 은 2027-10-07 15:00. 도메인 행은 남아 있다 | | F8 | 마지막 라벨 `jp` 의 이행된 `register`, `renew`, `transfer_in` | 환불 0. 120시간을 쓰지 않는다 | | F9 | 미이행 `register` 취소 | 환불은 남은 한도 전부. 도메인 행은 없다 | | F10 | WHOIS 12개월 3000 중 9개월 유지, 도메인 유지 | 유지 2250, 환불 750. 도메인 15000 은 0이다 | | F11 | WHOIS 3000, 2027-03-01 부터 2027-04-01, 새 끝 2027-03-11 00:00, 도메인 유지 | 유지 968, 환불 2032 | | F12 | F1과 같은 트랜잭션의 WHOIS 3000 | 비공개도 3000. 자식은 `terminated` 다 | | F13 | `usage` 단위 10, `amount` 1500, 정정 8 | 환불 300. `credit` `amount` 는 0. `billed_bytes` 는 그대로다. 단위를 11로 만드는 줄은 없다 | | F14 | 메일 12개월 108000 중 9개월 유지 | 환불 27000. 7일 전액은 아니다 | | F15 | 서버 12개월 120000, 2027-03-01 부터 2028-03-01, 새 끝 2027-03-11 | 유지 10000, 환불 110000. 6097 이 아니다 | | F16 | 호스팅 `renew` 년 가격 108000 에서 144000, 남은 기간이 정확히 9개월 | `change` `amount` 는 27000. 이행 때 `product_id` 가 바뀌고 `recurring_amount` 는 null 이다. `expires_at` 은 그대로다. 144000 에서 108000 으로 내리는 줄은 없다 | | F17 | `dns` 의 `expires_at` 이 null | 차감 줄은 없다 | | F18 | Premium DNS 만 해지 | 이 절로 금액을 만들지 않는다 | | F19 | `cancel_at` 만 설정 | 환불 행이 없고 `expires_at` 도 그대로다 | | F20 | 메일 환불 27000 중 카드 배분 4000, 무통장 23000 | 카드 환불 행 4000 과 무통장 환불 행 23000 이다 | ## 26. 설명문 한 문장과 25절 사례 번호 이 절은 입문 설명에서 24절에 해당하는 문단의 마지막 문장과, 25.6 표의 사례 번호와 그 표 안의 가리킴만 고친다. 25절의 금액, 12.3의 R(P), 24절의 유지 금액, 19절의 K1부터 K8, 유지 년 수 K, 유지 달 수 K 는 고치지 않는다. 새 action, 새 테이블, 새 상태값, 새 칸은 없다. 입문 설명에서 24절에 해당하는 문단의 마지막 문장은 다음 하나다. 싼 상품으로 바꾸는 금액은 없습니다. 이 문장은 금액이 0인 줄을 만들지 않는다. `kind=hosting` 부모에서 새 연장 판매가가 옛 연장 판매가 이하이면 그 `product_id` 변경 줄은 없다. 금액이 0인 다른 `change` 는 이 문장이 지우지 않는다. "상품 단계를 바꾸는 금액은 없습니다" 는 쓰지 않는다. 25절 입문 문단은 한 글자도 고치지 않는다. 더 비싼 호스팅의 남은 기간 차액과, 싼 상품으로는 바꾸지 않는다는 문장은 그 문단에 그대로 있다. 25.6 표는 F1부터 F20이다. 19절의 K1부터 K8은 가입 사례로 그대로다. 25.6에 K 번호는 남지 않는다. 유지 년 수 K 와 유지 달 수 K 는 변수다. ### 26.1 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | W1 | 19절 K1과 도메인 등록 취소 | 19절 K1은 개인·사업자 없이 가입이다. 등록 취소 15000 은 F1이다. 두 표에 같은 번호가 없다 | | W2 | F2 | 환불 0. 환불 행은 없다. 도메인은 `deleted` 다. 19절 K2 는 `member_kind` 컬럼을 적은 INSERT 가 저장되지 못하는 사례로 그대로다 | | W3 | F4 | 유지 30000, 환불 15000. 19절 K4 는 `buyer_kind=individual` 사례로 그대로다 | | W4 | F12 | 비공개도 3000. 자식은 `terminated` 다. 가리킴은 F1이다 | | W5 | 싼 호스팅으로 바꾸는 요청 | 줄을 만들지 못한다. 금액 0인 `product_id` 변경 줄도 없다. F16 의 27000 은 그대로다 | | W6 | 금액이 0인 다른 `change` | 이 절이 그 줄을 지우지 않는다. 명의변경의 0원 줄은 그대로다 | ## 27. 기간 한정 가격과 서비스 하나 할인 이 절은 기간이 있는 상품 가격과, 서비스 하나짜리 할인만 정한다. 12.3의 R(P) 문장, 23절의 유지 금액, 24절의 유지 금액, 25절의 환불 금액과 상품 변경 차액, S-A의 60000 은 고치지 않는다. 새 action, 새 테이블, 새 상태값은 없다. 새 칸은 `services.discount_percent` 와 `services.discount_until` 뿐이다. 쿠폰 코드, 등급 단가표, 고객의 모든 서비스에 번지는 할인, 기간 할인표, 위약금, 할인 환수, 한 줄에 할인을 두 번 얹는 계산은 없다. 1개월을 30일로 두지 않는다. ### 27.1 상품의 기간 한정 가격 대상은 한 상품의 현재 가격 행 하나다. 같은 `product_id`, `action`, `period_unit`, `period_count` 이다. 그 행의 `effective_from` 은 창의 시작 이전이고, `effective_to` 가 비었거나 창의 끝 이후다. 같은 키의 다른 행이 그 창과 겹치면 만들지 못한다. 창의 시작은 지금이거나 지금보다 늦다. 끝은 시작보다 늦다. 과거 시작은 만들지 못하고 기존 행은 그대로다. 한 트랜잭션에서 옛 행의 `effective_to` 를 창의 시작으로 둔다. 할인 행은 `effective_from` 이 시작, `effective_to` 가 끝이고 `sell_amount` 만 다르다. 복귀 행은 `effective_from` 이 끝, `effective_to` 는 옛 `effective_to`, `sell_amount` 는 옛 값이다. 두 새 행은 옛 행의 `action`, `period_unit`, `period_count`, `currency`, `cost_amount` 를 복사한다. 셋의 구간은 맞닿고 겹치지 않는다. 이미 만든 주문 줄은 고치지 않는다. 서비스의 `discount_percent`, `discount_until`, `recurring_amount` 도 고치지 않는다. 퍼센트는 1 이상 99 이하다. 할인 `sell_amount` 는 (옛 `sell_amount` * (100 - 퍼센트) + 50) / 100 의 정수 나눗셈이다. 금액 지정은 그 행의 새 `sell_amount` 자체이고, 정가에서 빼는 액수가 아니다. 결과가 1 미만이거나 옛 `sell_amount` 이상이면 세 행을 모두 만들지 못하고 옛 행은 그대로다. 다른 `action` 이나 다른 기간의 가격 행은 그대로다. 현재 가격은 `effective_from` 이 지금 이전이고 `effective_to` 가 비었거나 지금보다 늦은 행이다. 맞닿는 시각에는 새 행만 현재다. ### 27.2 서비스 하나 대상은 `status` 가 `active`, `past_due`, `suspended` 인 서비스 하나다. `pending`, `expired`, `terminated`, `failed` 에는 두지 못한다. 같은 고객의 다른 서비스, 자식 서비스, `usage` 줄, `once` 줄, `owner_change` 줄에는 번지지 않는다. add 는 12.3대로 `recurring_amount` 와 이 할인을 쓰지 않는다. 퍼센트와 `recurring_amount` 는 한 서비스에 같이 두지 못한다. 퍼센트는 1 이상 99 이하다. 금액 지정은 `recurring_amount` 이고, 지금 자동 연장이 고를 그 가격 행의 한 기간 총액이다. 수량이 포함된다. 1 이상이고 그 행의 `sell_amount` * `quantity` 보다 작아야 한다. 자동 연장이 가격을 고르지 못하면 지정하지 못한다. 정한 시각 `discount_until` 은 비울 수 있다. 두면 지금보다 늦어야 한다. 새로 지정하는 트랜잭션은 반대 칸을 null 로 둔다. 할인을 지우면 `discount_percent`, `discount_until`, `recurring_amount` 를 모두 null 로 둔다. 주문 생성 시각이 `discount_until` 보다 이르면 할인을 쓴다. 그 시각이거나 그보다 늦으면 그 트랜잭션에서 세 칸을 null 로 두고 가격표를 쓴다. 할인은 그 주문 줄 전체에 쓰고, 기간 중간에서 나누지 않는다. 이미 낸 줄은 고치지 않고 환불도 만들지 않는다. 퍼센트 단가 P 는 (그 줄의 현재 `sell_amount` * (100 - `discount_percent`) + 50) / 100 의 정수 나눗셈이다. P 가 1 미만이면 그 줄을 만들지 못한다. P 가 1 이상이면 12.3에서 `sell_amount` 자리에 이 P 를 쓴다. 금액 지정은 12.3의 `recurring_amount` 규칙 그대로다. 설정 뒤에 가격표가 내려가도 `recurring_amount` 를 다시 깎지 않는다. S-A 는 그대로다. 도메인 부모와 addon 자식을 한 주문에 넣는 24.5의 같은 기간, 12.3을 쓰지 않는 규칙은 그대로다. 그 중 이미 있는 서비스에 이 절의 할인이 있는 줄만, 그 가격 행의 정가 기간 금액에 이 절을 적용한다. 할인이 없는 줄은 카탈로그다. 수량 증가는 `recurring_amount` 를 null 로 두고, `discount_percent` 가 없으면 `discount_until` 도 null 로 두며, `discount_percent` 가 있으면 `discount_percent` 와 `discount_until` 은 그대로다. `discount_percent` 가 있으면 그 change 의 단가는 이 절의 P 다. 호스팅 부모의 `product_id` 변경 이행은 `recurring_amount` 와 함께 `discount_percent`, `discount_until` 을 null 로 둔다. 차액은 할인 전 `sell_amount` 다. 명의변경은 `discount_percent` 와 `discount_until` 을 `recurring_amount` 와 함께 복사한다. ### 27.3 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | B1 | 월 가격 9000, 퍼센트 10, 창 2026-11-01 00:00 부터 2026-12-01 00:00, 지금이 그 시작보다 이르다 | 할인 행 `sell_amount` 8100. 복귀 행은 9000. 옛 행의 `effective_to` 는 시작이다. 2026-12-01 00:00 에는 복귀 행만 현재다 | | B2 | B1의 지정 금액 8000 | 할인 행 `sell_amount` 는 8000 이다. 1000 을 빼는 계산이 아니다 | | B3 | 월 가격만 할인 | 같은 상품의 `renew` 년 가격은 그대로다 | | B4 | 창이 겹치거나 시작이 과거 | 새 행은 없다. 옛 행은 그대로다 | | B5 | 계산 결과가 0 | 새 행은 없다. `sell_amount` 9001 의 10퍼센트는 8101 이다 | | B6 | 창 안의 신규 `register` | 그 줄은 할인 `sell_amount` 다. 서비스의 `discount_percent` 는 null 이다. 창이 끝난 뒤의 연장은 복귀 가격이다. 이미 낸 줄은 그대로다 | | B7 | 현재 `sell_amount` 8100 인 서비스에 퍼센트 10, 수량 1, 정확히 1개월 | `amount` 는 7290 이다. 같은 고객의 다른 서비스는 자기 가격표다 | | B8 | `sell_amount` 9000, 퍼센트 10, 2027-03-01 부터 2027-04-01, 수량은 1, 기간은 2027-03-11 00:00 까지 | P 는 8100 이다. 12.3대로 `unit_price` = R(P) 이므로 `unit_price` 와 `amount` 는 2613 이다. 8100 을 `unit_price` 로 두지 않는다 | | B9 | 월 `recurring_amount` 8000 을 수량 1 에 지정한 뒤 가격표가 7000 이 됨 | 다음 정확히 12개월 연장은 96000 이다. S-A 의 60000 은 그대로다 | | B10 | `discount_until` 2027-10-07 15:00, 주문이 2027-10-07 14:00 | 할인을 쓴다. 2027-10-07 15:00 에 만든 주문은 세 칸을 null 로 두고 가격표다 | | B11 | 퍼센트와 `recurring_amount` 를 같이 지정 | 저장하지 못한다. 지우면 세 칸이 null 이고 다음 연장은 가격표다 | | B12 | `terminated` 서비스, `usage` 줄, `once` 설치비 | 할인을 쓰지 않는다. `usage` 는 `sell_amount` 곱하기 단위 수다 | | B13 | 부모 퍼센트 10, 자식 디스크 add | add 금액은 디스크 카탈로그다. 부모 퍼센트는 자식에 복사되지 않는다 | | B14 | 도메인 `renew` 15000 퍼센트 10 과 WHOIS 3000 을 한 주문 | 도메인은 13500, WHOIS 는 3000 이다. 기간은 같고 12.3으로 3000 을 나누지 않는다 | | B15 | 디스크 월 `sell_amount` 100, 퍼센트 10, `quantity` 40 에서 60, 남은 기간이 정확히 1개월 | `change` `amount` 는 1800 이다. `recurring_amount` 는 null 이고 `discount_percent` 10 과 `discount_until` 은 남는다. 퍼센트가 없는 H17 의 2000 은 그대로다 | | B16 | 퍼센트 10 인 호스팅을 108000 에서 144000 으로 변경, 남은 기간이 정확히 9개월 | `change` `amount` 는 27000 이다. 이행 때 `discount_percent` 와 `discount_until` 은 null 이다 | | B17 | 명의변경 | 새 서비스가 `discount_percent` 와 `discount_until` 을 복사한다. `cancel_at` 은 비어 있다 | | B18 | `amount` 8100 | `vat_amount` 는 736, `supply_amount` 는 7364 이다. 공급가와 부가세를 따로 할인하지 않는다 | | B19 | 퍼센트 없이 `recurring_amount` 8000 과 `discount_until` 이 있는 디스크를 40 에서 60 으로, 남은 기간이 정확히 1개월 | `recurring_amount` 와 `discount_until` 은 null 이다. `change` `amount` 는 2000 이다 | ## 28. 물리 스키마 이 문안이 28절이다. 이미 서명된 칸을 PostgreSQL 표와 ERD로 옮긴다. 12.3의 R(P), 23절부터 27절의 금액, S-A의 60000, 4절의 그림, 9절과 15.7에서 없다고 한 표는 고치지 않는다. 10절의 직원, 권한, 문의, 감사 로그는 계속 없다. 새 업무 규칙, 새 action, 새 상태값, 새 환불 공식은 없다. 국세청 클라이언트도 없다. ### 28.1 형식 SQL 파일은 PostgreSQL이다. 6절의 MySQL 문장은 문서에만 둔다. id 기본키는 bigint generated always as identity 다. 금액과 바이트는 bigint 다. 시각은 timestamptz 다. written_on 과 anchor_on 만 date 다. 통화는 char(3) not null default 'KRW' 다. 나열 문자열과 이름은 text 다. 참·거짓은 boolean 이다. config 와 payload 는 jsonb 다. epp_statuses 와 suspend_reasons 는 text[] not null default '{}' 다. 블록에 null 이라고 적힌 칸만 null 이다. 모든 표에 created_at timestamptz not null default now() 를 둔다. updated_at 은 어느 표에도 없다. FK 는 모두 ON DELETE RESTRICT 다. ON UPDATE 는 데이터베이스 기본값이다. CASCADE 는 없다. 뷰, 시드, 역할은 없다. 블록에 unique 라고 적힌 칸은 UNIQUE 다. 6절에 이미 있는 customers.login_id 와 customers.email 의 유니크 인덱스를 중복 UNIQUE 로 만들지 않는다. services (id, kind) 는 UNIQUE 다. ### 28.2 새로 정하는 것 tlds 는 id, label, identity_required, created_at 만 가진다. label 은 점이 없는 소문자 라벨이고 UNIQUE 다. identity_required 는 boolean not null 이다. 유예일, 환불일, 레지스트리 주소 칸은 없다. 25절 환불은 도메인 이름의 마지막 라벨을 본다. tlds.label 을 보지 않는다. registry_account_id 는 domains 와 registry_events 의 text not null 이다. 부모 표와 FK 는 없다. registry_accounts 표는 없다. customers.id 는 기본키다. 16.1 블록에 없어도 화살표의 대상이다. member_kind 칸은 없다. 16.1을 고쳐 id 줄을 끼워 넣지 않는다. dns_records.id 는 물리 기본키다. 15.4의 칸은 zone_id, host, record_type, ttl, priority, value 다. priority 만 null 이다. domain_id 칸은 없다. 15.4 블록을 고쳐 id 줄을 끼워 넣지 않는다. domain_services 는 문서대로 kind 칸이 있고 값은 domain 이다. hosting_services, ssl_services, mail_services, server_services, dbms_services, security_services 도 kind 칸이 있다. 값은 표 이름대로 hosting, ssl, mail, server, dbms, security 중 그 하나다. 복합 FK (service_id, kind) 는 services (id, kind) 를 가리킨다. dns_zones 에는 kind 칸이 없고 service_id 는 services(id) 만 가리킨다. ### 28.3 표 칸 순서는 이 목록이다. ? 는 null, 화살표는 FK 다. customers: id, login_id, password_hash, email, name, phone?, mobile, postal_code?, address?, address_detail?, terms_version, terms_accepted_at, created_at tlds: id, label, identity_required, created_at products: id, code unique, product_type, name, billing_model, status, tld_id? → tlds, created_at prices: id, product_id → products, action, period_unit, period_count, currency, cost_amount, sell_amount, effective_from, effective_to?, created_at services: id, customer_id → customers, product_id → products, parent_service_id? → services, kind, status, quantity default 1, auto_renew, cancel_at?, suspend_reasons, recurring_amount?, discount_percent?, discount_until?, currency, started_at?, next_due_at?, expires_at?, included_traffic_bytes?, suspended_at?, terminated_at?, created_at payment_methods: id, customer_id → customers, method, status, billing_key, billing_key_digest, card_last4?, card_issuer?, is_default, created_at orders: id, order_no unique, customer_id → customers, source, currency, status, ordered_at, idempotency_key? unique, created_at service_usage: id, service_id → services, meter, period_start, period_end, included_bytes, used_bytes, billed_bytes, created_at order_items: id, order_id → orders, parent_order_item_id? → order_items, product_id → products, service_id? → services, action, status, product_name, period_unit, period_count, period_start?, period_end?, quantity, unit_price, supply_amount, vat_amount, cost_amount, cost_currency, amount, settled_amount default 0, currency, requested_name?, from_product_id? → products, from_service_id? → services, to_customer_id? → customers, auth_code?, usage_id? → service_usage, config?, price_id? → prices, created_at payments: id, customer_id → customers, method, status, amount, currency, pg_tid? unique, payment_method_id? → payment_methods, paid_at?, created_at payment_allocations: id, payment_id → payments, order_item_id → order_items, amount, created_at refunds: id, customer_id → customers, payment_id? → payments, status, method, amount, reason, requested_by, approved_by?, created_at refund_lines: id, refund_id → refunds, order_item_id → order_items, amount, created_at customer_balance_entries: id, customer_id → customers, amount, payment_id? → payments, order_item_id? → order_items, refund_id? → refunds, reason, created_at domains: id, domain_name, tld_id → tlds, roid?, registry_account_id, lifecycle_status, epp_statuses, registrant_lock_until?, registered_at?, expires_at, registrar_renew_policy, registry_autorenewed_at?, predecessor_domain_id? → domains, created_at domain_services: service_id pk → services, kind, domain_id → domains, linked_at, unlinked_at?, created_at domain_reservations: id, requested_name, order_item_id → order_items, expires_at, released_at?, created_at hosting_servers: id, hostname, panel_type, status, created_at hosting_accounts: id, server_id → hosting_servers, panel_account_id, username, status, os_template?, created_at hosting_services: service_id pk → services, kind, hosting_account_id → hosting_accounts, created_at certificates: id, common_name, issuer, serial, expires_at, status, dcv_method?, created_at ssl_services: service_id pk → services, kind, certificate_id → certificates, created_at contacts: id, customer_id? → customers, name, name_loc?, contact_kind, organization?, email, phone, mobile?, street, city, state?, postal_code, country_code, registry_contact_id?, identity_kind?, identity_value?, created_at domain_contacts: id, domain_id → domains, role, contact_id → contacts, linked_at?, unlinked_at?, created_at our_nameservers: host_name pk, created_at domain_nameservers: id, domain_id → domains, host_name, sort_order, ipv4?, ipv6?, created_at dns_zones: id, zone_name, domain_id? → domains, service_id? → services, parent_zone_id? → dns_zones, closed_at?, created_at dns_records: id, zone_id → dns_zones, host, record_type, ttl, priority?, value, created_at zone_publish: zone_id pk → dns_zones, mode, forward_url?, forward_keep_url, parking_title?, parking_note?, inquiry_enabled, created_at mail_services: service_id pk → services, kind, quota_bytes, zone_id? → dns_zones, created_at server_services: service_id pk → services, kind, hostname, ipv4, created_at dbms_services: service_id pk → services, kind, host, port, db_name, created_at security_services: service_id pk → services, kind, security_kind, created_at provisioning_jobs: id, order_item_id → order_items, idempotency_key unique, job_type, status, attempts, last_error?, created_at registry_events: id, registry_account_id, message_id, domain_id? → domains, event_type, payload, processed_at?, created_at. unique (registry_account_id, message_id) customer_tax_profiles: customer_id pk → customers, buyer_kind, legal_name, business_no?, representative_name?, address?, email?, business_type?, business_item?, evidence_preference, cash_receipt_purpose?, cash_receipt_identifier?, created_at tax_documents: id, customer_id → customers, doc_type, status, supply_amount, vat_amount, amount, written_on, approval_no?, buyer_legal_name, buyer_business_no?, buyer_representative?, buyer_address?, buyer_email?, business_type?, business_item?, cash_receipt_purpose?, cash_receipt_identifier?, adjusts_document_id? → tax_documents, created_at tax_document_lines: id, tax_document_id → tax_documents, order_item_id → order_items, supply_amount, vat_amount, amount, created_at payment_notice_mails: id, customer_id → customers, service_id → services, trigger, anchor_on, offset_days?, to_address, subject, body, status, sent_at?, last_error?, resend_of_id? → payment_notice_mails, created_at domain_ds_records: id, domain_id → domains, key_tag, algorithm, digest_type, digest, created_at 이 40개 외의 표는 없다. order_items.quantity 에는 default 가 없다. requested_by 와 approved_by 는 text 다. 직원 FK 가 아니다. hosting_servers.status, hosting_accounts.status, certificates.status, provisioning_jobs.status 는 text not null 이고 값 CHECK 가 없다. ipv4 와 ipv6 는 text 다. inet 이 아니다. country_code 는 text 다. ### 28.4 체크 CHECK 는 같은 행에서 문서가 정한 것만 만든다. 다른 표와 비교하는 규칙은 CHECK 도 트리거도 아니다. 예치금 합, 환불 합, 가격 구간 겹침, 자식 만료, 열린 호스팅 계정 중복, 열린 인증서 중복, 살아 있는 도메인과 외부 존의 이름 충돌, DS 8개 제한이 그것이다. products.product_type 은 domain, hosting, ssl, addon, mail, server, dbms, security, dns 다. billing_model 은 one_time, recurring 이다. status 는 active, retired 다. prices.action 은 register, renew, transfer, restore, upgrade, addon_add, owner_change, usage 다. period_unit 은 year, month, once 다. orders.source 는 customer, staff, system 이다. status 는 awaiting_payment, paid, partially_fulfilled, fulfilled, cancelled 다. order_items.action 은 register, transfer_in, renew, restore, add, change, owner_change, credit, usage 다. status 는 pending, paid, fulfilled, failed, cancelled, refunded 다. period_unit 은 year, month, day, once 다. owner_change 일 때만 to_customer_id 와 from_service_id 가 있고, 다른 action 에서는 둘 다 null 이다. usage 일 때만 usage_id 가 있다. auth_code 는 null 이거나 action 이 transfer_in 이다. payments.method 는 card, bank_transfer 이다. status 는 pending, confirmed, failed 이다. payment_method_id 는 null 이거나 method 가 card 다. refunds.status 는 requested, approved, rejected, completed 다. method 는 balance, bank, card 다. customer_balance_entries 는 payment_id, order_item_id, refund_id 중 정확히 하나만 있다. services.kind 는 products.product_type 과 같은 아홉 값이다. status 는 pending, active, past_due, suspended, expired, terminated, failed 다. 5.4의 check 다섯은 그대로다. included_traffic_bytes 는 null 이거나, kind 가 hosting 또는 server 이면서 0 이상이다. 다른 kind 는 null 이다. suspend_reasons 의 원소는 unpaid, abuse, legal, customer 뿐이다. domains.lifecycle_status 는 active, expired_grace, redemption, pending_delete, pending_transfer, transferred_out, deleted 다. registrar_renew_policy 는 follow_service, always, never 다. 서브타입 kind 는 그 표의 한 값만 된다. provisioning_jobs.job_type 은 epp_create, epp_renew, epp_delete, epp_update_contact, epp_update_ns, epp_update_status, epp_transfer_reject, epp_authinfo, epp_update_ds, epp_transfer, zone_sync, privacy_on, privacy_off, migrate 다. customer_tax_profiles.buyer_kind 는 individual, business 이다. evidence_preference 는 none, tax_invoice, cash_receipt 다. cash_receipt_purpose 는 null 이거나 personal, business 이다. tax_documents.doc_type 은 tax_invoice, cash_receipt 다. status 는 issued, void 다. payment_notice_mails.trigger 는 auto, manual 이다. status 는 queued, sent, failed 다. auto 의 offset_days 는 14 또는 1 이다. manual 의 offset_days 는 null 이다. 칸 이름은 trigger 라서 따옴표로 만든다. contacts.contact_kind 는 individual, organization 이다. organization 일 때만 organization 칸이 있다. identity_kind 와 identity_value 는 둘 다 null 이거나 둘 다 있다. identity_kind 는 birth_date 또는 business_no 다. individual 은 birth_date 만, organization 은 business_no 만 된다. domain_contacts.role 은 registrant, admin, tech, billing 이다. dns_zones 는 (domain_id is null) <> (service_id is null) 이다. dns_records.record_type 은 A, AAAA, CNAME, MX, TXT, CAA, SRV, DNAME 이다. zone_publish.mode 는 delegation, parking, http_forward 다. delegation 은 forward_url 이 null, forward_keep_url 이 false, 파킹 두 칸이 null, inquiry_enabled 가 false 다. parking 은 forward_url 이 null 이고 forward_keep_url 이 false 다. http_forward 의 forward_url 은 http:// 또는 https:// 로 시작하고 파킹 두 칸은 null 이며 inquiry_enabled 는 false 다. forward_keep_url 이 true 인 것은 http_forward 뿐이다. inquiry_enabled 가 true 인 것은 parking 뿐이다. security_kind 는 waf, vpn, nms 다. certificates.dcv_method 는 null 이거나 email, dns, http 다. payment_methods.method 는 card 뿐이다. status 는 active, revoked 다. is_default 가 true 이면 status 는 active 다. card_last4 는 null 이거나 숫자 4자리다. billing_key_digest 는 소문자 16진수 64자리다. billing_key 는 v1: 로 시작한다. customers.login_id 는 4자 이상 20자 이하이고 소문자이며 영문 소문자로 시작하고 @ 가 없다. email 은 소문자다. name 은 앞뒤 공백을 뺀 1자 이상 100자 이하다. password_hash 는 $argon2id$v=19$m=19456,t=2,p=1$ 로 시작한다. phone, postal_code, address, address_detail, identity_value, 두 cash_receipt_identifier 는 null 이거나 v1: 로 시작한다. mobile 은 v1: 로 시작한다. service_usage.meter 는 traffic_bytes 다. included_bytes, used_bytes, billed_bytes 는 0 이상이다. period_end 는 period_start 보다 늦다. domain_ds_records.key_tag 는 0 이상 65535 이하다. algorithm 은 1 이상 255 이하다. digest_type 은 2 다. digest 는 소문자 16진수 64자리다. domain_name, zone_name, requested_name, our_nameservers.host_name, domain_nameservers.host_name 은 소문자 라벨을 점으로 이은 이름이다. 각 라벨은 1자 이상 63자 이하이고 영숫자로 시작하며 하이픈으로 끝나지 않는다. 점은 하나 이상이다. tlds.label 은 그 라벨 하나이고 점이 없다. 한글이 들어 있으면 저장되지 않는다. 저장 전에 퓨니코드로 바꾼다. dns_records.host 에는 이 검사를 하지 않는다. ### 28.5 인덱스와 트리거 6절의 유니크 인덱스 20개를 이름과 WHERE 그대로 복사한다. uq_open_addon, uq_paid_period, uq_paid_register_name, uq_live_domain_name, uq_current_domain_link, uq_open_reservation, uq_current_domain_contact, uq_pending_domain_contact, uq_domain_nameserver, uq_auto_payment_notice, uq_open_owner_change, uq_zone_domain, uq_zone_service, uq_open_external_zone_name, uq_default_payment_method, uq_active_billing_key, uq_customer_login_id, uq_customer_email, uq_usage_period, uq_usage_line 이다. 트리거는 둘뿐이다. products, orders, domains 의 DELETE 는 예외로 막는다. 다른 표의 DELETE 는 막지 않는다. order_items 는 OLD.status 가 paid, fulfilled, failed, refunded 중 하나이면 product_name, unit_price, supply_amount, vat_amount, cost_amount, amount, period_start, period_end 를 바꾸지 못한다. status 를 paid 에서 fulfilled 로 바꾸는 것은 된다. pending 과 cancelled 는 이 트리거가 막지 않는다. ### 28.6 파일 docs/schema/001_init.sql 이 이 절의 표, CHECK, 인덱스, 트리거다. docs/schema/erd.md 는 이 절의 40개 표와 화살표만 그린 mermaid 다. 4절 그림은 고치지 않는다. 부분 유니크는 ERD에 적지 않는다. 설명문에는 27절 문단 뒤에 다음만 넣는다. 표 그림과 데이터베이스 표는 지금까지 정한 칸만 담습니다. 최상위 도메인 표에는 이름과 식별이 필수인지만 있습니다. 레지스트리 계정 번호는 문자열로 두고 그 부모 표는 없습니다. 서명 줄의 13절부터 27절까지는 13절부터 28절까지로 바꾼다. 12절 날짜는 그대로다. ### 28.7 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | D1 | tlds 에 label kr 과 identity_required true | 행이 생긴다. 유예일 칸은 없다. co.kr 환불은 도메인 이름의 마지막 라벨을 보고 이 칸을 보지 않는다 | | D2 | registry_account_id 가 epp-1 인 도메인과 같은 값의 registry_events | 저장된다. registry_accounts 표는 없다. 같은 계정과 같은 message_id 는 둘일 수 없다 | | D3 | customers | id 가 있다. member_kind 와 updated_at 은 없다. created_at 은 있다 | | D4 | dns_records | id 가 있다. domain_id 칸은 없다. priority 가 null 인 A 레코드는 저장된다 | | D5 | kind 가 domain 인 서비스의 id 를 hosting_services 에 넣음 | 저장되지 않는다. dns_zones 에는 kind 칸이 없다 | | D6 | products 삭제 | 예외다. 자식이 없는 services 행의 삭제를 이 트리거가 막지는 않는다 | | D7 | status 가 paid 인 줄의 amount 변경. pending 줄의 amount 변경. paid 를 fulfilled 로만 변경 | 첫 변경은 실패한다. 둘째는 된다. 셋째는 amount 가 그대로면 된다 | | D8 | 같은 service_id 와 period_start 의 renew 줄이 둘 다 pending | 둘 다 저장된다. 둘 다 paid 이면 둘째가 실패한다 | | D9 | invoices, price_books, registry_accounts, dns_services, hosting_migrations, order_item_options, coupons, staff | 표가 없다 | | D10 | suspended 와 suspend_reasons unpaid. active 와 빈 배열. active 와 unpaid. suspended 와 nope | 처음 둘은 된다. 나중 둘은 실패한다 | | D11 | payments.method cms. payment_methods.method bank_transfer | 둘 다 저장되지 않는다 | | D12 | 40개 표 | 전부 created_at 이 있고 updated_at 은 없다. 통화 기본값은 KRW 다. 금액 칸은 bigint 다. FK 삭제는 RESTRICT 다 | | D13 | tlds.label KR, kr, xn--3e0b707e, co.kr | KR 과 co.kr 은 실패한다. kr 과 xn--3e0b707e 는 된다 | | D14 | delegation 이면서 inquiry_enabled true. http_forward 와 forward_url http://a.example | 첫째는 실패한다. 둘째는 된다 | | D15 | mobile 평문 01012345678. password_hash 가 bcrypt | 둘 다 저장되지 않는다 | ## 29. 감사 로그 이 문안이 29절이다. 인트라넷에서 행을 추가하거나 수정하거나 삭제하면 그 내역이 로그로 남는다. 10절이 모양을 비워 둔 일반 감사 로그가 이 절이다. 10절과 15.7과 28절의 문장은 고치지 않는다. 직원 표, 권한 표, 고객 문의 표는 계속 없다. 12.3의 R(P), 23절부터 27절의 금액, S-A의 60000은 고치지 않는다. 조회, 로그인 성공, 화면 이동은 로그가 아니다. 15.7의 활동 로그, 웹로그, FTP 로그는 계속 없다. ### 29.1 표 audit_events 하나만 만든다. 표마다 이력 표를 만들지 않는다. registry_events 와 합치지 않는다. 20절의 사업자번호 이력 표와 21절의 식별자 이력 표는 계속 없다. 프로필 행은 하나이고 발행 문서는 그대로다. 칸 순서는 이렇다. ? 는 null 이다. audit_events: id, occurred_at, request_id?, actor_kind, actor_customer_id? → customers, actor_label?, action, table_name, row_pk, changed_columns, before?, after?, created_at id 는 bigint generated always as identity 다. occurred_at 은 그 트랜잭션의 transaction_timestamp() 다. created_at 은 timestamptz not null default now() 다. updated_at 은 없다. request_id 와 actor_label 과 table_name 은 text 다. actor_kind 와 action 은 text 다. row_pk 와 before 와 after 는 jsonb 다. changed_columns 는 text[] not null 이다. FK 는 ON DELETE RESTRICT 다. 인덱스는 occurred_at 하나, (table_name, occurred_at) 하나, actor_customer_id 가 null 이 아닌 부분 인덱스 하나다. actor_kind 는 customer, staff, system 뿐이다. action 은 insert, update, delete 뿐이다. customer 이면 actor_customer_id 가 있고 actor_label 은 null 이다. staff 이면 actor_customer_id 는 null 이고 actor_label 은 앞뒤 공백을 뺀 1자 이상이다. system 이면 둘 다 null 이다. insert 는 before 가 null 이고 after 가 있다. delete 는 before 가 있고 after 가 null 이다. update 는 둘 다 있고 changed_columns 가 빈 배열이 아니다. table_name 은 audit_events 가 아니다. request_id 는 null 이거나 1자 이상이다. ### 29.2 언제 남기나 28절의 40개 표에 AFTER INSERT OR UPDATE OR DELETE 행 트리거를 둔다. 바꾼 문과 감사 행은 같은 트랜잭션이다. 그 문이 롤백되면 감사 행도 없다. 바뀐 칸이 하나도 없으면 update 감사 행을 만들지 않는다. 추가면 changed_columns 는 null 이 아닌 칸의 이름이다. 삭제는 null 이 아니었던 칸의 이름이다. 수정은 is distinct from 인 칸의 이름이다. row_pk 는 그 표의 기본키만 담는다. 기본키 칸 이름이 키다. 앱은 그 트랜잭션에서 set_config 로만 행위자를 둔다. 이름은 intranet.actor_kind, intranet.actor_customer_id, intranet.actor_label, intranet.request_id 다. set_config 의 세 번째 인자는 true 라서 그 설정은 그 트랜잭션 안에서만 있다. 설정이 없으면 actor_kind 는 system 이고 나머지 행위자 칸은 null 이다. 비즈니스 변경은 막지 않는다. actor_kind 가 customer, staff, system 이 아니면 그 비즈니스 문은 실패하고 감사 행도 없다. customer 인데 고객 행이 없으면 외래키가 막아 감사 행도 없다. audit_events 에 직접 INSERT 하지 못한다. 트리거 함수가 그 문 안에서만 표시를 켠 뒤 넣는다. UPDATE 와 DELETE 는 예외다. 그 예외로 새 감사 행이 생기지는 않는다. 보관 기간과 지우는 잡은 없다. 28절의 두 트리거는 그대로다. 001_init.sql 은 고치지 않는다. 감사 표와 트리거는 docs/schema/002_audit.sql 이다. erd.md 에는 audit_events 와 actor_customer_id 화살표만 더한다. 4절 그림은 그대로다. ### 29.3 값 before 와 after 는 그 행의 칸을 jsonb 로 담는다. 비밀 칸이 null 이면 jsonb null 이다. 비밀 칸에 값이 있으면 그 값 대신 [redacted] 만 넣는다. 암호문, 평문, 해시를 복사하지 않는다. 비밀 칸은 customers 의 password_hash, phone, mobile, postal_code, address, address_detail 과 contacts.identity_value 와 customer_tax_profiles.cash_receipt_identifier 와 tax_documents.cash_receipt_identifier 와 payment_methods 의 billing_key, billing_key_digest 와 order_items.auth_code 다. 그 밖 칸은 저장값 그대로다. order_items.config, registry_events.payload, provisioning_jobs.last_error 를 다시 훑어 지우지 않는다. 사업자번호는 비밀 칸이 아니다. 프로필을 고치면 감사의 before 와 after 에 이전 번호와 이후 번호가 남는다. 프로필 행은 여전히 하나이고 발행 문서의 buyer_business_no 는 그대로다. 식별자 암호문은 [redacted] 다. ### 29.4 설명문 28절 설명문 뒤에 다음만 넣는다. 추가와 수정과 삭제는 그 일이 확정되는 순간에 기록으로 남습니다. 누가 어느 표의 어느 행을 바꿨는지와 바뀌기 전과 후가 남습니다. 비밀번호와 암호문과 카드 키와 기관 이전 코드는 그 기록에 값을 적지 않습니다. 레지스트리가 보낸 사건과는 다른 기록입니다. 서명 줄의 13절부터 28절까지는 13절부터 29절까지로 바꾼다. 12절 날짜는 그대로다. ### 29.5 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | L1 | 고객 설정으로 services.auto_renew 를 바꾼다. request_id 는 req-1 | action 은 update 다. table_name 은 services 다. changed_columns 에 auto_renew 가 있다. actor_kind 는 customer 다. 롤백하면 서비스도 감사 행도 없다 | | L2 | 설정 없이 products.name 을 고친다 | 이름은 바뀐다. actor_kind 는 system 이고 actor_customer_id 와 actor_label 은 null 이다 | | L3 | actor_kind 가 staff 이고 actor_label 이 김민수 | 감사 행이 생긴다. 직원 표는 없다 | | L4 | actor_kind 가 customer 인데 고객 id 가 없다 | 그 수정은 실패하고 감사 행은 없다 | | L5 | actor_kind 가 admin | 그 수정은 실패하고 감사 행은 없다 | | L6 | customers.mobile 암호문을 다른 암호문으로 바꾼다 | changed_columns 에 mobile 이 있다. before 와 after 의 mobile 은 [redacted] 다. v1: 과 평문 숫자는 없다. password_hash 도 값 대신 [redacted] 다 | | L7 | auth_code 가 있는 transfer_in 을 넣는다 | after.auth_code 는 [redacted] 다. 평문 코드는 없다 | | L8 | 세금 프로필 business_no 를 1111111111 에서 2222222222 로 바꾼다. 발행 문서는 1111111111 | 프로필 행은 하나다. 문서 번호는 그대로다. 감사 before 는 1111111111 이고 after 는 2222222222 다. 사업자번호 이력 표는 없다. 식별자 암호문은 [redacted] 다 | | L9 | dns_records 한 행을 지운다 | action 은 delete 다. before 에 host 와 value 가 있고 after 는 null 이다 | | L10 | products 를 DELETE | 예외다. 감사 행은 없다 | | L11 | audit_events 를 직접 INSERT 하거나 UPDATE 하거나 DELETE | 예외다. 트리거가 넣은 행만 있다 | | L12 | 한 트랜잭션에서 레코드를 지우고 다시 넣는다. request_id 가 같다 | 감사 행이 둘 이상이고 occurred_at 과 request_id 가 같다. id 는 서로 다르다 | | L13 | registry_events 를 넣는다 | 두 표에 각각 행이 있다. 한 표로 합치지 않는다 | | L14 | paid 인 order_items 의 amount 를 고친다. 또는 status 만 fulfilled 로 고친다 | amount 변경은 실패하고 감사가 없다. status 변경은 성공하고 changed_columns 는 status 뿐이다 | | L15 | 조회, 로그인 성공 | 감사 행이 없다. 활동 로그 표도 없다 | | L16 | 같은 auto_renew 값으로 다시 쓴다 | 감사 행이 없다 | ## 30. 만기 안내 메일 이 문안이 30절이다. 만기 안내는 메일만 보낸다. 문자는 보내지 않는다. 14.2가 만든 payment_notice_mails 를 유지하고, 그 표에 칸 하나와 상태 하나와 자동 offset 만 더한다. 14.2와 15.7과 28절과 29절의 문장은 고치지 않는다. 14.2와 G4가 적은 자동 offset 14 와 1 은 이 절의 offset 이 대체한다. 직원 표, SMS 표, 발신 주소 칸, SMTP 업체, 계좌 표는 계속 없다. 12.3의 R(P), 23절부터 27절의 금액, S-A의 60000, 29절 감사 표는 고치지 않는다. 001_init.sql 과 002_audit.sql 과 erd.md 는 고치지 않는다. 변경은 docs/schema/003_notice.sql 이다. ### 30.1 채널 수신은 이메일뿐이다. customers.mobile 은 수신 주소가 아니다. 15.7의 관리자 SMS 수신 동의는 계속 없다. sms 로 시작하는 표와 칸을 만들지 않는다. 자동 갱신을 꺼도 cancel_at 이 null 이면 메일은 만든다. cancel_at 이 있거나 status 가 active 와 past_due 가 아니면 자동 메일을 만들지 않는다. ### 30.2 언제 기준일은 14.2 그대로다. next_due_at 의 Asia/Seoul 날짜이고, next_due_at 이 null 이면 expires_at 의 날짜다. 둘 다 null 이면 자동 메일이 없다. 도메인 만기일과 서비스 기준일이 달라도 메일은 서비스 기준일만 본다. 자동 행을 넣는 때와 발송 직전에 보는 현재 기준일은 둘 다 next_due_at 의 Asia/Seoul 날짜이고, next_due_at 이 null 이면 expires_at 의 날짜다. queued 인 자동 행의 anchor_on 이 그 값과 다르면 skipped 로 두고 sent_at 은 null 이며 보내지 않는다. 자동 offset_days 는 30, 7, 1, 0, -1 뿐이다. 14 는 저장되지 않는다. 만드는 날은 기준일에서 offset_days 를 뺀 Asia/Seoul 날짜다. 0 은 기준일 당일이다. -1 은 기준일 다음 날이다. anchor_on 에는 만드는 날이 아니라 서비스 기준일을 적는다. 기준일이 2027-06-01 이면 -1 행도 anchor_on 은 2027-06-01 이고, 2027-06-02 에 서비스의 현재 기준일이 2027-06-01 이면 넣는다. 그 날에 서비스의 현재 기준일이 그 anchor_on 과 같고 대상 status 일 때만 넣는다. 호스팅과 자식 서비스는 메일을 한 통으로 합치지 않는다. 나라 코드별 날짜를 따로 두지 않는다. ### 30.3 받는 주소 계정 메일의 to_address 는 넣는 시점의 customers.email 이다. 현재 domain_services 가 있고 현재 등록인 연락처의 이메일을 소문자로 바꾼 값이 계정 이메일과 다르면, 같은 기준일과 같은 offset 으로 등록인 메일을 하나 더 만든다. 등록인 to_address 는 그 소문자 이메일이다. 같거나 등록인 링크가 없으면 계정 메일만 있다. 도메인이 아닌 서비스는 계정 메일만 있다. 자동 유니크는 이름 uq_auto_payment_notice 를 유지하고 칸을 (service_id, anchor_on, offset_days, to_address) 로 바꾼다. WHERE 는 trigger 가 auto 인 그대로다. 수동은 offset_days 가 null 이고 이 유니크에 들어가지 않는다. ### 30.4 본문 넣는 순간 제목에는 서비스 표시와 기준일의 YYYY-MM-DD 가 있다. 서비스 표시는 현재 도메인 링크가 있으면 domain_name 이고, 없으면 그 시점의 products.name 이다. 계정 메일 본문에는 그 시점의 customers.name, 서비스 표시, 기준일, 로그인해야 연장된다는 뜻, 기준일이 지나면 서비스가 멈출 수 있다는 뜻이 있다. 등록인 메일 본문의 이름은 name_loc 가 있으면 그 값이고, 없으면 contacts.name 이다. 카드 키, 계좌번호, 비밀번호, 전화 평문은 제목과 본문에 없다. quoted_amount 는 bigint null 이고 표 끝에 더한다. 24절이 그 아침에 자동 연장 줄을 만들 수 있으면 그 줄의 amount 이고, 만들 수 없으면 null 이다. null 이 아니면 본문에 그 십진 숫자가 있다. null 이면 본문에 원 금액을 적지 않는다. 금액을 본문이 다른 숫자로 적지 않는다. status 는 queued, sent, failed, skipped 다. 발송 직전에 대상이 아니거나 현재 기준일이 anchor_on 과 다르면 skipped 로 두고 sent_at 은 null 이며 보내지 않는다. skipped 는 failed 가 아니다. 수동 재발송의 원본은 sent 또는 failed 만이다. 제목과 본문 바이트를 복사한다. to_address 는 지금 customers.email 이고 offset_days 는 null 이며 resend_of_id 는 그 메일이다. 횟수 제한은 없다. ### 30.5 설명문 만료 메일 단락 뒤에 다음만 넣는다. 메일은 기준일 30일 전, 7일 전, 1일 전, 당일, 다음 날에 만듭니다. 문자는 보내지 않습니다. 메일에는 누구의 어느 서비스가 어느 날 끝나는지 적고, 연장 금액을 정할 수 있으면 그 금액도 적습니다. 서명 줄의 13절부터 29절까지는 13절부터 30절까지로 바꾼다. 12절 날짜는 그대로다. ### 30.6 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | N1 | 기준일 2027-06-01 이고 active 이며 cancel_at 이 null | 자동 offset 은 30, 7, 1, 0, -1 각 하나다. 14 는 저장되지 않는다. -1 의 날은 2027-06-02 다 | | N2 | cancel_at 이 있거나 status 가 suspended | 자동 행이 없다 | | N3 | auto_renew 가 false 이고 cancel_at 이 null | 자동 행은 있다. SMS 표와 SMS 칸은 없다. to_address 는 이메일이다 | | N4 | 자동 연장 줄의 amount 가 9000 으로 정해짐 | quoted_amount 는 9000 이다. 제목에 서비스 표시와 2027-06-01 이 있다. 본문에 계정 이름, 서비스 표시, 2027-06-01, 9000 이 있다. 카드 키와 계좌번호와 비밀번호는 없다 | | N5 | 24절이 자동 연장 줄을 만들지 못함 | 메일은 있다. quoted_amount 는 null 이다. 본문에 원 금액이 없다 | | N6 | 등록인 이메일이 계정 이메일과 다름. 또는 같음 | 다르면 같은 offset 의 자동 행이 둘이다. 계정 본문의 이름은 customers.name 이고 등록인 본문의 이름은 name_loc, 없으면 contacts.name 이다. 같으면 행은 하나다 | | N7 | sent 인 메일을 수동 재발송. skipped 를 원본으로 재발송 | 수동은 제목과 본문이 같고 to_address 는 지금 계정 이메일이며 offset 은 null 이고 resend_of_id 가 있다. skipped 는 원본이 아니다 | | N8 | queued 인 동안 기준일이 바뀜 | status 는 skipped 다. sent_at 은 null 이다. failed 가 아니다 | | N9 | 행을 만든 뒤 계정 이메일이 바뀜 | 이미 만든 to_address 는 그대로다. 그 뒤 수동 재발송의 to_address 는 새 이메일이다 | | N10 | 호스팅과 그 자식 디스크가 같은 기준일 | 서비스마다 메일이 있다. 한 통으로 합치지 않는다 | | N11 | 도메인 링크가 없는 호스팅 | 계정 메일만 있다. 등록인 메일은 없다 | | N12 | next_due_at 과 expires_at 이 둘 다 null | 자동 행이 없다 | ## 31. 인트라넷 로그인 직원 이 문안이 31절이다. 인트라넷에 로그인하는 직원은 staff 표 하나다. customers 에 넣지 않는다. member_kind 칸은 계속 없다. 10절과 15.7과 18.3과 28절과 29절과 30절의 문장은 고치지 않는다. 10절의 직원 모양 가운데 로그인 계정만 이 절이 정한다. 권한 표와 고객 문의 표는 계속 없다. 18.3이 고객 해시를 customers.password_hash 에만 둔 문장은 그대로다. 직원 해시는 staff.password_hash 에 둔다. 29절은 staff 일 때 actor_customer_id 가 null 이고 actor_label 이 1자 이상이라고 했다. 이 절은 그 조건에 actor_staff_id 를 더한다. 29절 L3 의 문장과 30절의 직원 표가 없다는 문장은 그대로다. 그 이후의 staff 감사 행은 직원 행이 있어야 한다. SMS 표, 발신 주소 칸, SMTP 업체, 계좌 표는 계속 없다. 12.3의 R(P), 23절부터 27절의 금액, S-A의 60000 은 고치지 않는다. 001_init.sql 과 002_audit.sql 과 003_notice.sql 은 고치지 않는다. 변경은 docs/schema/004_staff.sql 이다. 적용 순서는 001, 002, 003, 004 다. ### 31.1 표 staff: id, login_id, password_hash, email, name, disabled_at?, created_at id 는 bigint generated always as identity 다. created_at 은 timestamptz not null default now() 다. updated_at 은 없다. disabled_at 은 timestamptz null 이다. null 이면 로그인할 수 있다. 시각이 있으면 새 세션을 열지 못한다. login_id, password_hash, email, name 은 text not null 이다. 데이터베이스 CHECK 는 customers 와 같다. login_id 는 4자 이상 20자 이하이고 소문자이며 영문 소문자로 시작하고 @ 가 없다. email 은 소문자다. name 은 앞뒤 공백을 뺀 1자 이상 100자 이하다. password_hash 는 $argon2id$v=19$m=19456,t=2,p=1$ 로 시작한다. 18.2의 나머지 글자 규칙, 18.3의 평문 비밀번호 규칙, 18.4의 이메일 길이·골뱅이·점·공백 규칙은 앱이 직원에게도 적용한다. 그 세 절의 문장은 고치지 않는다. 재설정 토큰 표는 없다. 유니크 인덱스는 uq_staff_login_id 와 uq_staff_email 뿐이다. 표 제약 UNIQUE 를 겹치지 않는다. 둘 다 막힌 행을 포함한다. 막힌 login_id 와 email 은 다시 쓰지 않는다. 다시 들이려면 disabled_at 을 null 로 둔다. 고객의 login_id 또는 email 과 같은 문자열은 된다. 고객 유니크와 직원 유니크는 따로다. login_id 는 만든 뒤 바꾸지 못한다. 004 의 BEFORE UPDATE 트리거가 OLD.login_id 와 다르면 예외를 낸다. 고객 login_id 트리거는 이 절이 만들지 않는다. email 과 name 과 password_hash 와 disabled_at 은 고칠 수 있다. 물리 DELETE 는 예외다. 001 의 forbid_physical_delete 를 staff 에 BEFORE DELETE 로 건다. 그 예외의 감사 행은 없다. 전화, 역할, 권한, 세션, 마지막 로그인 시각 칸은 없다. 로그인을 적으려고 행을 수정하지 않는다. 로그인 성공과 실패는 29절 그대로 감사 행이 아니다. ### 31.2 로그인 인트라넷 로그인은 staff 만 본다. login_id 가 같고 disabled_at 이 null 인 행의 password_hash 만 검사한다. 고객 로그인은 customers 만 본다. 같은 문자열의 아이디가 양쪽에 있어도 서로 통과시켜 주지 않는다. 직원 이메일은 입금요청 주소가 아니다. staff.email 을 고쳐도 payment_notice_mails.to_address 는 그대로다. orders.source 의 staff 값은 계속 글자다. orders 에 직원 칸을 더하지 않는다. refunds.requested_by 와 approved_by 는 계속 text 다. 직원 FK 가 아니다. 로그인 가능한 직원이 한 명은 있어야 한다는 제약은 없다. 마지막 활성 직원도 자신을 막을 수 있다. actor_kind 가 system 이면 disabled_at 을 보지 않고 그 칸을 다시 비울 수 있다. ### 31.3 감사 audit_events 끝에 actor_staff_id bigint null 을 더한다. staff(id) 를 가리키고 ON DELETE RESTRICT 다. 부분 인덱스는 actor_staff_id 가 null 이 아닌 행만이다. 002 의 행위자 모양 CHECK 만 갈아끼운다. actor_kind 목록, action 목록, table_name, request_id, insert/update/delete 모양 CHECK 는 유지한다. customer 이면 actor_customer_id 가 있고 actor_label 은 null 이고 actor_staff_id 는 null 이다. staff 이면 actor_customer_id 는 null 이고 actor_staff_id 가 있고 actor_label 은 앞뒤 공백을 뺀 1자 이상이다. system 이면 셋 다 null 이다. 앱은 set_config 로만 행위자를 둔다. 이름은 29절의 넷에 intranet.actor_staff_id 를 더한다. 세 번째 인자는 true 다. 빈 문자열은 null 이다. 설정이 없으면 actor_kind 는 system 이고 직원 칸은 null 이다. 첫 직원 행은 행위자 설정을 비운 채 만든다. 그 감사 행의 actor_kind 는 system 이고 actor_staff_id 는 null 이다. actor_kind 가 customer, staff, system 이 아니면 그 문은 실패하고 감사 행은 없다. staff 인데 직원 id 가 없거나 그 행이 없거나 고객 id 가 같이 있으면 실패하고 감사 행은 없다. id 가 숫자가 아니면 실패한다. 세션을 열 때 그 시점의 staff.name 을 intranet.actor_label 에 복사한다. 감사 행은 그 문자열을 저장한다. 저장 뒤에 staff.name 과 같아야 한다는 제약은 없다. 이름을 고치는 문에서도 라벨은 세션의 문자열이다. 라벨이 김민수이고 새 이름이 이민수여도 문은 성공한다. 예전 감사 행의 actor_label 은 바꾸지 않는다. 로그인과 감사 트리거가 disabled_at 을 본다. 감사 트리거는 행위자 직원의 disabled_at 을 그 문 시작 값으로 본다. 행위자 자신의 staff 행을 UPDATE 하는 문의 시작 값은 고치기 전 disabled_at 이고, 다른 문은 그 직원의 disabled_at 이다. 시작 값이 null 이 아니면 문을 실패시키고 감사 행을 만들지 않는다. 자신을 막는 UPDATE 는 시작 값이 null 이라 성공한다. 막힌 뒤 그 행위자가 자기 칸을 비우거나 다른 표를 고치면 실패한다. 다른 활성 직원은 그 칸을 비울 수 있다. 004 는 audit_redact 와 audit_capture 를 갈아끼운다. search_path 는 pg_catalog, public 이다. 비밀 칸에 staff.password_hash 를 더한다. 값이 있으면 [redacted] 다. staff.email 과 staff.name 은 비밀이 아니다. 002 의 다른 비밀 칸은 그대로다. 가리기는 비교 다음이다. 빈 수정은 감사 행이 없다. AFTER 트리거는 null 을 반환한다. staff 에만 audit_capture_staff 를 단다. 001 의 40개 표에 트리거를 더 달거나 빼지 않는다. 002 파일의 트리거 목록은 고치지 않는다. ### 31.4 설명문 29절 설명문 단락 뒤에 다음만 넣는다. 인트라넷에 로그인하는 직원은 고객과 다른 표에 둡니다. 아이디와 비밀번호를 저장하는 방식은 고객과 같고, 그만둔 직원은 행을 지우지 않고 로그인만 막습니다. erd.md 첫 문장에 31절의 staff 를 넣고 staff 에서 audit_events 로 actor_staff_id 화살표를 하나 더한다. 4절 그림과 다른 화살표는 그대로다. 서명 줄의 13절부터 30절까지는 13절부터 31절까지로 바꾼다. 12절 날짜는 그대로다. ### 31.5 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | Q1 | 설정 없이 staff 를 넣는다. login_id 는 minsu, name 은 김민수, email 은 minsu@example.com, password_hash 접두가 맞다 | 행이 생긴다. customers 는 늘지 않는다. 감사 actor_kind 는 system 이고 actor_customer_id 와 actor_staff_id 와 actor_label 은 null 이다. after.password_hash 는 [redacted] 이고 after.email 은 minsu@example.com 이다 | | Q2 | 고객 login_id 와 email 이 그 직원과 같다 | 두 행이 같이 있다. 인트라넷 로그인은 disabled_at 이 null 인 staff 만 본다. 고객 로그인은 customers 만 본다 | | Q3 | login_id 가 3자이거나 @ 가 있거나 대문자다. password_hash 접두가 다르다. name 이 공백이다. 같은 직원 email 을 다시 쓴다 | 어느 쪽도 새 직원 행이 없다 | | Q4 | 김민수 세션으로 products.name 을 고친다. actor_kind 는 staff, actor_staff_id 는 그 직원, actor_label 은 김민수, 고객 id 는 비운다 | 감사 actor_kind 는 staff 다. actor_staff_id 가 그 직원이고 actor_label 은 김민수 이며 actor_customer_id 는 null 이다 | | Q5 | staff 인데 actor_staff_id 가 없거나, 없는 직원이거나, 고객 id 를 같이 두거나, actor_kind 가 admin | 그 수정은 실패하고 감사 행은 없다 | | Q6 | disabled_at 이 있는 직원으로 로그인한다. 같은 login_id 로 새 직원 행을 넣는다 | 세션이 없다. 로그인 시도의 감사 행은 없다. 직원 행은 남는다. 새 행은 유니크에 걸린다 | | Q7 | 활성 직원 A와 B가 있다. A가 자기 disabled_at 을 채운 뒤 그 id 로 products 를 고친다. A가 자기 disabled_at 을 비운다. B가 A의 칸을 비운다 | A를 막는 UPDATE 는 성공하고 감사가 있다. products 수정은 실패하고 감사가 없다. A가 비우기는 실패한다. B가 비우면 성공하고 A는 다시 로그인된다. 새 직원 행은 없다 | | Q8 | 세션 라벨이 김민수인 채로 name 을 이민수로 고친다. 같은 값으로 name 을 다시 쓴다 | 이름 변경은 성공한다. staff.name 은 이민수 다. 그 감사 actor_label 은 김민수 다. 예전 감사 행은 바뀌지 않는다. after.name 은 이민수 다. 같은 값을 다시 쓰면 감사 행이 없다 | | Q9 | password_hash 를 다른 합격 해시로 고친다 | changed_columns 에 password_hash 가 있다. before 와 after 의 password_hash 는 [redacted] 다. 해시 문자열은 없다 | | Q10 | login_id 를 고치거나 staff 를 DELETE | 둘 다 실패한다. 행은 남는다. 그 실패의 감사 행은 없다 | | Q11 | 고객 세션으로 services 를 고친다. actor_staff_id 설정은 비운다 | actor_kind 는 customer 다. actor_staff_id 는 null 이다. 직원 행이 없어도 그 수정은 된다 | | Q12 | orders.source 가 staff 이거나 requested_by 가 글자다. staff.email 을 다른 소문자 주소로 고친다. 입금요청 메일이 있다 | source 와 requested_by 는 text 다. 직원 FK 를 더하지 않는다. 권한 표, 문의 표, 세션 표, SMS 표, 재설정 토큰 표는 없다. to_address 는 그대로다 | | Q13 | 활성 직원이 없는 뒤 system 이 disabled_at 을 null 로 둔다 | 그 직원은 다시 로그인된다. 데이터베이스가 활성 직원 수를 막지 않는다 | ## 32. 로그인 잠금과 허용 주소 이 문안이 32절이다. 고객과 직원 모두 로그인에 실패 잠금과 허용 주소를 둔다. customers 와 staff 에 칸을 더하지 않는다. failed_count 와 locked_until 을 그 두 표에 넣지 않는다. disabled_at 은 31절 그대로 그만둔 직원이고, 실패 잠금을 그 칸에 넣지 않는다. 세션 표, 캡차, 2단계, 국가 차단, 주소 대역, 시도 이력 표, 권한 표, 문의 표는 없다. 15.7의 해외 IP 로그인 차단과 방화벽 IP 와 활동 로그는 계속 없다. 이 절의 주소는 서버 방화벽도 아니고 호스팅 추가 IP 도 아니다. 10절과 15.7과 18.3과 28절과 29절과 30절과 31절의 문장은 고치지 않는다. 12.3의 R(P), 23절부터 27절의 금액, S-A의 60000 은 고치지 않는다. 001_init.sql 과 002_audit.sql 과 003_notice.sql 과 004_staff.sql 은 고치지 않는다. 변경은 docs/schema/005_login_lock.sql 이다. 적용 순서는 001, 002, 003, 004, 005 다. 005 는 audit_capture 와 audit_redact 를 갈아끼우지 않는다. 역할과 권한 부여는 만들지 않는다. ### 32.1 잠금 표 login_locks: id, subject, customer_id? → customers, staff_id? → staff, failed_count, window_started_at, locked_until?, created_at id 는 bigint generated always as identity 다. created_at 은 timestamptz not null default now() 다. updated_at 은 없다. subject 는 text not null 이다. failed_count 는 integer not null 이다. window_started_at 은 timestamptz not null 이다. locked_until 은 timestamptz null 이다. FK 는 ON DELETE RESTRICT 다. CASCADE 는 없다. subject 가 customer 이면 customer_id 가 있고 staff_id 는 null 이다. staff 이면 staff_id 가 있고 customer_id 는 null 이다. 그 밖 subject 는 실패다. failed_count 는 0 이상 5 이하다. 5이면 locked_until 이 있고, 5보다 작으면 locked_until 은 null 이다. 유니크 인덱스는 uq_login_locks_customer 와 uq_login_locks_staff 뿐이다. 표 제약 UNIQUE 를 겹치지 않는다. 각각 customer_id 가 null 이 아닌 행, staff_id 가 null 이 아닌 행이다. 계정 하나당 잠금 행은 하나다. 고객과 직원은 login_id 문자열이 같아도 잠금 행이 따로다. 감사 트리거를 달지 않는다. 이 표의 insert 와 update 와 delete 는 감사 행을 만들지 않는다. 29절은 로그인 성공과 실패를 감사 행이 아니라고 했다. 그 문장은 그대로다. 잠금 행을 푸는 직원의 문도 감사 행이 아니다. customers 와 staff 는 로그인을 적으려고 고치지 않는다. 성공 시각 칸은 없다. BEFORE 트리거 login_lock_writer 가 행위자를 본다. 설정이 없거나 actor_kind 가 system 이면 insert 와 update 와 delete 가 된다. customer 이면 항상 실패하고 행은 그대로다. staff 이면 그 직원 행이 있고 그 시점의 disabled_at 이 null 일 때만 된다. 막힌 직원이 고치면 실패한다. actor_kind 가 customer, staff, system 이 아니면 실패한다. 그 실패의 감사 행은 없다. 빈 문자열은 null 이다. insert 와 update 는 새 행을 반환하고 delete 는 옛 행을 반환한다. search_path 는 pg_catalog, public 이다. ### 32.2 허용 주소 login_ip_allows: id, subject, customer_id? → customers, staff_id? → staff, address, created_at id 와 created_at 과 subject 와 FK 는 잠금 표와 같다. updated_at 은 없다. address 는 inet not null 이다. IPv4 는 masklen 32 만, IPv6 는 masklen 128 만 된다. 대역은 넣지 못한다. 별칭 칸은 없다. 유니크 인덱스는 uq_login_ip_allows_customer 의 (customer_id, address), uq_login_ip_allows_staff 의 (staff_id, address) 뿐이다. 각각 그 id 가 null 이 아닌 행이다. 표 제약 UNIQUE 를 겹치지 않는다. PostgreSQL inet 같음이라 192.0.2.1 과 192.0.2.1/32 는 한 주소다. 이 표에는 audit_capture_login_ip_allows 를 단다. 004 에 있는 audit_capture 를 그대로 쓴다. address 는 비밀이 아니라서 audit_redact 를 고치지 않는다. 바꾼 문과 감사 행은 같은 트랜잭션이다. BEFORE 트리거 login_ip_allow_owner 가 소유를 본다. 설정이 없거나 system 이면 어떤 행이든 된다. customer 이면 그 문의 행이 subject customer 이고 staff_id 가 null 이고 customer_id 가 그 행위자 고객과 같을 때만 된다. update 는 고치기 전과 후가 모두 그렇다. 다른 고객의 행이나 직원 행이거나 고객 id 가 숫자가 아니면 실패하고 감사 행은 없다. staff 이면 대상 계정은 가리지 않는다. 막힌 직원은 31절 감사 트리거가 문을 실패시키고 감사 행은 없다. customer, staff, system 이 아니면 실패하고 감사 행은 없다. 자기 현재 주소를 목록에 남겨야 한다는 제약은 없다. 마지막 주소를 지워 빈 목록으로 둘 수 있다. 허용 주소가 없는 활성 직원이 남아야 한다는 제약은 없다. insert 와 update 는 새 행을 반환하고 delete 는 옛 행을 반환한다. search_path 는 pg_catalog, public 이다. ### 32.3 시도 attempt_at 은 그 시도에서 clock_timestamp() 를 한 번 읽은 값이다. 비교와 locked_until 은 그 값만 쓰고, 문 안의 now() 로 다시 재지 않는다. 서울 달력은 쓰지 않는다. 시도 주소는 앱이 넘긴 inet 하나다. 데이터베이스는 헤더를 읽지 않는다. 로그인 시도 트랜잭션은 행위자 설정을 두지 않는다. 틀린 비밀번호와 맞는 비밀번호가 고치는 표는 login_locks 뿐이다. 맞는 비밀번호인데 잠금 행이 없으면 아무 표도 고치지 않는다. 주소가 null 이거나 호스트 마스크가 아니면 실패하고 어느 표도 고치지 않는다. 고객 로그인은 customers 만 보고, 직원 로그인은 staff 만 본다. 31절 그대로 직원은 login_id 가 같고 disabled_at 이 null 인 행만 비밀번호 대상이다. 그 행이 없으면 실패하고 login_locks 를 만들지 않는다. 막힌 직원은 비밀번호를 보지 않고, 잠금 행이 있어도 횟수를 올리거나 지우지 않는다. 그 계정의 login_ip_allows 가 0행이면 호스트 주소는 모두 통과다. 1행 이상이면 같은 address 가 있을 때만 통과다. IPv4 와 IPv6 를 서로 바꾸지 않고 포함 연산자는 쓰지 않는다. 목록에 없으면 실패하고 비밀번호를 보지 않고 login_locks 를 고치지 않는다. 잠금이 활성이면 실패다. 활성은 locked_until 이 null 이 아니고 attempt_at < locked_until 이다. attempt_at 이 locked_until 과 같으면 활성이 아니다. 활성인 동안은 비밀번호가 맞아도 로그인되지 않고, 틀려도 failed_count 와 window_started_at 과 locked_until 과 created_at 은 그대로다. 창의 끝이 지났어도 locked_until 전이면 그대로다. 잠금을 늘리지 않는다. 거기까지 통과한 뒤 앱이 기존 해시로 비밀번호를 본다. 비밀번호 함수는 더하지 않는다. 틀리면 그 계정의 잠금 행을 고치고 그 변경은 커밋한다. 행이 없으면 insert 한다. 동시에 두 시도가 insert 하면 유니크에 진 쪽은 다시 읽어 이어서 더한다. 고치기 전에 그 행을 FOR UPDATE 로 잠근다. customers 와 staff 행은 잠그지 않는다. 틀린 비밀번호는 잠금이 아님을 확인한 다음에만 값을 바꾼다. failed_count 가 0 이거나 5 이거나 attempt_at >= window_started_at + 15분 이면 failed_count 는 1, window_started_at 은 attempt_at, locked_until 은 null 이다. 그렇지 않으면 failed_count 를 1 더하고 window_started_at 은 그대로다. 더한 값이 5이면 locked_until 은 attempt_at + 15분 이다. 15분은 다섯 번째 실패 시각부터다. 저장 횟수는 5를 넘지 않는다. 횟수 표와 설정 표는 없다. 비밀번호가 맞고 잠금이 활성이 아니면 로그인된다. 잠금 행이 있으면 failed_count 는 0, locked_until 은 null, window_started_at 은 attempt_at 이다. customers 와 staff 는 고치지 않는다. 그 성공의 감사 행은 없다. 앱의 로그인 실패는 하나의 결과다. 없는 아이디, 틀린 비밀번호, 잠금, 허용되지 않은 주소, 막힌 직원, 호스트가 아닌 주소를 서로 다른 SQLSTATE 로 가리지 않는다. 가드와 CHECK 의 예외는 표를 직접 쓰는 문이다. 잠금은 새 로그인만 막는다. 이미 연 접속을 끊는 표는 없다. 허용 주소가 없는 계정은 아이디를 아는 틀린 비밀번호로 잠길 수 있다. 잠금이 끝난 뒤 틀린 비밀번호 다섯 번이면 다시 잠긴다. 활성 직원은 잠금 행의 failed_count 를 0, locked_until 을 null 로 두거나 그 행을 지울 수 있다. 그 문도 감사 행이 없다. 이렇게 0 이 되면 다음 틀린 비밀번호는 1 부터다. actor_kind system 은 허용 주소 행을 지울 수 있고 그 삭제는 감사가 있다. 주소 때문에 못 들어오는 계정은 다른 활성 직원이나 system 이 그 주소를 지운 뒤에 다른 주소로 들어온다. ### 32.4 설명문 31절 설명문 뒤에 다음만 넣는다. 비밀번호를 15분 안에 다섯 번 틀리면 그 시각부터 15분 동안 로그인할 수 없습니다. 허용한 IP가 없으면 주소는 가리지 않고, 하나라도 있으면 등록한 주소에서만 로그인됩니다. erd.md 첫 문장에 32절의 login_locks 와 login_ip_allows 를 넣는다. customers 와 staff 에서 login_locks 로, 계정 하나당 최대 하나이고 가리키는 id 가 빌 수 있는 화살표를 더한다. login_ip_allows 로는 여러 행이 되고 id 가 빌 수 있는 화살표를 더한다. 4절 그림과 다른 화살표는 그대로다. 부분 유니크는 그리지 않는다. 서명 줄에서 13절부터 31절의 날짜는 그대로 두고, 32절은 두 검토가 서명한 날로 덧붙인다. ### 32.5 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | U1 | 고객 비밀번호가 틀리고 잠금 행이 없다 | login_locks 가 하나 생기고 failed_count 는 1, locked_until 은 null, window_started_at 은 attempt_at 이다. customers 는 그대로다. 감사 행은 늘지 않는다 | | U2 | 그 창 안에서 틀린 횟수가 다섯 번째가 된다 | failed_count 는 5 다. locked_until 은 그 시도의 attempt_at 더하기 15분이다. window_started_at 은 첫 실패 시각 그대로다. 감사 행은 없다 | | U3 | 잠금이 활성인 동안 맞는 비밀번호, 이어서 틀린 비밀번호. 창의 끝은 지났어도 locked_until 전이다 | 둘 다 로그인되지 않는다. failed_count 와 window_started_at 과 locked_until 은 그대로다. 잠금 끝은 늘어나지 않는다 | | U4 | U2 로 잠근 계정에서 attempt_at 이 locked_until 과 같고 비밀번호가 틀린다 | 활성이 아니다. failed_count 는 1 이고 locked_until 은 null 이며 window_started_at 은 그 attempt_at 이다. 횟수는 6이 되지 않는다 | | U5 | 네 번 틀린 뒤 창 안에서 비밀번호가 맞다. 잠금 행이 없는 다른 계정은 맞다 | 로그인된다. 있던 행의 failed_count 는 0, locked_until 은 null 이다. customers 와 staff 는 그대로고 감사 행은 없다. 없던 행은 만들지 않는다 | | U6 | 없는 login_id. 막힌 직원의 맞는 비밀번호와 이미 있는 잠금 행. 고객과 같은 문자열의 활성 직원 | 없는 아이디는 잠금 행이 없다. 막힌 직원은 비밀번호를 보지 않고 잠금 행을 만들거나 고치지 않는다. 고객이 다섯 번 잠겨도 그 직원은 로그인된다 | | U7 | attempt_at 이 window_started_at 더하기 15분과 같고 failed_count 는 4 다 | 창이 아니다. 다섯 번째로 세지 않는다. failed_count 는 1 이고 locked_until 은 null 이다 | | U8 | 고객 허용 주소가 192.0.2.1 하나다. 192.0.2.9 에서 맞는 비밀번호, 192.0.2.1 에서 틀린 비밀번호, 시도 주소 192.0.2.0/24, 시도 주소 2001:db8::1 | 192.0.2.9 는 호스트이나 목록에 없어 실패하고 비밀번호를 보지 않으며 잠금 행은 그대로다. 192.0.2.1 의 틀린 비밀번호만 횟수를 올린다. 192.0.2.0/24 는 호스트 마스크가 아니라 목록 비교 전에 실패하고 행은 그대로다. 2001:db8::1 은 호스트이나 목록의 주소와 같지 않아 실패하고 횟수는 그대로다 | | U9 | 허용 주소가 0행인 고객이 호스트에서 맞는다. 192.0.2.0/24 를 넣는다. 192.0.2.1 을 두 번 넣는다. 2001:db8::1 을 넣는다 | 0행이면 호스트 주소는 통과다. /24 는 CHECK 로 실패한다. 두 번째는 유니크 실패다. IPv6 호스트는 들어간다. 별칭 칸은 없다 | | U10 | 고객 행위자가 자기 주소를 넣고 그 주소를 지운다. 다른 고객의 주소와 직원 주소를 넣는다. 그 고객이 login_locks 를 0 으로 고친다 | 자기 주소의 감사는 insert 와 delete 이고 table_name 은 login_ip_allows 다. insert 의 after.address 와 delete 의 before.address 가 그 inet 이다. 다른 고객과 직원 주소는 실패하고 감사가 없다. 잠금 수정은 실패하고 잠금 값은 그대로이며 감사가 없다 | | U11 | 활성 직원이 그 고객의 허용 주소를 지운다. 막힌 직원이 주소를 지운다. 설정 없는 system 이 직원 허용 주소를 모두 지운다 | 활성 직원의 삭제는 되고 감사 action 은 delete 다. 고객은 다시 어느 호스트에서나 된다. 막힌 직원은 실패하고 행과 감사는 그대로다. system 삭제는 되고 actor_kind 는 system 이다. 그 직원은 다른 주소에서 로그인된다 | | U12 | 잠긴 고객을 활성 직원이 failed_count 0, locked_until null 로 고친 뒤 고객이 맞는 비밀번호를 낸다. 직원이 잠금 행을 DELETE 한다 | 풀린 뒤 로그인된다. 다음 틀린 비밀번호는 1 부터다. 그 풀기와 삭제의 감사 행은 없다. staff.disabled_at 은 바뀌지 않는다 | | U13 | 잠금 행이 없는 계정에 틀린 비밀번호 두 건이 겹친다 | 잠금 행은 하나이고 failed_count 는 2 다. 진 insert 는 다시 읽어 이어서 더한다. customers 행을 FOR UPDATE 하지 않는다 | | U14 | 005 를 적용한다 | 늘어난 표는 login_locks 와 login_ip_allows 뿐이다. customers 와 staff 의 칸은 31절 그대로다. 세션 표, 시도 이력 표, 국가 칸, 대역 행은 없다. 001 부터 004 파일은 그대로다 | ## 33. 가격의 일 단위와 시 단위 이 문안이 33절이다. prices.period_unit 에 day 와 hour 를 더한다. order_items.period_unit 에 hour 를 더한다. day 는 주문 줄에 이미 있다. 5.1과 12절과 15.1과 28.4의 기간 단위 문장은 고치지 않는다. 33절이 그 목록보다 우선한다. 4절 그림은 다시 그리지 않는다. 12.3의 R(P), 23절부터 27절의 금액, S-A의 60000, R1의 27000, R2의 6097, R3의 108000, F16의 27000, H17의 2000, B8의 2613 은 고치지 않는다. 새 action, 새 표, 새 상태값은 없다. 001_init.sql 부터 005_login_lock.sql 까지는 고치지 않는다. 변경은 docs/schema/006_period_unit.sql 이다. 적용 순서는 001, 002, 003, 004, 005, 006 이다. 006 은 트리거와 함수를 만들지 않는다. 역할과 권한 부여는 없다. 30절 메일은 고치지 않는다. ### 33.1 허용 값 006 은 prices_period_unit_check 를 갈아 끼운다. prices.period_unit 은 year, month, day, hour, once 다. order_items_period_unit_check 도 갈아 끼운다. order_items.period_unit 은 year, month, day, hour, once 다. 001 만 적용하면 가격의 day 와 hour, 주문 줄의 hour 는 실패한다. 001 의 주문 줄 day 는 그대로 된다. prices_day_hour_count_check 는 가격의 day 와 hour 가 period_count 1 이상일 때만 통과다. order_items_day_hour_count_check 는 hour 줄만 period_count 1 이상이다. 주문 줄의 day 에는 개수 하한을 두지 않는다. 12절 날짜 차가 0인 줄이 남기 때문이다. prices_day_hour_action_check 와 order_items_day_hour_action_check 는 day 또는 hour 이면서 action 이 owner_change 또는 usage 이면 실패다. usage 와 owner_change 의 once 는 15.2와 16.3 그대로다. year 나 month 인 usage 가격을 이 절이 새로 막지는 않는다. sell_amount 는 그 period_count 개의 그 단위에 대한 부가세 포함 금액이다. 1개 단가에 줄의 개수를 다시 곱하지 않는다. 반기는 month 와 period_count 6 이다. week 와 minute 는 없다. 다른 표와 비교하는 규칙은 28.4대로 CHECK 도 트리거도 아니다. ### 33.2 정가 줄 12절이나 22절이 부모 끝에 맞추는 줄은 month 또는 year 가격만 고른다. day 또는 hour 가격은 그 맞춤의 price_id 가 아니다. 그 가격을 사는 줄은 가격 행의 period_unit 과 period_count 를 그대로 가진다. period_start 는 그 action 의 기존 시각이다. register 는 22절의 주문 생성 시각이고, renew 는 그 서비스의 expires_at 이다. day 의 period_end 는 period_start 의 Asia/Seoul 벽시계로 period_count 일을 더한 같은 시각이다. hour 의 period_end 는 period_start 에 period_count 시간을 더한 시각이고 1시간은 3600초다. 끝은 시작보다 늦다. recurring_amount 가 null 이면 unit_price 는 sell_amount 다. discount_percent 가 있고 27절의 기한 안이면 unit_price 는 27절의 P 다. amount 는 unit_price * quantity 다. 이 줄에는 12.3을 쓰지 않는다. 27절 문장은 고치지 않고, 33절이 이 정가 줄에서만 P 를 나누지 않는다. recurring_amount 가 null 이 아니면 amount 는 그 값이고 quantity 를 다시 곱하지 않으며 unit_price 는 sell_amount 다. add 는 27.2대로 이 할인을 쓰지 않고 sell_amount * quantity 다. supply_amount 와 vat_amount 는 13절의 절사다. 이 줄의 끝이 부모 expires_at 보다 늦으면 만들지 못한다. 22절의 같은 주문 예외도 이 가격에는 쓰지 않는다. 이행이 expires_at 과 next_due_at 을 period_end 로 두는 기존 문장은 그대로다. product_type 이 domain 인 상품이거나 kind 가 domain 인 서비스의 줄은, price_id 의 period_unit 이 day 또는 hour 이거나 그 줄의 period_unit 이 day 또는 hour 이면 만들지 못한다. 가격 행 자체는 CHECK 가 막지 않는다. ### 33.3 맞춤, 자동 연장, 상품 변경 12.3의 price_id 는 계속 month 또는 year 다. once 와 day 와 hour 로는 맞출 수 없다. 달력 월이 아니면 줄은 period_unit=day 이고 period_count 는 Asia/Seoul 날짜 차다. 그 줄의 price_id 는 month 또는 year 가격이다. 같은 상품에 day 가격이 있어도 그 맞춤은 그 행을 고르지 않는다. 자동 renew 가 고른 현재 가격의 period_unit 이 day 또는 hour 이면 새 줄은 그 행의 단위와 개수다. 맞춤 줄의 개수가 아니다. 끝과 금액은 이 절의 정가 줄이다. 부모 서비스가 없으면 부모 끝과 비교하지 않고 그 정가 줄을 만든다. 부모가 있고 그 끝이 부모 expires_at 보다 늦으면 자동 주문을 만들지 않는다. 끝을 부모에 맞추지 않고 12.3도 쓰지 않는다. month 와 year 는 24절 그대로 부모 끝에 맞출 수 있다. price_id 가 없고 줄의 단위가 day 또는 hour 이면 자동 주문이 없다. 후보가 없거나 둘이면 없다. H10 은 그대로다. 24.5의 수량 증가는 12.3이다. 그 계산이 고를 가격이 day 또는 hour 이면 그 change 를 만들지 못한다. 25.5가 세는 현재 renew 가격은 month 또는 year 뿐이다. day 와 hour 행은 그 개수에 넣지 않는다. 남는 쌍이 하나가 아니면 상품 변경 줄이 없다. 27절은 day 와 hour 가격도 action, period_unit, period_count 를 복사하고 sell_amount 만 바꾼다. 서버의 일·시 줄은 정확히 N개월이 아니므로 25.4대로 환불액 0이다. 그 밖 유지 금액은 23절과 24절 문장 그대로다. ### 33.4 설명문 02의 "한 달을 30일로 두거나, 기간이 길다고 깎아 주는 표는 만들지 않습니다." 다음에만 넣는다. 가격표의 기간에는 일과 시도 있습니다. 3일 가격이 5,000원이면 그 줄은 5,000원이고, 시작 시각부터 서울 날짜로 3일 뒤 같은 시각까지입니다. 2시간 가격은 시작 시각에 그 시간을 더한 끝까지입니다. 끝나는 날을 맞추는 계산은 월 가격과 년 가격만 고릅니다. erd.html 의 prices.period_unit 설명은 "year, month, day, hour, once 중 가격의 기간 단위다. 반기는 month 와 개수 6이다. 판매가는 그 개수만큼의 금액이다." 다. order_items.period_unit 설명은 "year, month, day, hour, once 중 이 줄의 기간 단위다." 다. order_items.period_count 설명은 "이 줄이 덮는 기간의 개수다. 달력 월이면 개월 수이고, 맞춤으로 자른 줄이면 서울 날짜 차다. 일 가격과 시 가격을 담은 줄은 그 가격의 개수다." 다. 검색용 data-find 도 그 문장을 담는다. erd.md 의 표와 화살표는 그대로다. 28.6의 40개 문장은 고치지 않는다. 서명 줄은 32절과 33절이 2026-10-08에 추가로 서명했다고 둔다. 12절 날짜와 13절부터 31절의 날짜는 그대로다. ### 33.5 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | V1 | renew 가격 day, period_count 3, sell_amount 5000, 수량 1, recurring_amount 없음. period_start 2026-10-08 15:00 Asia/Seoul | 줄은 day, 개수 3, unit_price 5000, amount 5000, vat_amount 454, supply_amount 4546 이다. period_end 는 2026-10-11 15:00 이다. R(P)를 쓰지 않는다 | | V2 | hour, period_count 2, sell_amount 1000, 수량 2. period_start 2026-10-08 23:30 Asia/Seoul | unit_price 1000, amount 2000 이다. period_end 는 2026-10-09 01:30 이다. period_count 2를 unit_price 에 다시 곱하지 않는다 | | V3 | day 1개, sell_amount 9000, 수량 1, recurring_amount 7000 | amount 는 7000 이고 unit_price 는 9000 이다. quantity 를 다시 곱하지 않는다 | | V4 | week, minute, Day, 가격 day 개수 0, 가격 hour 개수 -1, owner_change 의 day, usage 의 hour, 주문 줄 hour 개수 0. 그리고 hour 24, day 1, month 6, once 인 usage, once 인 owner_change, 주문 줄 day 개수 0 | 앞의 여덟은 실패한다. 뒤의 여섯은 들어간다. 반기는 month 6 그대로다 | | V5 | 같은 상품에 month 1개 sell_amount 9000 과 day 1개 sell_amount 300 이 있다. 맞춤 기간은 2027-06-01 00:00 부터 2027-06-04 00:00, 수량 1 | price_id 는 월 가격이다. 줄은 day, 개수 3, amount 900 이다. 300 을 쓰지 않는다 | | V6 | day 1개의 시작이 2028-02-28 15:00, 2027-02-28 15:00, 2026-10-31 15:00 | 끝은 2028-02-29 15:00, 2027-03-01 15:00, 2026-11-01 15:00 이다. 같은 시각이다 | | V7 | 부모가 없는 서비스다. 마지막 fulfilled register 의 price_id 가 현재 day 1개 가격이고, 그 서비스의 expires_at 은 2026-10-11 15:00, 수량 1, sell_amount 5000 이다. 다른 부모 없는 서비스는 마지막 줄이 day 이고 price_id 가 현재 month 1개다 | 첫 자동 줄의 period_start 는 2026-10-11 15:00 이고 단위는 day, 개수는 1, period_end 는 2026-10-12 15:00, amount 는 5000 이다. 비교할 부모가 없다. 둘째 자동 줄은 month 1개다. 맞춤 줄의 개수를 쓰지 않는다 | | V8 | V7의 일 가격 자동 끝이 부모 expires_at 2026-10-11 18:00 보다 늦다 | 자동 주문이 없다. 자식 expires_at 은 2026-10-11 15:00 그대로다. 12.3으로 자르지 않는다 | | V9 | price_id 가 없고 줄이 hour. 현재 day 1개 가격이 둘. H10 의 day 이고 price_id 없음 | 셋 다 자동 주문이 없다 | | V10 | 옛 상품과 새 상품에 renew month 1개와 renew day 1개가 있다. 월 쌍만 있는 F16. 일 쌍만 있는 다른 두 상품 | 월 쌍만 센다. F16 은 27000 이다. 일 쌍만 있으면 변경 줄이 없다 | | V11 | 호스팅의 이행된 day 3개 줄 amount 5000, 2026-10-08 15:00 부터 2026-10-11 15:00. started_at 은 2026-09-01. 새 끝은 2026-10-09 15:00. 남은 한도 5000 | 유지 1667, 환불 3333 이다. 7일 전액이 아니다. R1, R2, R3 은 그대로다 | | V12 | V11과 같은 구간인 서버 줄 | 환불액 0 이다. 환불 행은 없다 | | V13 | day 가격 sell_amount 5000 을 창 안에서 4000 으로 가른다. 이미 낸 줄이 있다 | 옛 행, 할인 행, 복귀 행이다. 단위와 개수는 같고 sell_amount 만 다르다. 이미 낸 줄은 그대로다. S-A 의 60000 은 그대로다 | | V14 | day 1개 sell_amount 9000, 수량 1, discount_percent 10, 기한 안인 renew | P 와 unit_price 와 amount 는 8100 이다. 12.3으로 나누지 않는다. B8 의 2613 은 그대로다 | | V15 | domain 상품의 day 가격으로 register 줄을 만든다. kind=domain 서비스의 hour 줄. 호스팅 상품의 day 가격 행 | 두 줄은 없다. 가격 행은 CHECK 를 통과한다. 호스팅의 day 가격 행은 생긴다 | | V16 | 디스크의 현재 가격이 day 뿐일 때 수량을 늘린다. H17 | change 줄이 없다. H17 의 2000 은 그대로다 | | V17 | 006 을 적용한다 | 새 표는 없다. 006 은 두 period_unit 검사와 day·hour 검사 넷이다. 001 부터 005 파일은 그대로다. 화살표는 그대로다 | ## 34. 통화는 원 이 문안이 34절이다. 통화 칸은 그 행의 금액이 어느 단위인지 가리킨다. 달러 금액 칸을 따로 만들지 않는다. 금액 칸이 이미 그 통화의 금액이다. 저장하는 통화는 KRW 뿐이다. 1은 1원이다. 전 단위와 센트는 없다. 환율 표, 환산 시각, sell_amount_usd, amount_usd 는 없다. 5절의 원 단위 문장과 28.2의 통화 기본값 문장과 D12 는 고치지 않는다. 34절이 KRW 만 저장한다고 더한다. 12.3의 R(P), 13절 부가세, 23절부터 33절의 금액, S-A의 60000, 15.6의 1000원 충전은 고치지 않는다. 새 표와 새 칸과 새 상태값은 없다. 001_init.sql 부터 006_period_unit.sql 까지는 고치지 않는다. 변경은 docs/schema/007_currency.sql 이다. 적용 순서는 001, 002, 003, 004, 005, 006, 007 이다. 007 은 트리거와 함수를 만들지 않는다. ### 34.1 여섯 칸 prices.currency, services.currency, orders.currency, order_items.currency, order_items.cost_currency, payments.currency 는 지금처럼 char(3) not null default 'KRW' 다. 007 이 각 칸에 CHECK 를 더한다. 값은 'KRW' 와 같아야 한다. 소문자와 다른 세 글자는 실패다. prices.currency 는 cost_amount 와 sell_amount 의 단위다. services.currency 는 recurring_amount 의 단위다. orders.currency 는 그 주문 헤더의 단위다. order_items.currency 는 unit_price, supply_amount, vat_amount, amount, settled_amount 의 단위다. order_items.cost_currency 는 cost_amount 의 단위다. payments.currency 는 payments.amount 의 단위다. 한 행에 원 금액과 달러 금액을 같이 두지 않는다. 프리미엄 도메인의 원가는 127의 문장대로 order_items.cost_amount 에 고정한다. 그 값도 원이다. 레지스트리가 달러로 청구한 금액을 다른 칸에 두지 않는다. cost_amount 를 amount 에 더하지 않는다. 주문과 줄과 결제와 가격의 통화가 같은지 보는 트리거는 없다. 각 행의 CHECK 로 충분하다. ### 34.2 통화 칸이 없는 금액 payment_allocations.amount, refunds.amount, refund_lines.amount, customer_balance_entries.amount, tax_documents 의 supply_amount 와 vat_amount 와 amount, tax_document_lines 의 세 금액, payment_notice_mails.quoted_amount 는 통화 칸이 없다. 007 도 칸을 더하지 않는다. 그 금액은 원이다. 예치금 합과 배분과 환불과 세금계산서는 13절과 15.6 그대로다. 30절 메일은 고치지 않는다. 가격을 고르는 24절과 27절은 통화를 따로 거르지 않는다. 저장된 가격이 KRW 뿐이라 후보는 그대로다. H10 은 그대로다. ### 34.3 설명문 02의 "영수증에 적힌 15,000원과 3,000원은 그 날의 가격입니다. 다음 주에 비공개 요금이 4,000원이 되어도 이 영수증은 3,000원입니다." 다음에만 넣는다. 금액은 원으로만 적습니다. 달러 금액을 담는 칸은 없습니다. erd.html 의 prices.currency 설명은 "이 가격의 통화다. KRW 만 된다. 원가와 판매가는 원이다." 다. services.currency 설명은 "이 서비스 금액의 통화다. KRW 만 된다. recurring_amount 는 원이다." 다. orders.currency 설명은 "이 주문 금액의 통화다. KRW 만 된다." 다. order_items.cost_currency 설명은 "원가의 통화다. KRW 만 된다. 원가다." 다. order_items.currency 설명은 "이 줄 금액의 통화다. KRW 만 된다. 단가와 공급가와 부가세와 청구액과 정산액은 원이다." 다. payments.currency 설명은 "이 결제 금액의 통화다. KRW 만 된다." 다. 검색용 data-find 도 그 문장을 담는다. 표와 화살표는 그대로다. 서명 줄은 32절부터 34절까지가 2026-10-08에 추가로 서명했다고 둔다. 12절 날짜와 13절부터 31절의 날짜는 그대로다. ### 34.4 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | X1 | 여섯 통화 칸을 비우고 prices.sell_amount 15000 을 넣는다 | 여섯 칸은 KRW 다. 15000 은 15000원이다. sell_amount_usd 칸은 없다 | | X2 | 여섯 칸에 USD, usd, krw, US$ 를 넣고, 이어서 KRW 를 넣는다 | 앞의 넷은 각 칸에서 실패한다. KRW 는 들어간다 | | X3 | 배분, 환불, 환불 줄, 예치금, 세금계산서, 세금계산서 줄, 안내 메일의 quoted_amount | 통화 칸이 없다. 007 이 칸을 더하지 않는다. 금액은 원이다. 1000원 충전은 그대로다 | | X4 | 프리미엄 줄의 cost_amount 8000, cost_currency KRW, amount 15000, currency KRW. cost_currency 를 USD 로 둔다 | 첫 줄은 둘 다 원으로 저장된다. amount 는 15000 이다. 원가를 청구액에 더하지 않는다. USD 원가는 실패한다 | | X5 | 007 을 적용한다 | 새 표와 새 칸은 없다. CHECK 는 여섯이다. 001 부터 006 파일은 그대로다. 환율 표는 없다 | ## 35. 원과 달러 이 문안이 35절이다. 34절이 KRW 만 허용하고 통화를 거르지 않으며 금액을 원으로만 두는 문장과 그 CHECK 보다 이 절이 우선한다. 34절 문장과 007_currency.sql 은 고치지 않는다. 저장하는 통화는 KRW 와 USD 다. 통화 칸은 그 행에 이미 있는 금액의 단위다. 달러 금액 칸을 따로 만들지 않는다. sell_amount_usd 와 amount_usd 와 환율 표와 환산 시각은 없다. 한 행에 원 금액과 달러 금액을 같이 두지 않는다. KRW 는 1이 1원이다. 전은 없다. USD 는 1이 1센트다. 1500 은 15.00 달러다. 센트 미만은 없다. 5절과 28.2와 D12 의 기본값 문장은 고치지 않는다. 칸을 비우면 기본 KRW 다. 12.3의 R(P), 13절의 KRW 부가세, 23절부터 33절의 원화 예시, S-A의 60000, H10, H17의 2000, F16의 27000, 15.6의 1000원 충전은 고치지 않는다. 12.3과 23절과 24절의 정수 나눗셈은 그 줄의 amount 단위로 같다. 새 표와 새 칸과 새 상태값과 새 인덱스는 없다. 001_init.sql 부터 007_currency.sql 까지는 고치지 않는다. 변경은 docs/schema/008_currency_usd.sql 이다. 적용 순서는 001, 002, 003, 004, 005, 006, 007, 008 이다. 008 은 트리거와 함수를 만들지 않는다. 다른 표의 통화가 같은지는 28.4대로 트리거가 아니다. ### 35.1 허용 값 008 은 prices_currency_check, services_currency_check, orders_currency_check, order_items_currency_check, order_items_cost_currency_check, payments_currency_check 를 갈아 끼운다. 각 값은 'KRW' 또는 'USD' 다. usd, krw, US$, EUR 은 실패다. 007 만 적용하면 USD 는 실패한다. order_items_same_currency_check 는 cost_currency 와 currency 가 같을 때만 통과다. prices.currency 는 cost_amount 와 sell_amount 의 단위다. services.currency 는 recurring_amount 의 단위다. orders.currency 는 그 주문 헤더의 단위다. order_items.currency 는 unit_price, supply_amount, vat_amount, amount, settled_amount 의 단위다. order_items.cost_currency 는 cost_amount 의 단위다. payments.currency 는 payments.amount 의 단위다. 같은 상품의 KRW 가격이 현재여도 USD 가격 행을 만든다. 그 행은 27절 할인이 아니다. 프리미엄 원가는 order_items.cost_amount 에 그 줄의 통화로 고정한다. 레지스트리 청구 통화를 줄 통화와 다르게 저장하지 않는다. cost_amount 를 amount 에 더하지 않는다. ### 35.2 한 주문 한 통화 한 주문의 줄은 orders.currency 와 같다. 부모 서비스가 있으면 그 줄은 부모 services.currency 와 같다. 다르면 응용 프로그램이 줄을 만들지 않는다. 008 은 그 삽입을 막지 않는다. 결제의 배분은 그 결제와 줄의 통화가 같을 때만 둔다. 한 환불의 줄은 한 통화다. refunds.amount 는 그 줄 합이다. 서비스를 만드는 줄의 currency 가 services.currency 다. 그 뒤 응용 프로그램은 services.currency 를 고치지 않는다. 24.5가 새 서비스에 복사하는 칸에 currency 를 더한다. 옛 서비스의 currency 는 그대로다. 예치금, 배분, 환불, 환불 줄, 세금계산서, 안내 메일의 quoted_amount 에는 통화 칸을 더하지 않는다. 예치금 행의 통화는 payment_id 가 있으면 그 결제의 통화이고, order_item_id 가 있으면 그 줄의 통화이고, refund_id 가 있으면 그 환불의 통화다. 고객의 잔액은 통화마다 따로 0 이상이다. 다른 통화 잔액을 더하거나 빼지 않는다. renew 를 만들 때 쓰는 예치금은 그 줄의 통화만이다. H6 과 H7 은 원화 예시다. 15.6 충전은 KRW 와 1000 이상 그대로다. 배분 없는 USD 카드 결제는 충전이 아니므로 만들지 않는다. USD 결제의 남은 과입금은 그 결제의 통화로 양수 예치금이 된다. KRW 줄의 공급가와 부가세는 13절이다. USD 줄은 vat_amount 가 0 이고 supply_amount 가 amount 다. 11 로 나누지 않는다. USD 줄의 문서 대상은 0 이다. 13.2의 합을 USD 줄에 쓰지 않는다. 세금계산서와 현금영수증은 KRW 줄만 담는다. S4 의 252040 은 그대로다. 16.3과 24절과 25.5와 27절이 현재 가격을 셀 때 currency 가 그 서비스의 currency 와 같은 행만 센다. 25.5의 새 상품 가격도 그 통화다. 그 통화의 가격이 없으면 변경 줄을 만들지 않는다. 그 뒤의 하나·둘 규칙은 그대로다. 12절이 맞출 때 고르는 month 또는 year 가격도 그 서비스의 currency 다. 27.1의 겹침 키에 currency 를 더한다. 27절 문장은 고치지 않는다. 같은 통화끼리만 창이 겹치면 만들지 못한다. KRW 행과 USD 행은 창이 겹쳐도 된다. 할인 분할은 그 행의 currency 를 복사하고 다른 통화의 창을 닫지 않는다. quoted_amount 는 그 서비스 통화의 정수다. null 이 아니면 30절 본문에 그 정수와 통화 코드를 둔다. 다른 숫자로 바꾸지 않는다. 1500 을 15 또는 15.00 으로 적지 않는다. null 이면 통화 코드도 금액으로 적지 않는다. N4 의 9000 은 그대로이고 본문에 KRW 를 더한다. 30절 문장은 고치지 않는다. ### 35.3 설명문 02의 "금액은 원으로만 적습니다. 달러 금액을 담는 칸은 없습니다." 를 다음으로 바꾼다. 금액은 원 또는 미국 달러입니다. 원은 1이 1원이고, 달러는 1이 1센트입니다. 한 줄에는 한 통화만 적습니다. erd.html 의 prices.currency 설명은 "이 가격의 통화다. KRW 또는 USD 다. KRW 의 원가와 판매가는 원이고, USD 는 센트다." 다. services.currency 설명은 "이 서비스 금액의 통화다. KRW 또는 USD 다. recurring_amount 는 그 단위다." 다. orders.currency 설명은 "이 주문 금액의 통화다. KRW 또는 USD 다. 한 주문의 줄은 이 통화다." 다. order_items.cost_currency 설명은 "원가의 통화다. 이 줄의 currency 와 같다. KRW 는 원이고 USD 는 센트다." 다. order_items.currency 설명은 "이 줄 금액의 통화다. KRW 또는 USD 다. 단가와 공급가와 부가세와 청구액과 정산액은 그 단위다." 다. payments.currency 설명은 "이 결제 금액의 통화다. KRW 또는 USD 다." 다. 검색용 data-find 도 그 문장을 담는다. 표와 화살표는 그대로다. 서명 줄은 32절부터 35절까지가 2026-10-08에 추가로 서명했다고 둔다. 12절 날짜와 13절부터 31절의 날짜는 그대로다. ### 35.4 맞는 경우 | 번호 | 상황 | 결과 | |---|---|---| | Y1 | 여섯 통화 칸을 비우고 prices.sell_amount 15000 을 넣는다. 같은 상품에 currency USD, sell_amount 1500 인 가격을 둔다 | 비운 칸은 KRW 다. 15000 은 15000원이다. 1500 은 1500센트, 곧 15.00 달러다. sell_amount_usd 칸은 없다 | | Y2 | 여섯 칸에 usd, krw, US$, EUR 을 넣고, 이어서 KRW 와 USD 를 넣는다 | 앞의 넷은 각 칸에서 실패한다. KRW 와 USD 는 들어간다. 007 만 적용하면 USD 는 실패한다 | | Y3 | KRW 줄과 USD 줄을 한 주문에 넣는다. USD 결제를 KRW 줄에 배분한다. KRW 예치금 2000 을 USD renew 1500 에 쓴다 | 응용 프로그램은 그 주문과 그 배분과 그 예치금 사용을 만들지 않는다. 008 은 두 줄의 삽입을 막지 않는다. 1000원 충전은 KRW 잔액이다 | | Y4 | USD 줄 amount 1500. KRW 줄 amount 15000. USD 결제 과입금 50. S4 | USD 줄은 vat_amount 0, supply_amount 1500 이고 세금계산서가 없다. KRW 줄은 vat_amount 1363, supply_amount 13637 이다. 과입금 50 은 USD 잔액이고 원화 잔액에 더하지 않는다. S4 의 252040 은 그대로다 | | Y5 | USD 줄 cost_amount 800, cost_currency USD, amount 1500, currency USD. 그 줄의 cost_currency 를 KRW 로 둔다. KRW 줄의 cost_currency 를 USD 로 둔다 | 첫 줄의 amount 는 1500 이다. 원가를 청구액에 더하지 않는다. 뒤의 둘은 실패한다 | | Y6 | USD 서비스에 현재 month 가격이 KRW 하나, USD 하나다. 다른 USD 서비스는 현재 USD month 가격이 둘이고 price_id 가 없다. USD 가격만 27절로 나눈다. 명의변경. quoted_amount 1500. N4. KRW 호스팅의 F16 에 같은 기간 USD 년 가격도 있다 | 첫 자동 줄은 USD 가격이다. 둘째 자동 주문은 없다. H10 의 KRW 가격 둘도 주문이 없다. 할인 행은 USD 이고 KRW 가격의 창은 닫히지 않는다. 새 서비스의 currency 는 USD 다. 본문에 1500 과 USD 가 있고 15.00 은 없다. N4 본문은 9000 과 KRW 다. F16 은 27000 이다. USD 년 가격은 세지 않는다 | | Y7 | 008 을 적용한다 | 새 표와 새 칸은 없다. CHECK 는 통화 여섯과 같은 줄 검사 하나다. 001 부터 007 파일은 그대로다. 환율 표와 트리거는 없다 |