Skip to content

Leyve 영업 유통 코어 백엔드 계약

목적과 경계

이 코어는 Leyve 자체 상품을 본사와 파트너가 판매할 때 고객계정, 판매귀속, 담당배정과 계정 준비 요청을 일관되게 기록한다.

  • CRM은 문의·리드·딜·수주를 소유한다.
  • Sales Distribution은 상업적 고객계정과 판매귀속을 소유한다.
  • Platform Company/Office/Auth는 실제 테넌트·사용자·권한을 소유한다.
  • Partner 정산은 이 코어의 실제 파트너 귀속을 참조하지만 별도 원장이다.

본사를 가상의 파트너로 만들지 않는다. 신규 계약의 seller_categoryfirst_party | channel_partner이며, 본사와 판매 파트너가 같은 생성 RPC를 사용한다. 수수료·정산 대상은 channel_partner만이다. organization_kind=internal | partner는 배포된 v1 클라이언트 호환 필드로만 유지한다.

스키마

테이블책임
sales_seller_organizations본사·파트너 판매주체와 전역관리/계정생성 능력
sales_seller_memberships사용자와 판매주체의 역할·활성 멤버십
sales_customer_accountsCRM prospect와 Platform Company 사이의 상업적 고객계정
sales_customer_attributions최초 유입·판매귀속 fact. append-only
sales_customer_attribution_semanticsv2 유입경로·등록방식 fact. append-only
sales_customer_assignments현재 영업·서비스·빌링 담당 배정
sales_customer_provisioning_requestsCompany·관리자·첫 Office·모듈 준비 요청
sales_customer_eventsappend-only 감사 이벤트
private.sales_distribution_commands멱등 명령의 요청 해시와 응답 저장
partner_organizationsPartner Center 계약 상대와 Sales seller 연결
partner_memberships파트너 사용자·역할·승인 상태
partner_applications신청·본사 심사·승인 상태
private.partner_approval_outbox승인과 channel partner 생성 사이의 lease outbox
sales_company_administrator_invitations프로비저닝 관리자 초대·수락 상태
private.sales_customer_provisioning_materializations요청별 Company·Office 멱등 결과
private.sales_product_provisioning_policies상품 판매상태·판매주체 종류·Office 에디션 호환 SSOT

두 작업 큐에는 manual_retry_count, last_manual_retry_at/by/reason이 있다. 자동 시도 횟수는 수동 재시도 때 새 주기로 초기화되지만, 수동 재시도 횟수와 append-only 이벤트로 전체 운영 이력을 보존한다.

RPC 계약

get_my_sales_seller_context_v1

  • 인증 사용자의 활성 판매주체 membership을 반환한다.
  • 출력은 sellerOrganizationId, sellerCategory, externalPartnerReference, 역할과 계정생성 권한이다.
  • Partner Center는 임의 seller ID를 입력받지 않고 이 결과와 자신의 파트너 참조가 일치하는 판매주체만 사용한다.

create_sales_customer_account_v2

  • 입력: 판매주체, 요청 키, 고객명, 사업자등록번호, 대표/담당 연락처, 실제 유입 경로, 선택적 referral/source request, 등록 방식
  • 유입 경로: phone | website | community | outbound | event | referral | existing_relationship | unknown
  • 등록 방식: self_service | first_party_assisted | channel_partner_assisted | admin_import
  • 유입 경로와 등록 방식은 독립된 fact다. 예: 전화 문의를 파트너가 대신 등록하면 phone + channel_partner_assisted다.
  • 출력: 생성된 고객계정 ID, 등록번호, 상태, sellerCategory, 유입 경로와 등록 방식
  • channel_partnerchannel_partner_assisted만 사용할 수 있고, first_party는 이를 사용할 수 없다.
  • v2는 내부에서 v1 명령의 멱등성·중복검사를 재사용하고, 정규화된 의미를 별도 append-only fact로 저장한다.

create_sales_customer_account_v1 (호환)

  • 입력: 판매주체, 요청 키, 고객명, 사업자등록번호, 대표/담당 연락처, 유입 채널, 선택적 referral/source request
  • registration_methodself_service | headquarters_assisted | partner_assisted를 사용한다.
  • 출력: 생성된 고객계정 ID, 등록번호, 상태와 판매주체
  • 본사 전역관리자는 본사 직판뿐 아니라 실제 파트너를 대신해 그 파트너 귀속 고객도 만들 수 있다.
  • 파트너 멤버는 자신의 판매주체만 사용하며 partner_referral | partner_created 채널만 허용한다.

request_sales_customer_provisioning_v1

  • 입력: 고객계정, 판매주체, 요청 키, 관리자 이메일, 첫 Office 이름/에디션, 요청 모듈 키
  • 출력: 프로비저닝 요청 ID와 상태
  • 고객계정 상태를 provisioning으로 옮기고 이벤트를 남긴다.
  • 이 RPC는 Platform Company를 직접 생성하지 않는다. 작업자가 요청을 소비해 Company/Auth/Office API를 각각 멱등 호출해야 한다.
  • INSERT trigger가 현재 seller 활성·계정생성 권한, catalog active, policy available, seller category와 Office edition 호환을 검증한다. 프런트 선택지는 권한 근거가 아니다.

list_sales_customer_accounts_v2

  • Sales Center와 신규 클라이언트의 기본 조회 RPC다.
  • v1 목록 권한·필터를 재사용하고 acquisitionSource, registrationMethod, attributedSellerCategory, provisioningStatus, administratorInvitationStatus를 반환한다.
  • 아직 v1으로 생성된 고객은 호환 값을 v2 의미로 정규화해 반환한다.

list_sales_customer_accounts_v1 (호환)

  • 본사 전역관리자: 전체 또는 지정 판매주체 범위 조회
  • 파트너: 자신의 귀속·담당 고객만 조회
  • 검색과 lifecycle 필터를 제공한다.

파트너 신청·승인 Bridge

  • submit_partner_application_v1: 인증 사용자가 파트너 조직·owner membership·신청을 함께 만든다.
  • list_partner_applications_v1: 본사의 승인 권한자가 상태별 신청과 seller 준비 결과를 조회한다.
  • get_my_sales_management_capabilities_v1: 현재 사용자의 파트너 승인·전체 고객 조회 capability를 서버에서 계산한다. UI는 seller 행 속성으로 승인 권한을 추론하지 않는다.
  • approve_partner_application_v1: 본사의 활성 first_party admin/sales manager만 승인하며 공백이 아닌 1,000자 이하 검토 메모를 서버에서 강제한다. 승인 transaction은 partner.approved outbox를 함께 기록한다.
  • claim_partner_approval_bridge_v1: service worker가 FOR UPDATE SKIP LOCKED로 outbox 하나를 lease한다.
  • bridge_approved_sales_partner_v2: claim한 outbox ID·lease token·worker ID가 현재 유효한 경우에만 channel_partner seller와 최초 admin membership을 멱등 생성한다. v1 bridge는 worker에 공개하지 않는다.
  • complete_partner_approval_bridge_v1 / fail_partner_approval_bridge_v1: lease token을 확인해 완료하거나 backoff 후 재시도한다.

bridge 성공 뒤 complete 전에 worker가 종료되어도 안전하다. lease 만료 후 같은 outbox를 다시 claim하고, seller·membership upsert를 재생한 뒤 complete한다. Edge Function은 private outbox를 PostgREST로 직접 읽지 않는다.

Company 프로비저닝 Worker

sales-provisioning-worker Edge Function은 서버 Secret으로만 호출한다. supabase-js Auth Admin과 RPC에는 자동 제공되는 SUPABASE_SERVICE_ROLE_KEY JWT를 사용하며 브라우저에 전달하지 않는다. opaque secret key를 supabase-js bearer token처럼 혼용하지 않는다. 함수는 CORS·OPTIONS를 열지 않는다.

  1. claim_sales_customer_provisioning_v1(worker, leaseSeconds)가 pending/approved, retry 가능 failed, 만료된 provisioning 요청 하나를 claim한다.
  2. prepare_sales_customer_provisioning_v1(request, lease)가 현재 lease와 seller·상품 판매상태·Office 호환 policy를 Auth 작업 직전에 재검증한다.
  3. resolve_sales_provisioning_auth_user_v1(email)로 기존 Auth 사용자를 확인한다.
  4. 사용자가 없을 때만 Auth Admin inviteUserByEmail()을 호출한다. resolve와 invite 사이 race가 나면 한 번 더 resolve한다.
  5. materialize_sales_customer_provisioning_v1(request, lease, user)가 lease와 현재 policy를 다시 검증한 뒤 한 transaction에서 Company, invited owner membership, Office 0, invited office_admin, Company 계약·item, Office entitlement, invitation과 멱등 결과를 만든다. 이미 결과가 있어도 현재 lease 검증보다 먼저 반환하지 않는다.
  6. complete_sales_customer_provisioning_v1이 요청과 고객계정을 완료·active로 옮기고 append-only 완료 이벤트를 기록한다.
  7. 오류는 fail_sales_customer_provisioning_v1이 code/detail/retry_after를 기록한다. 최대 10회 뒤 자동 claim 대상에서 제외한다.

기존·신규 Auth 사용자 모두 이메일 확인만으로 새 Company 권한을 자동으로 받지 않는다. 로그인 사용자는 list_my_pending_sales_company_invitations_v1로 자신의 초대를 확인하고 accept_sales_company_invitation_v1을 명시 호출해야 invited Company·Office membership이 active가 된다. 수락은 다른 사용자의 초대를 처리할 수 없고 멱등이다.

Company 생성과 Auth 초대는 단일 transaction이 될 수 없다. 따라서 Auth 초대는 재사용 가능한 사용자 ID를 만들고, DB materialization은 요청 ID를 고유 키로 사용하는 Saga다. materialization 뒤 complete가 실패해도 같은 요청의 Company·Office를 다시 만들지 않는다.

본사 프로비저닝 운영 RPC

  • get_my_sales_management_capabilities_v1canOperateProvisioning을 추가로 반환한다. 활성 first_party seller의 admin | sales_manager이고 전 고객 관리 권한이 있어야 참이다.
  • list_sales_provisioning_operations_v1(kind?, limit?)은 미완료 partner outbox와 미완료 customer provisioning request를 한 운영 DTO로 합친다. pending | processing | failed | stuck | exhausted 상태, 시도/한도, 다음 처리·lease, 최근 오류, 수동 재시도 감사를 반환한다.
  • retry_sales_provisioning_operation_v1(kind, queueItemId, observedAttempts, reason)은 본사 운영 capability 전용이다. 공백이 아닌 1,000자 이하 사유와 화면이 관찰한 시도 횟수를 요구한다.
  • mutation은 대상 행을 FOR UPDATE로 잠근 뒤 observedAttempts가 현재 attempts와 다르면 sales_provisioning_operation_stale, 아직 유효한 lease면 sales_provisioning_operation_busy로 거부한다.
  • customer는 failed 또는 lease가 만료된 provisioning만, partner는 error·10회 소진 또는 lease 만료 상태만 재시도할 수 있다. 완료·정상 대기·정상 처리 중 작업은 되돌리지 않는다.
  • 성공하면 자동 attempts를 0으로, 다음 처리 시각을 현재로 되돌리고 lease를 비운다. 기존 오류는 다음 claim이 시작될 때까지 남겨 운영자가 직전 원인을 볼 수 있게 한다.
  • customer는 sales.customer_provisioning_manual_retry_requested, partner는 partner.approval_bridge_manual_retry_requested append-only 이벤트에 actor, 이전 attempts와 reason을 기록한다.

두 RPC는 authenticated에만 execute를 부여하지만 security-definer 내부에서 capability를 다시 계산한다. service_role은 사용자 운영 RPC를 호출하지 않고 기존 worker RPC만 사용한다.

Realtime invalidation

public.sales_customer_eventssupabase_realtime publication에 등록한다. 이벤트 payload는 감사·관찰용이며 UI read model이 아니다. Sales Center와 Partner Center는 허용된 INSERT를 받으면 list_sales_customer_accounts_v2를 재호출한다. RLS는 private.sales_actor_can_read_customer를 그대로 사용한다.

worker 요청의 maxJobs는 파트너와 고객 작업을 합친 총 claim 예산이며 1~20으로 제한한다. 하나라도 fail transition으로 끝나면 HTTP 500과 failedJobs를 반환해 scheduler가 성공으로 오인하지 않게 한다. 인증·환경 오류는 401/503이다.

불변식

  1. first_party 판매주체에는 외부 파트너 참조를 두지 않는다.
  2. channel_partner는 전 고객 관리 권한을 가질 수 없다.
  3. 최초 판매귀속은 수정·삭제하지 않는다. 운영 담당 변경은 assignment에 남긴다.
  4. v2에서 판매 파트너는 channel_partner_assisted, 본사는 self_service | first_party_assisted | admin_import만 사용할 수 있다.
  5. active 고객계정에는 provisioned_company_id가 반드시 있어야 한다.
  6. 사업자등록번호 중복은 별도 정정·병합 절차 전에는 생성하지 않는다.
  7. 명령 재시도는 같은 요청 키와 같은 canonical payload면 저장 응답을 재생하고, payload가 다르면 충돌로 거부한다.
  8. 공개 테이블 직접 쓰기는 금지하고 security-definer RPC만 사용한다.
  9. worker RPC는 service_role에만 execute를 부여하고 함수 내부에서도 service claim을 확인한다.
  10. Company·Office materialization은 provisioning request당 한 번이며 관리자 수락 전 membership은 invited다.
  11. long-term-repair-planning은 서버 catalog 비활성·policy internal_prototype으로 유지하며 판매 프로비저닝에 포함하지 않는다.
  12. 수동 재시도는 활성 lease를 덮어쓰지 않고, 관찰 attempts가 일치할 때만 새 자동 시도 주기를 연다.
  13. 수동 재시도 사유·actor·시각은 queue 감사 필드와 append-only 이벤트에 함께 남긴다.

RLS·권한

  • 모든 public.sales_* 테이블은 RLS가 활성화되어 있다.
  • authenticated에는 필요한 조회 권한만 명시적으로 부여한다.
  • insert/update/delete는 revoke하고 RPC execute만 부여한다.
  • security-definer helper와 private command table은 PUBLIC, anon, authenticated 직접 실행·접근을 금지한다.
  • 파트너 신청 데이터 RLS는 applying | active membership만 허용하며 invited | suspended 사용자는 읽을 수 없다. 판매주체 스코프도 JWT 주장만 믿지 않고 활성 membership과 판매주체를 함께 확인한다.

배포·운영 체크리스트

  1. migration 적용 뒤 SQL smoke와 DB advisor를 실행한다.
  2. sales-provisioning-worker Edge Function에 worker secret과 허용된 초대 redirect를 설정한다.
  3. 운영 스케줄러는 x-worker-secret으로 짧은 간격 POST를 수행한다. 브라우저에서 호출하지 않는다.
  4. Auth 초대 메일 템플릿과 redirect allow-list를 검증한다.
  5. Sales Center 프로비저닝 운영 카드에서 pending/failed, lease 만료, attempts 10 도달을 확인하고 외부 알림 채널은 후속 연결한다.
  6. 수동 재시도는 오류 원인 조치와 사유 기록 뒤 실행하며, 반복 재시도 횟수도 운영 지표로 감시한다.
  7. CRM deal.won outbox, 직접가입 webhook, 고객 병합·귀속 보정 워크플로는 후속 범위다.