결제수단을 다 더하면 순매출이 됩니다 — 하나만 빼고 전 매장이 원 단위로 맞았습니다
고객이 손으로 관리하던 손익 시트에는 매출 아래에 '결제'라는 블록이 있었습니다. 현금, 신용카드, 전자화폐 세 줄입니다. 프랜차이즈 ERP의 손익계산서에 이 블록을 옮겨 넣는 일을 맡았습니다.
시트를 그대로 옮기면 끝날 줄 알았습니다. 열어보니 정해진 것이 하나도 없었습니다. 합계가 무엇과 맞아야 하는지, 분류를 누가 소유하는지, 사람이 고친 값을 어떻게 저장하는지, 안 맞는 매장을 어떻게 다루는지가 전부 미정이었습니다. 넷 다 운영 데이터로 정했습니다.
처음 본 매장에서는 결제수단 합계가 총매출과 정확히 같았습니다. 그래서 '합 = 총매출'로 잡을 뻔했습니다. 그 매장은 그 달 할인이 0원이었습니다. 할인이 없으면 총매출과 순매출이 같은 값이라, 표본 하나로는 두 가설을 구분할 수 없었습니다.
차액이 서비스 할인과 원 단위로 같았습니다
그래서 전사 한 달치를 통째로 집계했습니다. 먼저 결제수단 분포부터 봤습니다. 신용카드 84.5%, 전자화폐 12.2%, 현금 4.0%였고 상품권과 포인트가 소수로 따라왔습니다. 선불카드, 외상, 임직원, 캐시백, 교환권은 전부 0이었습니다.
이 분포가 첫 번째 결정을 바꿨습니다. 결제수단은 POS 영수증만으로 100% 채워집니다. '직접입력이 기본이고 자동은 보조'가 아니라 자동이 기본이고, 직접입력은 상위 항목 안에서 쪼개거나 보정하는 용도입니다. 자동값이 이미 순매출을 다 채우고 있으니 사람이 무엇을 더 얹으면 무조건 순매출을 넘습니다.
다음은 합계 기준입니다. 전사 매출에서 결제수단 합을 빼니 얼마가 남았습니다. 같은 달 서비스 할인 합계를 옆에 놓으니 원 단위까지 같은 숫자였습니다. 할인은 받지 않은 돈이라 결제에 잡히지 않습니다. 결제수단의 합은 총매출이 아니라 순매출입니다. 블록의 위치도 이 순간 순매출 바로 아래로 확정됐습니다.

분류는 POS 것을 그대로 흘렸습니다
다음 결정은 분류를 누가 소유하느냐였습니다. 우리가 카드·현금·기타 세 가지로 묶는 안과 POS의 결제 유형을 그대로 노출하는 안이 있었습니다. 후자를 골랐습니다. POS 영수증의 결제 컬럼 10개를 취소분 차감으로 그대로 더하고, 0원인 유형만 화면에서 숨깁니다.
이렇게 하면 POS가 새 결제수단을 쓰기 시작해도 코드 수정 없이 화면에 한 줄이 저절로 늡니다. 우리 세 분류로 묶는 순간부터는 새 유형이 생길 때마다 '어디에 넣지'라는 결정이 우리에게 밀려옵니다. 분류 체계를 소유하지 않으면 확장이 공짜입니다.
조인에서 한 가지를 조심했습니다. POS의 결제 코드는 매장 로컬 값이라 코드만으로 조인하면 다른 브랜드 매장의 영수증이 섞입니다. 코드와 브랜드의 쌍으로 걸었습니다.
수정은 델타가 아니라 그 달을 통째로 교체합니다
세 번째는 사람이 고친 값을 어떻게 저장하느냐였습니다. 요구사항에 행 삭제가 있었습니다. 변경분만 얹는 델타 방식으로는 '지웠다'와 '0원이다'를 구분할 수 없습니다. 그래서 수정행이 하나라도 있으면 그 목록이 그 달의 전부이고, 없으면 POS 집계를 씁니다. 빈 목록을 저장하면 POS 값으로 돌아갑니다. 복원 API를 따로 만들 필요가 없었습니다.
화면에서는 고친 달에도 POS 원본 값을 항상 나란히 보여줍니다. 'POS 값 불러오기'는 화면만 채우고 저장하지 않습니다. 되돌린 뒤 한두 줄만 다시 고치는 경우가 있어서입니다. 통째 교체는 동시 편집에 약해서 마지막 저장이 이깁니다. 변경 이력이 필요하면 별도 로그가 따로 필요합니다.

하나만 빼고 전 매장이 원 단위로 맞았습니다
구현을 마치고 전 매장을 대조했습니다. 하나만 빼고 전부 차이 0이었습니다. 남은 하나는 두 브랜드를 함께 운영하는 매장이었습니다. 매출은 한 브랜드 라인을 빼고 집계하는데, 결제 금액은 영수증 헤더에만 있어 라인 단위로 뺄 수가 없습니다. 차이가 그 브랜드 라인의 한 달 매출만큼이었습니다.
선택지는 셋이었습니다. 보정해서 맞춘 척하거나, 검증에서 빼고 감추거나, 붉은 경고와 차액을 그대로 드러내 사람이 맞춰 넣게 하거나. 세 번째로 갔습니다. 나머지 전부가 맞는 검증 장치에서 하나를 감추면 그 장치가 거짓말을 시작합니다. 이 예외는 코드가 아니라 데이터 구조에서 온 것이라, 없애는 게 아니라 드러내는 것까지가 시스템의 몫입니다.
차액을 알려진 값과 원 단위로 맞추는 방법은 전에도 썼습니다. 그때는 늘 1.37% 부족하던 매출 합계의 원인이 원천 API의 누락이었습니다.
화면과 서버가 다른 집합을 더하고 있었습니다
배포 전에 실제 매장 화면을 열어봤습니다. 이름 없이 금액만 넣은 줄이 하나 있으면 화면 합계는 맞아 보여 저장 버튼이 열리는데, 서버가 차액만큼 거부했습니다. 차액이 정확히 그 줄의 금액이었습니다. 화면 합계는 모든 줄을 더하고, 서버로 보내는 목록은 이름 있는 줄만 담고 있었습니다.
이런 모양의 버그는 어느 쪽 로그를 봐도 각자는 멀쩡합니다. 화면은 검증을 통과시켰고 서버는 규칙대로 거부했습니다. 합계·차액·비움 판정을 전부 '이름 있는 줄' 기준으로 통일했습니다. 이름 없는 금액 줄은 저장을 막고 이유를 말합니다. 조용히 버리면 사람이 넣은 돈이 사라집니다.

화면을 보며 나온 보완이 이것 말고도 넷 있었습니다. 금액 입력의 콤마·음수 허용, 저장을 막는 차액 안내의 줄 분리, 다이얼로그의 primary 버튼을 저장 하나로 정리, 앞서 말한 POS 원본 값 병기입니다. 사용자가 실제 매장 화면에서 저장까지 확인했습니다.
카드 수수료의 분모도 같은 실측에서 나왔습니다
이 작업 옆에 카드 수수료 자동 산출 요구사항이 있었습니다. '카드매출 × 요율'이 자연스러워 보였습니다. 한 매장의 채널별 결제수단을 분해해 보니 배달 매출 가운데 카드는 0.1% 도 안 됐습니다. 나머지는 전부 전자화폐, 즉 배달 플랫폼이 정산해 주는 결제 대금이었습니다. 홀은 반대로 카드가 대부분이었습니다.
카드매출에만 요율을 걸면 배달 수수료가 통째로 빠집니다. 요율을 걸 분모는 채널 구성을 실측한 뒤에 정하는 것이 맞습니다.
검증식은 문서가 아니라 데이터가 정합니다
넷 다 문서로 정할 수 있는 일처럼 보였습니다. 실제로는 차액이 알려진 값과 원 단위로 일치하는 순간 식이 스스로 확정됐고, 표본 하나로는 갈리지 않던 가설이 전사 집계에서 갈렸습니다.
결정 | 선택지 | 채택 | 근거 |
|---|---|---|---|
합계 기준 | 총매출 / 순매출 | 순매출 | 차액이 서비스 할인과 원 단위 일치 |
분류 소유 | 우리 3분류 / POS 그대로 | POS 그대로 | 새 유형에 코드 수정 없음 |
수정 저장 | 델타 / 그 달 통째 교체 | 통째 교체 | 「지웠다」와 「0원」을 구분 |
예외 매장 | 보정 / 감춤 / 경고+차액 | 경고+차액 | 검증 장치가 거짓말하지 않게 |
다만 '합 = 순매출'은 이 POS의 회계 규칙에서 나온 결과입니다. 할인을 결제수단의 하나로 기록하는 POS나 부가세를 따로 떼는 업종에서는 다른 식이 나옵니다. 이식되는 것은 식이 아니라, 차액이 알려진 값과 일치하는 식을 찾는 절차입니다.
합계 기준을 정하기 전에 차액이 어떤 알려진 값과 원 단위로 맞는지 전사 집계로 확인했는가
원천의 분류를 그대로 흘릴 수 있는가, 우리가 묶어서 새 유형마다 결정을 떠안을 것인가
사람 수정을 델타로 받으면 '지웠다'와 '0원'이 구분되는가
구조적으로 못 맞추는 예외를 검증에서 빼고 있지 않은가
화면이 더하는 집합과 서버가 저장하는 집합이 같은 식에서 나오는가
결제수단 블록 하나를 넣는 일이었습니다. 끝나고 보니 남은 것은 블록이 아니라, 원 단위로 맞는 값을 찾을 때까지 식을 확정하지 않는 습관과 맞는 매장들 옆에 붉게 서 있는 하나였습니다.