Wave G: 실제 매입사 경제모델 고도화 (딥리서치 갭분석 기반)

딥리서치(95 에이전트, OCC/Fed/Berlin Group 등 1차 출처, 검증된 9 finding)로
실제 카드 매입사 아키텍처를 조사(docs/GAP_ANALYSIS.md)하고, 최우선 갭 3개를
스키마+시드+런타임서비스+화면으로 메웠다.

메운 갭 (실제 매입사 핵심 메커니즘)
1. 수수료 3층 분해: 기존 flat MDR → interchange(발급사)+scheme fee(카드망)+
   markup(매입사 수익). 실측 결과 interchange=MDR의 86% (리서치 "70~90%"와 일치).
2. 클리어링 ≠ 정산 분리: 승인·정산과 별도인 프레젠트먼트(클리어링) 단계 추가.
3. 발급사↔매입사 순액정산: BIN/ICA 기준 다자 순액(한국 BC카드 컨소시엄형).
4. 펀딩 항등식: 총매출−환불−채그백−수수료−리저브=입금 (2,924건 전부 항등 성립).
5. 채그백/분쟁 라이프사이클: FIRST_CB→REPRESENT→PRE_ARB→ARBITRATION 상태기계.

스키마 (db/schema.d/70-econ.sql, 96-econ-seed.sql)
- 신규 9테이블: st_interchange_rate, st_scheme_fee, st_fee_component, cl_presentment,
  st_member_bank, st_net_settlement, st_reserve, st_merchant_funding, ac_dispute.
- 기존 정산건 소급 분해(요율표 카드종류×채널 차등) + 순액정산 배치 + 펀딩 계산.
- 시드 계산검증: 3층 항등식·펀딩 항등식 100% 성립(내가 채그백 일수중복 버그 1건 수정).

런타임 서비스 (전부 게이트웨이로 실호출 검증)
- st: ST_FEEDECOMP(ic+sf+markup=mdr), ST_NETSETTLE(BIN별 순액), ST_FUNDING(항등식)
- ac: ACQ_DISPUTE_OPEN, ACQ_DISPUTE_ADVANCE(상태기계-부적법 전이 거부),
  ACQ_PRESENT(클리어링). 각 120줄+, ECPG/XA 격리 규율 준수.

화면 (포털 AR0003 "정산 경제", #econ)
- 수수료 3층 색상바 + 펀딩 항등식 워터폴 + 발급사별 순액정산 + 분쟁 라이프사이클
  + 클리어링. 전부 실측 데이터 조회.

검증
- 통합 빌드 error 0, 도메인 재기동 5,879 서비스 AVAIL
- 6개 신규 서비스 실호출 정상(수수료 3층 84=74+6+4, 상태기계 부적법 전이 거부)
- 기존 6단 매입 체인 COMMIT OK 유지, 미결 2PC 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyeongwoo 2026-07-22 14:03:35 +09:00
parent 46ff41b633
commit 438ae8992d
20 changed files with 1500 additions and 4 deletions

View file

@ -155,6 +155,11 @@
#include "ACQ_RETURNQRY.h"
#include "ACQ_RETURNSTAT.h"
/* Wave G — 매입사 경제모델(분쟁 라이프사이클 + 클리어링) */
#include "ACQ_DISPUTE_OPEN.h"
#include "ACQ_DISPUTE_ADVANCE.h"
#include "ACQ_PRESENT.h"
/* ----- advertise table: {service name, function} (fn is extern from its file) */
static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
{"ACQUIRE", ACQUIRE}, {"ACQ_DDC", ACQ_DDC},
@ -253,6 +258,9 @@ static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
{"ACQ_EXCHANGE", ACQ_EXCHANGE},
{"ACQ_RETURNQRY", ACQ_RETURNQRY},
{"ACQ_RETURNSTAT", ACQ_RETURNSTAT},
{"ACQ_DISPUTE_OPEN", ACQ_DISPUTE_OPEN},
{"ACQ_DISPUTE_ADVANCE", ACQ_DISPUTE_ADVANCE},
{"ACQ_PRESENT", ACQ_PRESENT},
{NULL, NULL}
};

View file

@ -0,0 +1,25 @@
/*
* ACQ_DISPUTE_ADVANCE.h - ac ACQ_DISPUTE_ADVANCE (copybook / record header).
* : FIRST_CBREPRESENTPRE_ARBARBITRATIONWON/LOST,
* ACCEPTACCEPTED. (FAIL).
* Request fields: T_ID1(dispute_id), T_STR1(action), [T_STR2( stage ), T_PURCHASE_ID]
* Response fields: T_STATUS( stage status), T_ID1, T_ARG1(resolve flag), T_RC
*/
#ifndef ACQ_DISPUTE_ADVANCE_H
#define ACQ_DISPUTE_ADVANCE_H
#include "ac_svc.h"
/* 분쟁 전이 결과 (내부 판정 결과 표현용) */
typedef struct {
long dispute_id;
char from_stage[16];
char action[16];
char to_stage[16];
char to_status[16];
long resolved; /* 1 = 종결 전이(resolved_at 세팅) */
} ac_dispute_move_t;
void ACQ_DISPUTE_ADVANCE(TPSVCINFO *p);
#endif /* ACQ_DISPUTE_ADVANCE_H */

View file

@ -0,0 +1,182 @@
/*
* ACQ_DISPUTE_ADVANCE.pgc - ac 모듈 서비스 ACQ_DISPUTE_ADVANCE (one service = one file).
*
* 분쟁 라이프사이클 상태기계. 카드 분쟁은 정해진 순서로만 진행하며, 이 서비스는
* 현재 stage/status 를 읽어 요청 action 에 대한 "적법한 전이만" 허용하고 나머지는 거부한다.
*
* stage/action 전이표 (status='OPEN' 인 열린 분쟁에서만):
* FIRST_CB --REPRESENT--> REPRESENT (매입사 재청구 방어)
* REPRESENT --ESCALATE---> PRE_ARB (사전 중재 상신)
* PRE_ARB --ESCALATE---> ARBITRATION (중재 상신)
* ARBITRATION --WIN--------> status=WON (종결, resolved_at)
* ARBITRATION --LOSE-------> status=LOST (종결, resolved_at)
* <any open> --ACCEPT-----> status=ACCEPTED (책임 인수, 종결)
* 그 외 (stage,action) 조합 = 부적절한 전이 → T_RC 세팅 후 FAIL.
* 이미 WON/LOST/ACCEPTED 로 종결된 분쟁은 어떤 전이도 거부.
*
* 입출력 계약: 입력 T_ID1(dispute_id) + T_STR1(action)[+ T_STR2(현재 stage 힌트)],
* 출력 T_STATUS(새 stage 또는 종결 status).
* Runs on the caller's XA branch (no EXEC SQL CONNECT). Advertised by ac_svr.
*/
#include "ACQ_DISPUTE_ADVANCE.h"
void ACQ_DISPUTE_ADVANCE(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
const char *SVC = "ACQ_DISPUTE_ADVANCE";
char action[24], bizdate[16], newstage[16], newstatus[16], memo[256], logmsg[256];
long resolve = 0, valid = 0, i = 0;
EXEC SQL BEGIN DECLARE SECTION;
long h_did, h_pid, h_amt, h_logid, h_open;
char h_stage[16], h_status[16], h_merch[64], h_reason[64];
char h_new_stage[16], h_new_status[16], h_bizdate[16], h_memo[256], h_logmsg[256];
EXEC SQL END DECLARE SECTION;
/* 지역/호스트 변수 초기화 (DECLARE SECTION 밖) */
h_did = h_pid = h_amt = h_logid = h_open = 0;
action[0] = bizdate[0] = newstage[0] = newstatus[0] = memo[0] = logmsg[0] = 0;
h_stage[0] = h_status[0] = h_reason[0] = h_bizdate[0] = 0;
strcpy(h_merch, "UNKNOWN");
userlog("%s 진입: 분쟁 단계 전이 요청", SVC);
/* [1] 입력 검증부 -------------------------------------------------- */
h_did = getl(b, T_ID1);
if (h_did <= 0) {
Bchg(b, T_MSG, 0, "분쟁 ID(T_ID1) 오류", 0L); setl(b, T_RC, 7301); FAIL(b);
}
gets_(b, T_STR1, action, sizeof(action));
if (action[0] == 0) {
Bchg(b, T_MSG, 0, "action(T_STR1) 필수", 0L); setl(b, T_RC, 7302); FAIL(b);
}
for (i = 0; action[i]; i++) /* action 대문자 정규화 */
if (action[i] >= 'a' && action[i] <= 'z') action[i] -= 32;
gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
if (h_bizdate[0] == 0) {
EXEC SQL SELECT param_val INTO :h_bizdate FROM cm_param WHERE param_key = 'BIZ_DATE';
if (sqlca.sqlcode != 0) strcpy(h_bizdate, "2026-07-19");
}
/* [2] 마스터 조회부: 현재 분쟁 상태 (전이의 기준) ------------------- */
EXEC SQL SELECT purchase_id, merchant_id, amount, reason_code, stage, status
INTO :h_pid, :h_merch, :h_amt, :h_reason, :h_stage, :h_status
FROM ac_dispute WHERE dispute_id = :h_did;
if (sqlca.sqlcode < 0) {
userlog("%s: 분쟁 조회 실패 [%d] %s", SVC, sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
setl(b, T_RC, 90); FAIL(b);
}
if (sqlca.sqlcode == 100) {
/* XA 격리: 형제 브랜치가 방금 개시한 미커밋 분쟁이면 전문에 실린 현재 stage 를 쓴다.
* 전문 힌트(T_STR2)도 없으면 진짜 미존재이므로 업무검증 실패로 거부한다. */
gets_(b, T_STR2, h_stage, sizeof(h_stage));
gets_(b, T_STATUS, h_status, sizeof(h_status));
if (h_status[0] == 0) strcpy(h_status, "OPEN");
h_pid = getl(b, T_PURCHASE_ID);
h_amt = getl(b, T_AMOUNT);
if (h_stage[0] == 0) {
userlog("%s: 분쟁 미존재 did=%ld (전문 힌트 없음)", SVC, h_did);
Bchg(b, T_MSG, 0, "분쟁 미존재", 0L); setl(b, T_RC, 7303); FAIL(b);
}
userlog("%s: 분쟁 미커밋(체인) did=%ld — 전문 stage=%s 사용", SVC, h_did, h_stage);
}
userlog("%s 컨텍스트: did=%ld pid=%ld merch=%s stage=%s status=%s action=%s",
SVC, h_did, h_pid, h_merch, h_stage, h_status, action);
/* [3] 종결 상태 확인부: 이미 resolved 면 어떤 전이도 불가 ----------- */
if (strcmp(h_status, "WON") == 0 || strcmp(h_status, "LOST") == 0 ||
strcmp(h_status, "ACCEPTED") == 0) {
snprintf(logmsg, sizeof(logmsg), "이미 종결된 분쟁 status=%s 전이 거부", h_status);
userlog("%s %s did=%ld", SVC, logmsg, h_did);
Bchg(b, T_MSG, 0, logmsg, 0L);
Bchg(b, T_STATUS, 0, h_status, 0L);
setl(b, T_RC, 7310); FAIL(b);
}
/* [4] 상태기계 전이 판정부 ----------------------------------------- */
strcpy(newstage, h_stage); strcpy(newstatus, h_status);
resolve = 0; valid = 0;
if (strcmp(action, "ACCEPT") == 0) {
/* 어느 열린 단계에서든 가맹점/매입사가 책임을 인수 → ACCEPTED(종결) */
strcpy(newstatus, "ACCEPTED"); resolve = 1; valid = 1;
} else if (strcmp(h_stage, "FIRST_CB") == 0 && strcmp(action, "REPRESENT") == 0) {
strcpy(newstage, "REPRESENT"); valid = 1;
} else if (strcmp(h_stage, "REPRESENT") == 0 && strcmp(action, "ESCALATE") == 0) {
strcpy(newstage, "PRE_ARB"); valid = 1;
} else if (strcmp(h_stage, "PRE_ARB") == 0 && strcmp(action, "ESCALATE") == 0) {
strcpy(newstage, "ARBITRATION"); valid = 1;
} else if (strcmp(h_stage, "ARBITRATION") == 0 &&
(strcmp(action, "WIN") == 0 || strcmp(action, "WON") == 0)) {
strcpy(newstatus, "WON"); resolve = 1; valid = 1;
} else if (strcmp(h_stage, "ARBITRATION") == 0 &&
(strcmp(action, "LOSE") == 0 || strcmp(action, "LOSS") == 0 ||
strcmp(action, "LOST") == 0)) {
strcpy(newstatus, "LOST"); resolve = 1; valid = 1;
}
if (!valid) {
snprintf(logmsg, sizeof(logmsg), "부적절한 전이 거부: stage=%s action=%s", h_stage, action);
userlog("%s %s did=%ld", SVC, logmsg, h_did);
Bchg(b, T_MSG, 0, logmsg, 0L);
Bchg(b, T_STATUS, 0, h_stage, 0L); /* 현재 stage 유지 통지 */
setl(b, T_RC, 7320); FAIL(b);
}
userlog("%s 전이확정: %s/%s --%s--> stage=%s status=%s resolve=%ld",
SVC, h_stage, h_status, action, newstage, newstatus, resolve);
/* [5] 본처리부: ac_dispute UPDATE (종결 전이면 resolved_at 세팅) ----- */
strncpy(h_new_stage, newstage, sizeof(h_new_stage)-1); h_new_stage[sizeof(h_new_stage)-1] = 0;
strncpy(h_new_status, newstatus, sizeof(h_new_status)-1); h_new_status[sizeof(h_new_status)-1] = 0;
snprintf(memo, sizeof(memo), "전이 %s/%s -%s-> %s/%s @%s",
h_stage, h_status, action, h_new_stage, h_new_status, h_bizdate);
strncpy(h_memo, memo, sizeof(h_memo)-1); h_memo[sizeof(h_memo)-1] = 0;
if (resolve) {
EXEC SQL UPDATE ac_dispute
SET stage = :h_new_stage, status = :h_new_status, resolved_at = now(), memo = :h_memo
WHERE dispute_id = :h_did;
} else {
EXEC SQL UPDATE ac_dispute
SET stage = :h_new_stage, status = :h_new_status, memo = :h_memo
WHERE dispute_id = :h_did;
}
if (sqlca.sqlcode < 0) {
userlog("%s: 분쟁 갱신 실패 [%d] %s", SVC, sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
setl(b, T_RC, 90); FAIL(b);
}
if (sqlca.sqlcode == 100) {
/* XA 격리: 대상 행이 이 브랜치에 안 보임(체인 미커밋). 전문 기반으로 진행하고 FAIL 안 함 */
userlog("%s: UPDATE 대상 미가시(체인 미커밋) did=%ld — 전문값 응답", SVC, h_did);
}
/* [6] 이력/감사 적재부 (cm_log) ------------------------------------- */
snprintf(logmsg, sizeof(logmsg), "분쟁전이 did=%ld %s->%s/%s action=%s resolve=%ld",
h_did, h_stage, h_new_stage, h_new_status, action, resolve);
strncpy(h_logmsg, logmsg, sizeof(h_logmsg)-1); h_logmsg[sizeof(h_logmsg)-1] = 0;
EXEC SQL SELECT nextval('cm_log_seq') INTO :h_logid;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
EXEC SQL INSERT INTO cm_log (log_id, svc_name, log_level, msg, biz_date)
VALUES (:h_logid, 'ACQ_DISPUTE_ADVANCE', 'I', :h_logmsg, :h_bizdate);
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
/* [7] 집계/요약부: 가맹점 진행중 분쟁 수 재집계 --------------------- */
EXEC SQL SELECT count(*) INTO :h_open FROM ac_dispute
WHERE merchant_id = :h_merch AND status = 'OPEN';
if (sqlca.sqlcode < 0) { h_open = 0; } /* 집계 실패는 응답을 막지 않음 */
/* [8] 응답부 (계약 보존: T_STATUS = 새 stage 또는 종결 status) ------- */
if (resolve) Bchg(b, T_STATUS, 0, h_new_status, 0L);
else Bchg(b, T_STATUS, 0, h_new_stage, 0L);
setl(b, T_ID1, h_did);
setl(b, T_PURCHASE_ID, h_pid);
setl(b, T_AMOUNT, h_amt);
Bchg(b, T_MERCHANT, 0, h_merch, 0L);
Bchg(b, T_STR2, 0, h_new_stage, 0L); /* 새 stage 힌트(체인 전달용) */
Bchg(b, T_STR1, 0, action, 0L);
setl(b, T_ARG1, resolve);
setl(b, T_COUNT, h_open);
Bchg(b, T_MSG, 0, h_memo, 0L);
setl(b, T_RC, 0);
userlog("%s 완료: did=%ld -> stage=%s status=%s (resolve=%ld)",
SVC, h_did, h_new_stage, h_new_status, resolve);
OK(b);
}
/* vim: set ts=4 sw=4 et smartindent: */

View file

@ -0,0 +1,24 @@
/*
* ACQ_DISPUTE_OPEN.h - ac ACQ_DISPUTE_OPEN (copybook / record header).
* / : 1 ac_dispute FIRST_CB/OPEN .
* Request fields: T_PURCHASE_ID, T_STR1(reason_code), [T_AMOUNT, T_MERCHANT, T_BIZDATE]
* Response fields: T_ID1(dispute_id), T_STATUS='FIRST_CB', T_AMOUNT, T_COUNT, T_ARG1(tier)
*/
#ifndef ACQ_DISPUTE_OPEN_H
#define ACQ_DISPUTE_OPEN_H
#include "ac_svc.h"
typedef struct {
long dispute_id;
long purchase_id;
char merchant_id[64];
char reason_code[64];
long amount;
char stage[16]; /* FIRST_CB */
char status[16]; /* OPEN */
} ac_dispute_rec_t;
void ACQ_DISPUTE_OPEN(TPSVCINFO *p);
#endif /* ACQ_DISPUTE_OPEN_H */

View file

@ -0,0 +1,136 @@
/*
* ACQ_DISPUTE_OPEN.pgc - ac 모듈 서비스 ACQ_DISPUTE_OPEN (one service = one file).
* 채그백/분쟁 개시: 매입 원장을 확인하고 ac_dispute 에 FIRST_CB/OPEN 분쟁행을 만든다.
* 분쟁금액은 매입금액, reason_code 는 카드망 분쟁코드(10.4 사기 / 13.1 상품미수취 /
* 12.6 중복 ...)를 그대로 적재하고 사기·상품·중복 등급(tier)으로 분류한다.
* 입출력 계약: 입력 T_PURCHASE_ID + T_STR1(reason_code)[+ T_AMOUNT/T_MERCHANT/T_BIZDATE],
* 출력 T_ID1(dispute_id) / T_STATUS='FIRST_CB'.
* Runs on the caller's XA branch (no EXEC SQL CONNECT). Advertised by ac_svr.
*/
#include "ACQ_DISPUTE_OPEN.h"
void ACQ_DISPUTE_OPEN(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
const char *SVC = "ACQ_DISPUTE_OPEN";
char bizdate[16], reason[64], memo[256], logmsg[256];
long tier = 0;
EXEC SQL BEGIN DECLARE SECTION;
long h_pid, h_amt, h_did, h_logid, h_dup, h_cnt, h_merchcb;
char h_bizdate[16], h_reason[64], h_merch[64], h_pstat[16];
char h_memo[256], h_logmsg[256];
EXEC SQL END DECLARE SECTION;
/* 지역/호스트 변수 초기화 (DECLARE SECTION 밖) */
h_pid = h_amt = h_did = h_logid = h_dup = h_cnt = h_merchcb = 0;
bizdate[0] = reason[0] = memo[0] = logmsg[0] = 0;
h_bizdate[0] = h_reason[0] = 0;
strcpy(h_merch, "UNKNOWN"); strcpy(h_pstat, "S");
userlog("%s 진입: 분쟁/채그백 개시 요청", SVC);
/* [1] 입력 검증부 -------------------------------------------------- */
h_pid = getl(b, T_PURCHASE_ID);
if (h_pid <= 0) {
Bchg(b, T_MSG, 0, "매입 ID 오류", 0L); setl(b, T_RC, 7201); FAIL(b);
}
gets_(b, T_STR1, reason, sizeof(reason));
if (reason[0] == 0) strcpy(reason, "13.1"); /* 기본: 13.1 상품미수취 */
strncpy(h_reason, reason, sizeof(h_reason)-1); h_reason[sizeof(h_reason)-1] = 0;
gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
if (h_bizdate[0] == 0) {
EXEC SQL SELECT param_val INTO :h_bizdate FROM cm_param WHERE param_key = 'BIZ_DATE';
if (sqlca.sqlcode != 0) strcpy(h_bizdate, "2026-07-19");
}
/* [2] 마스터/상태 조회부 (XA 격리: 미커밋이면 전문값 사용, 100 을 FAIL 로 다루지 않음) */
EXEC SQL SELECT amount, merchant_id, status INTO :h_amt, :h_merch, :h_pstat
FROM purchase WHERE purchase_id = :h_pid;
if (sqlca.sqlcode < 0) {
userlog("%s: 매입원장 조회 실패 [%d] %s", SVC, sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
setl(b, T_RC, 90); FAIL(b);
}
if (sqlca.sqlcode == 100) {
/* 체인 내 신규 매입(미커밋): 요청 전문에 실린 값으로 진행 */
h_amt = getl(b, T_AMOUNT);
gets_(b, T_MERCHANT, h_merch, sizeof(h_merch));
if (h_merch[0] == 0) strcpy(h_merch, "UNKNOWN");
strcpy(h_pstat, "S");
userlog("%s: 매입 미커밋(체인) pid=%ld — 전문값 사용", SVC, h_pid);
}
if (h_amt <= 0) h_amt = getl(b, T_AMOUNT);
if (h_amt <= 0) {
Bchg(b, T_MSG, 0, "분쟁 금액 산정 불가", 0L); setl(b, T_RC, 7202); FAIL(b);
}
userlog("%s 컨텍스트: pid=%ld merch=%s 상태=%s 금액=%ld reason=%s",
SVC, h_pid, h_merch, h_pstat, h_amt, h_reason);
/* [3] 업무규칙 분기부: 중복 분쟁 방지 + reason 등급 분류 -------------- */
EXEC SQL SELECT count(*) INTO :h_dup FROM ac_dispute
WHERE purchase_id = :h_pid AND status = 'OPEN';
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
if (h_dup > 0) {
userlog("%s 거절: 이미 진행중인 분쟁 존재 pid=%ld", SVC, h_pid);
Bchg(b, T_MSG, 0, "이미 진행중인 분쟁 존재", 0L); setl(b, T_RC, 7203); FAIL(b);
}
/* 무효/취소된 매입은 분쟁 개시 자체가 성립하지 않는다 */
if (strcmp(h_pstat, "V") == 0) {
Bchg(b, T_MSG, 0, "무효 매입은 분쟁 불가", 0L); setl(b, T_RC, 7204); FAIL(b);
}
/* reason 등급: 사기(10.x)=3 최우선, 상품(13.x)=2, 중복(12.x)=1, 그 외=0 */
if (strncmp(h_reason, "10.", 3) == 0) tier = 3;
else if (strncmp(h_reason, "13.", 3) == 0) tier = 2;
else if (strncmp(h_reason, "12.", 3) == 0) tier = 1;
else tier = 0;
/* [4] 본처리부: dispute INSERT (FIRST_CB / OPEN) --------------------- */
EXEC SQL SELECT nextval('ac_dispute_seq') INTO :h_did;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
snprintf(memo, sizeof(memo), "채그백 개시 pid=%ld reason=%s tier=%ld bizdate=%s",
h_pid, h_reason, tier, h_bizdate);
strncpy(h_memo, memo, sizeof(h_memo)-1); h_memo[sizeof(h_memo)-1] = 0;
EXEC SQL INSERT INTO ac_dispute
(dispute_id, purchase_id, merchant_id, reason_code, amount, stage, status, memo)
VALUES (:h_did, :h_pid, :h_merch, :h_reason, :h_amt, 'FIRST_CB', 'OPEN', :h_memo);
if (sqlca.sqlcode < 0) {
userlog("%s: 분쟁행 적재 실패 [%d] %s", SVC, sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
setl(b, T_RC, 90); FAIL(b);
}
/* [5] 이력/감사 적재부 (cm_log) ------------------------------------- */
snprintf(logmsg, sizeof(logmsg), "분쟁개시 did=%ld pid=%ld reason=%s amt=%ld tier=%ld",
h_did, h_pid, h_reason, h_amt, tier);
strncpy(h_logmsg, logmsg, sizeof(h_logmsg)-1); h_logmsg[sizeof(h_logmsg)-1] = 0;
EXEC SQL SELECT nextval('cm_log_seq') INTO :h_logid;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
EXEC SQL INSERT INTO cm_log (log_id, svc_name, log_level, msg, biz_date)
VALUES (:h_logid, 'ACQ_DISPUTE_OPEN', 'I', :h_logmsg, :h_bizdate);
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
/* [6] 집계/요약 갱신부: 가맹점 진행중 분쟁 수 + LOST 확정 채그백 누계 ---- */
EXEC SQL SELECT count(*) INTO :h_cnt FROM ac_dispute
WHERE merchant_id = :h_merch AND status = 'OPEN';
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
EXEC SQL SELECT coalesce(sum(amount),0) INTO :h_merchcb FROM ac_dispute
WHERE merchant_id = :h_merch AND status = 'LOST';
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
userlog("%s 집계: merch=%s 진행중=%ld LOST누계=%ld", SVC, h_merch, h_cnt, h_merchcb);
/* [7] 응답부 (계약 보존: T_ID1=dispute_id / T_STATUS='FIRST_CB') ------ */
setl(b, T_ID1, h_did);
Bchg(b, T_STATUS, 0, "FIRST_CB", 0L);
setl(b, T_PURCHASE_ID, h_pid);
setl(b, T_AMOUNT, h_amt);
Bchg(b, T_MERCHANT, 0, h_merch, 0L);
Bchg(b, T_STR1, 0, h_reason, 0L);
Bchg(b, T_BIZDATE, 0, h_bizdate, 0L);
setl(b, T_ARG1, tier);
setl(b, T_COUNT, h_cnt);
setl(b, T_GROSS, h_merchcb);
Bchg(b, T_MSG, 0, "분쟁 개시(FIRST_CB/OPEN)", 0L);
setl(b, T_RC, 0);
userlog("%s 완료: did=%ld pid=%ld amt=%ld tier=%ld", SVC, h_did, h_pid, h_amt, tier);
OK(b);
}
/* vim: set ts=4 sw=4 et smartindent: */

View file

@ -0,0 +1,26 @@
/*
* ACQ_PRESENT.h - ac ACQ_PRESENT (copybook / record header).
* () : · .
* clearing_amt = amount interchange ( ).
* Request fields: T_PURCHASE_ID, [T_STR1(scheme), T_AMOUNT, T_AMT1(interchange), T_BIZDATE]
* Response fields: T_ID1(presentment_id), T_AMT1(clearing_amt), T_STATUS='CLEARED', T_FEE(ic)
*/
#ifndef ACQ_PRESENT_H
#define ACQ_PRESENT_H
#include "ac_svc.h"
typedef struct {
long presentment_id;
long purchase_id;
char scheme[16];
char msg_class[24]; /* FIRST_PRESENT / REPRESENT */
long clearing_amt;
char present_date[16];
char cycle[12]; /* T+1 */
char status[16]; /* CLEARED */
} cl_presentment_rec_t;
void ACQ_PRESENT(TPSVCINFO *p);
#endif /* ACQ_PRESENT_H */

View file

@ -0,0 +1,140 @@
/*
* ACQ_PRESENT.pgc - ac 모듈 서비스 ACQ_PRESENT (one service = one file).
* 프레젠트먼트(클리어링) 생성. 승인·정산과 분리된 데이터 교환 단계로, 발급사↔매입사
* 채무를 확정한다. 수수료 3층 분해(st_fee_component)의 interchange 는 발급사 귀속분이므로
* 클리어링 대상 금액은 clearing_amt = amount interchange 이다.
* 같은 매입에 FIRST_PRESENT 가 이미 있으면 msg_class 를 REPRESENT 로 승격한다.
* 입출력 계약: 입력 T_PURCHASE_ID [+ T_STR1(scheme)/T_AMOUNT/T_AMT1(interchange)/T_BIZDATE],
* 출력 T_ID1(presentment_id) / T_AMT1(clearing_amt) / T_STATUS='CLEARED'.
* Runs on the caller's XA branch (no EXEC SQL CONNECT). Advertised by ac_svr.
*/
#include "ACQ_PRESENT.h"
void ACQ_PRESENT(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
const char *SVC = "ACQ_PRESENT";
char bizdate[16], scheme[16], msgclass[24], cycle[12], logmsg[256];
long clearing = 0, has_fc = 0;
EXEC SQL BEGIN DECLARE SECTION;
long h_pid, h_amt, h_ic, h_clr, h_prid, h_logid, h_cnt, h_prev;
char h_bizdate[16], h_scheme[16], h_msgclass[24], h_cycle[12], h_merch[64], h_logmsg[256];
EXEC SQL END DECLARE SECTION;
/* 지역/호스트 변수 초기화 (DECLARE SECTION 밖) */
h_pid = h_amt = h_ic = h_clr = h_prid = h_logid = h_cnt = h_prev = 0;
bizdate[0] = logmsg[0] = 0;
strcpy(scheme, "BC"); strcpy(cycle, "T+1"); strcpy(msgclass, "FIRST_PRESENT");
h_bizdate[0] = 0; strcpy(h_scheme, "BC"); strcpy(h_merch, "UNKNOWN");
userlog("%s 진입: 프레젠트먼트(클리어링) 생성 요청", SVC);
/* [1] 입력 검증부 -------------------------------------------------- */
h_pid = getl(b, T_PURCHASE_ID);
if (h_pid <= 0) {
Bchg(b, T_MSG, 0, "매입 ID 오류", 0L); setl(b, T_RC, 7401); FAIL(b);
}
gets_(b, T_STR1, scheme, sizeof(scheme));
if (scheme[0] == 0) strcpy(scheme, "BC");
gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
/* [2] 수수료 분해 조회부 (XA 격리: 없으면 전문 amount/interchange, 100 은 정상흐름) */
EXEC SQL SELECT amount, interchange, scheme INTO :h_amt, :h_ic, :h_scheme
FROM st_fee_component WHERE purchase_id = :h_pid;
if (sqlca.sqlcode < 0) {
userlog("%s: 수수료 분해 조회 실패 [%d] %s", SVC, sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
setl(b, T_RC, 90); FAIL(b);
}
if (sqlca.sqlcode == 100) {
/* 분해 미산출/미커밋: 전문값으로 클리어링. interchange 미제공이면 0 */
has_fc = 0;
h_amt = getl(b, T_AMOUNT);
h_ic = getl(b, T_AMT1);
strncpy(h_scheme, scheme, sizeof(h_scheme)-1); h_scheme[sizeof(h_scheme)-1] = 0;
userlog("%s: st_fee_component 미존재 pid=%ld — 전문 amount=%ld ic=%ld 사용",
SVC, h_pid, h_amt, h_ic);
} else {
has_fc = 1;
}
if (h_amt <= 0) h_amt = getl(b, T_AMOUNT);
if (h_amt <= 0) {
Bchg(b, T_MSG, 0, "클리어링 금액 산정 불가", 0L); setl(b, T_RC, 7402); FAIL(b);
}
if (h_ic < 0) h_ic = 0;
if (h_ic > h_amt) h_ic = h_amt; /* interchange 는 매출을 넘지 못함 */
if (h_scheme[0] == 0) strcpy(h_scheme, "BC");
/* 매입 원장에서 영업일/가맹점 보강 (미커밋이면 전문/파라미터 폴백) */
EXEC SQL SELECT merchant_id, biz_date INTO :h_merch, :h_bizdate
FROM purchase WHERE purchase_id = :h_pid;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
if (sqlca.sqlcode == 100) {
gets_(b, T_MERCHANT, h_merch, sizeof(h_merch));
if (h_merch[0] == 0) strcpy(h_merch, "UNKNOWN");
userlog("%s: 매입 미커밋(체인) pid=%ld — 전문값 사용", SVC, h_pid);
}
if (h_bizdate[0] == 0) {
EXEC SQL SELECT param_val INTO :h_bizdate FROM cm_param WHERE param_key = 'BIZ_DATE';
if (sqlca.sqlcode != 0) strcpy(h_bizdate, "2026-07-19");
}
userlog("%s 컨텍스트: pid=%ld merch=%s amt=%ld ic=%ld scheme=%s fc=%ld",
SVC, h_pid, h_merch, h_amt, h_ic, h_scheme, has_fc);
/* [3] 업무규칙 분기부: 기존 프레젠트먼트 존재 여부로 msg_class 결정 ---- */
EXEC SQL SELECT count(*) INTO :h_prev FROM cl_presentment
WHERE purchase_id = :h_pid AND msg_class = 'FIRST_PRESENT';
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
if (h_prev > 0) strcpy(msgclass, "REPRESENT"); /* 1차가 있으면 재프레젠트 */
else strcpy(msgclass, "FIRST_PRESENT");
strncpy(h_msgclass, msgclass, sizeof(h_msgclass)-1); h_msgclass[sizeof(h_msgclass)-1] = 0;
strncpy(h_cycle, cycle, sizeof(h_cycle)-1); h_cycle[sizeof(h_cycle)-1] = 0;
/* 클리어링 금액 = 매출 발급사 귀속 interchange */
clearing = h_amt - h_ic;
if (clearing < 0) clearing = 0;
h_clr = clearing;
/* [4] 본처리부: cl_presentment INSERT (CLEARED) --------------------- */
EXEC SQL SELECT nextval('cl_present_seq') INTO :h_prid;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
EXEC SQL INSERT INTO cl_presentment
(presentment_id, purchase_id, scheme, msg_class, clearing_amt, present_date, cycle, status)
VALUES (:h_prid, :h_pid, :h_scheme, :h_msgclass, :h_clr, :h_bizdate, :h_cycle, 'CLEARED');
if (sqlca.sqlcode < 0) {
userlog("%s: 프레젠트먼트 적재 실패 [%d] %s", SVC, sqlca.sqlcode, sqlca.sqlerrm.sqlerrmc);
setl(b, T_RC, 90); FAIL(b);
}
/* [5] 이력/감사 적재부 (cm_log) ------------------------------------- */
snprintf(logmsg, sizeof(logmsg), "프레젠트먼트 prid=%ld pid=%ld class=%s clr=%ld ic=%ld",
h_prid, h_pid, h_msgclass, clearing, h_ic);
strncpy(h_logmsg, logmsg, sizeof(h_logmsg)-1); h_logmsg[sizeof(h_logmsg)-1] = 0;
EXEC SQL SELECT nextval('cm_log_seq') INTO :h_logid;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
EXEC SQL INSERT INTO cm_log (log_id, svc_name, log_level, msg, biz_date)
VALUES (:h_logid, 'ACQ_PRESENT', 'I', :h_logmsg, :h_bizdate);
if (sqlca.sqlcode < 0) { setl(b, T_RC, 90); FAIL(b); }
/* [6] 집계/요약부: 해당 매입 프레젠트먼트 총 건수 ------------------- */
EXEC SQL SELECT count(*) INTO :h_cnt FROM cl_presentment WHERE purchase_id = :h_pid;
if (sqlca.sqlcode < 0) { h_cnt = 0; }
/* [7] 응답부 (계약 보존: T_ID1=presentment_id / T_AMT1=clearing_amt) - */
setl(b, T_ID1, h_prid);
setl(b, T_AMT1, clearing);
setl(b, T_PURCHASE_ID, h_pid);
setl(b, T_AMOUNT, h_amt);
setl(b, T_FEE, h_ic);
Bchg(b, T_STATUS, 0, "CLEARED", 0L);
Bchg(b, T_STR1, 0, h_scheme, 0L);
Bchg(b, T_STR2, 0, h_msgclass, 0L);
Bchg(b, T_BIZDATE, 0, h_bizdate, 0L);
Bchg(b, T_MERCHANT, 0, h_merch, 0L);
setl(b, T_COUNT, h_cnt);
Bchg(b, T_MSG, 0, "프레젠트먼트 생성(CLEARED)", 0L);
setl(b, T_RC, 0);
userlog("%s 완료: prid=%ld pid=%ld clr=%ld class=%s", SVC, h_prid, h_pid, clearing, h_msgclass);
OK(b);
}
/* vim: set ts=4 sw=4 et smartindent: */

View file

@ -151,6 +151,10 @@
#include "ST_RELAYSPLIT.h"
#include "ST_GRADESTAT.h"
#include "ST_MONTHSTAT.h"
/* Wave G — 매입사 경제모델(수수료 3층 분해/순액정산/펀딩 항등식) */
#include "ST_FEEDECOMP.h"
#include "ST_NETSETTLE.h"
#include "ST_FUNDING.h"
/* ----- advertise table: {service name, function} ---------------------- */
static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
@ -221,6 +225,8 @@ static struct { const char *name; void (*fn)(TPSVCINFO *); } SVCS[] = {
{"ST_DISPUTERSV", ST_DISPUTERSV}, {"ST_APPEALSTAT", ST_APPEALSTAT},
{"ST_VANDETAIL", ST_VANDETAIL}, {"ST_RELAYSPLIT", ST_RELAYSPLIT},
{"ST_GRADESTAT", ST_GRADESTAT}, {"ST_MONTHSTAT", ST_MONTHSTAT},
{"ST_FEEDECOMP", ST_FEEDECOMP}, {"ST_NETSETTLE", ST_NETSETTLE},
{"ST_FUNDING", ST_FUNDING},
{NULL, NULL}
};

View file

@ -0,0 +1,26 @@
/*
* ST_FEEDECOMP.h - st ST_FEEDECOMP (copybook / record header).
* 3 : MDR = interchange() + scheme_fee() + markup().
* st_interchange_rate / st_scheme_fee st_fee_component UPSERT.
*/
#ifndef ST_FEEDECOMP_H
#define ST_FEEDECOMP_H
#include "st_svc.h"
typedef struct {
long purchase_id;
long amount;
long interchange; /* 발급사 귀속 */
long scheme_fee; /* 카드망 귀속 */
long markup; /* 매입사 수익 */
long mdr_total; /* = interchange + scheme_fee + markup */
char card_type[16]; /* CREDIT/CHECK/PREPAID */
char scheme[16]; /* BC/VISA/MASTER/LOCAL */
char channel[16];
char biz_date[16];
} st_st_feedecomp_rec_t;
void ST_FEEDECOMP(TPSVCINFO *p);
#endif /* ST_FEEDECOMP_H */

View file

@ -0,0 +1,156 @@
/*
* ST_FEEDECOMP.pgc - st (정산/수수료) 모듈 서비스 ST_FEEDECOMP (one service = one file).
* 수수료 3층 분해(심화판): MDR = interchange(발급사) + scheme_fee(카드망) + markup(매입사).
* 처리단계: 입력검증 -> 매입/카드종류 결정 -> interchange/scheme 요율조회 -> 업무규칙(3층 산출)
* -> st_fee_component UPSERT -> 감사이력 -> 재검증 -> 응답 -> 로깅.
* 호출자 XA 브랜치에서 수행(EXEC SQL CONNECT 없음). st_svr 가 tpsvrinit 에서 광고.
* XA 격리: 체인 신규매입(미커밋)은 이 브랜치에서 안 보이므로 sqlcode=100 을 정상흐름으로 보고
* 요청 전문(UBF)값을 사용한다(owner-writes 규율, RECONCILE.pgc 참고). 100 을 FAIL 로 처리하지 않는다.
*/
#include "ST_FEEDECOMP.h"
/* ST_FEEDECOMP - 수수료 3층 분해 (interchange + scheme_fee + markup). */
void ST_FEEDECOMP(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
/* ---- 지역 스크래치 (DECLARE SECTION 밖에서 초기화) ---- */
char channel[16], issuer[40], ctype[16], scheme[16], msgbuf[200];
long amount, feein, persist;
EXEC SQL BEGIN DECLARE SECTION;
long h_pid, h_amount, h_fee, h_icbps, h_icfix, h_sfbps, h_sffix;
long h_ic, h_sf, h_markup, h_mdr, h_floor, h_cnt;
char h_ctype[16], h_scheme[16], h_channel[16], h_issuer[40];
char h_merch[64], h_status[16], h_bizdate[16], h_svc[24], h_msg[200];
EXEC SQL END DECLARE SECTION;
strcpy(h_svc, "ST_FEEDECOMP");
h_amount = h_fee = h_ic = h_sf = h_markup = h_mdr = h_cnt = 0;
h_icbps = 0; h_icfix = 0; h_sfbps = 0; h_sffix = 0; h_floor = 0;
strcpy(h_channel, "POS"); strcpy(h_issuer, "UNKNOWN"); strcpy(h_merch, "-");
strcpy(h_status, "S"); strcpy(h_bizdate, "2026-07-20");
persist = 0;
/* ===== 1) 입력 검증부 ===== */
h_pid = getl(b, T_PURCHASE_ID);
amount = getl(b, T_AMOUNT);
feein = getl(b, T_FEE);
gets_(b, T_CHANNEL, channel, sizeof(channel));
gets_(b, T_ISSUER, issuer, sizeof(issuer));
gets_(b, T_STR1, ctype, sizeof(ctype)); /* 선택: 명시 카드종류 */
gets_(b, T_STR2, scheme, sizeof(scheme)); /* 선택: 명시 스킴 */
if (channel[0] == 0) strcpy(channel, "POS");
if (issuer[0] == 0) strcpy(issuer, "UNKNOWN");
userlog("[정산심화] 수수료 3층 분해 진입 pid=%ld amt=%ld chan=%s issuer=%s", h_pid, amount, channel, issuer);
if (h_pid <= 0 && amount <= 0) {
setl(b, T_RC, 8101); Bchg(b, T_MSG, 0, "매입 ID 또는 (금액+채널) 필수", 0L);
userlog("[ST_FEEDECOMP] 입력 부족 pid=%ld amt=%ld", h_pid, amount); FAIL(b);
}
if (amount < 0) { setl(b, T_RC, 8102); Bchg(b, T_MSG, 0, "매입 금액 음수 불가", 0L); FAIL(b); }
/* ===== 2) 매입 조회부(미커밋이면 전문값) + 카드종류/스킴 결정 ===== */
if (h_pid > 0) {
EXEC SQL SELECT amount, fee, channel, issuer, merchant_id, status, biz_date
INTO :h_amount, :h_fee, :h_channel, :h_issuer, :h_merch, :h_status, :h_bizdate
FROM purchase WHERE purchase_id = :h_pid;
if (sqlca.sqlcode == 100) {
h_amount = amount; h_fee = feein;
strncpy(h_channel, channel, sizeof(h_channel)-1); h_channel[sizeof(h_channel)-1] = 0;
strncpy(h_issuer, issuer, sizeof(h_issuer)-1); h_issuer[sizeof(h_issuer)-1] = 0;
userlog("[ST_FEEDECOMP] 체인 신규매입(미커밋) pid=%ld — 전문값으로 분해", h_pid);
} else if (sqlca.sqlcode < 0) {
setl(b, T_RC, 8110); Bchg(b, T_MSG, 0, "매입 조회 오류", 0L);
userlog("[ST_FEEDECOMP] 매입 조회 오류 [%d]", sqlca.sqlcode); FAIL(b);
}
} else {
h_amount = amount; h_fee = feein;
strncpy(h_channel, channel, sizeof(h_channel)-1); h_channel[sizeof(h_channel)-1] = 0;
strncpy(h_issuer, issuer, sizeof(h_issuer)-1); h_issuer[sizeof(h_issuer)-1] = 0;
}
if (h_amount <= 0) { setl(b, T_RC, 8103); Bchg(b, T_MSG, 0, "분해 대상 금액이 0 이하", 0L); FAIL(b); }
/* 카드종류: 명시값 우선, 없으면 매입ID 규칙(하위 3자리 % 10 < 3 → 체크카드) */
if (ctype[0] == 0) strcpy(ctype, (h_pid > 0 && (h_pid % 10) < 3) ? "CHECK" : "CREDIT");
if (scheme[0] == 0) strcpy(scheme, "BC");
strncpy(h_ctype, ctype, sizeof(h_ctype)-1); h_ctype[sizeof(h_ctype)-1] = 0;
strncpy(h_scheme, scheme, sizeof(h_scheme)-1); h_scheme[sizeof(h_scheme)-1] = 0;
/* ===== 3) interchange 요율 조회부 (card_type×channel×SALE) ===== */
EXEC SQL SELECT rate_bps, fixed_fee INTO :h_icbps, :h_icfix FROM st_interchange_rate
WHERE card_type = :h_ctype AND mcc = 'DEFAULT' AND channel = :h_channel AND txn_type = 'SALE';
if (sqlca.sqlcode == 100) {
/* 채널 미매핑 → DEFAULT 채널 요율로 폴백 */
EXEC SQL SELECT rate_bps, fixed_fee INTO :h_icbps, :h_icfix FROM st_interchange_rate
WHERE card_type = :h_ctype AND mcc = 'DEFAULT' AND channel = 'DEFAULT' AND txn_type = 'SALE';
if (sqlca.sqlcode == 100) { h_icbps = 130; h_icfix = 0;
userlog("[ST_FEEDECOMP] interchange 요율 미존재 ctype=%s chan=%s — 기본 130bp 적용", h_ctype, h_channel); }
else if (sqlca.sqlcode < 0) { setl(b, T_RC, 8120); Bchg(b, T_MSG, 0, "interchange 요율 조회 오류", 0L); FAIL(b); }
} else if (sqlca.sqlcode < 0) { setl(b, T_RC, 8120); Bchg(b, T_MSG, 0, "interchange 요율 조회 오류", 0L); FAIL(b); }
/* ===== 4) scheme fee 요율 조회부 (scheme×SALE) ===== */
EXEC SQL SELECT rate_bps, fixed_fee INTO :h_sfbps, :h_sffix FROM st_scheme_fee
WHERE scheme = :h_scheme AND txn_type = 'SALE';
if (sqlca.sqlcode == 100) { h_sfbps = 11; h_sffix = 0;
userlog("[ST_FEEDECOMP] scheme 요율 미존재 scheme=%s — 기본 11bp 적용", h_scheme); }
else if (sqlca.sqlcode < 0) { setl(b, T_RC, 8130); Bchg(b, T_MSG, 0, "scheme 요율 조회 오류", 0L); FAIL(b); }
/* ===== 5) 업무규칙 분기부 (3층 산출) ===== */
h_ic = h_amount * h_icbps / 10000 + h_icfix; /* 발급사 귀속 */
h_sf = h_amount * h_sfbps / 10000 + h_sffix; /* 카드망 귀속 */
h_floor = h_amount * 8 / 10000; /* 매입사 최소 마진 8bp */
h_markup = h_fee - h_ic - h_sf; /* 기존 flat fee 잔여 = markup */
if (h_markup < h_floor) {
h_markup = h_floor;
userlog("[ST_FEEDECOMP] markup 하한(8bp) 적용 fee=%ld ic=%ld sf=%ld floor=%ld", h_fee, h_ic, h_sf, h_floor);
}
h_mdr = h_ic + h_sf + h_markup; /* MDR 합계 */
userlog("[ST_FEEDECOMP] 3층분해 ic=%ld(%ldbp) sf=%ld(%ldbp) markup=%ld mdr=%ld",
h_ic, h_icbps, h_sf, h_sfbps, h_markup, h_mdr);
/* ===== 6) 본처리부 (st_fee_component UPSERT) — 매입ID 있을 때만 영속 ===== */
if (h_pid > 0) {
EXEC SQL INSERT INTO st_fee_component
(purchase_id, amount, interchange, scheme_fee, markup, mdr_total, card_type, scheme)
VALUES (:h_pid, :h_amount, :h_ic, :h_sf, :h_markup, :h_mdr, :h_ctype, :h_scheme)
ON CONFLICT (purchase_id) DO UPDATE SET
amount = excluded.amount, interchange = excluded.interchange,
scheme_fee = excluded.scheme_fee, markup = excluded.markup,
mdr_total = excluded.mdr_total, card_type = excluded.card_type,
scheme = excluded.scheme, computed_at = now();
if (sqlca.sqlcode < 0) {
setl(b, T_RC, 8140); Bchg(b, T_MSG, 0, "수수료 분해 UPSERT 실패", 0L);
userlog("[ST_FEEDECOMP] st_fee_component UPSERT 실패 [%d]", sqlca.sqlcode); FAIL(b);
}
persist = 1;
} else {
userlog("[ST_FEEDECOMP] 매입ID 없음 — 미리보기(preview) 계산만 수행(영속 생략)");
}
/* ===== 7) 감사이력 적재부 (cm_log) + 재검증 ===== */
snprintf(msgbuf, sizeof(msgbuf), "ST_FEEDECOMP pid=%ld amt=%ld ic=%ld sf=%ld markup=%ld mdr=%ld ctype=%s",
h_pid, h_amount, h_ic, h_sf, h_markup, h_mdr, h_ctype);
strncpy(h_msg, msgbuf, sizeof(h_msg)-1); h_msg[sizeof(h_msg)-1] = 0;
EXEC SQL INSERT INTO cm_log (log_id, svc_name, log_level, msg, biz_date)
VALUES (nextval('cm_log_seq'), :h_svc, 'I', :h_msg, :h_bizdate);
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8150); Bchg(b, T_MSG, 0, "감사 로그 적재 실패", 0L); FAIL(b); }
if (persist) {
EXEC SQL SELECT count(*) INTO :h_cnt FROM st_fee_component WHERE purchase_id = :h_pid;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8151); Bchg(b, T_MSG, 0, "분해 재검증 조회 오류", 0L); FAIL(b); }
if (h_cnt <= 0) { setl(b, T_RC, 8152); Bchg(b, T_MSG, 0, "분해 미적재(보상 롤백)", 0L); FAIL(b); }
}
/* ===== 8) 응답부 (출력 필드 설정) ===== */
setl(b, T_AMT1, h_ic); /* interchange */
setl(b, T_AMT2, h_sf); /* scheme_fee */
setl(b, T_AMT3, h_markup); /* markup */
setl(b, T_FEE, h_mdr); /* mdr_total */
Bchg(b, T_STR1, 0, h_ctype, 0L);
Bchg(b, T_STR2, 0, h_scheme, 0L);
Bchg(b, T_SVCNAME, 0, "ST_FEEDECOMP", 0L);
setl(b, T_RC, 0);
/* ===== 9) 완료 로깅 ===== */
userlog("[정산심화] 수수료 3층 분해 완료 rc=0 pid=%ld mdr=%ld persist=%ld", h_pid, h_mdr, persist);
OK(b);
}
/* vim: set ts=4 sw=4 et smartindent: */

View file

@ -0,0 +1,26 @@
/*
* ST_FUNDING.h - st ST_FUNDING (copybook / record header).
* : = .
* gross(S)/refunds(C)/chargebacks(ac_dispute LOST)/fees(st_fee_component)/reserve(st_reserve)
* deposit st_merchant_funding UPSERT.
*/
#ifndef ST_FUNDING_H
#define ST_FUNDING_H
#include "st_svc.h"
typedef struct {
char merchant_id[64];
char biz_date[16];
long gross_sales;
long refunds;
long chargebacks;
long fees; /* MDR 합계 */
long reserve;
long deposit; /* 실제 입금액 */
char funding_model[16];
} st_st_funding_rec_t;
void ST_FUNDING(TPSVCINFO *p);
#endif /* ST_FUNDING_H */

View file

@ -0,0 +1,155 @@
/*
* ST_FUNDING.pgc - st (정산/수수료) 모듈 서비스 ST_FUNDING (one service = one file).
* 가맹점 펀딩 항등식(심화판): 총매출(S) 환불(C) 채그백(ac_dispute LOST) 수수료
* (st_fee_component) 리저브(st_reserve) = 입금(deposit) → st_merchant_funding UPSERT.
* 처리단계: 입력검증 -> 가맹점 마스터조회 -> 매출/환불/채그백/수수료/리저브 집계
* -> 업무규칙(항등식/음수보정) -> st_merchant_funding UPSERT -> 감사이력 -> 재검증 -> 응답 -> 로깅.
* 호출자 XA 브랜치에서 수행(EXEC SQL CONNECT 없음). st_svr 가 tpsvrinit 에서 광고.
* XA 격리: 채그백/수수료/리저브는 본 브랜치에서 조회 가능한 커밋행 기준으로 집계한다.
*/
#include "ST_FUNDING.h"
/* ST_FUNDING - 가맹점 펀딩 항등식(총매출-환불-채그백-수수료-리저브=입금). */
void ST_FUNDING(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
/* ---- 지역 스크래치 (DECLARE SECTION 밖에서 초기화) ---- */
char merch[64], bizdate[16], model[16], msgbuf[200];
EXEC SQL BEGIN DECLARE SECTION;
long h_mcnt, h_gross, h_refunds, h_cb, h_fees, h_reserve, h_deposit;
long h_scnt, h_ccnt, h_mdr, h_limit, h_chk;
char h_merch[64], h_bizdate[16], h_model[16], h_mstatus[16], h_svc[24], h_msg[200];
EXEC SQL END DECLARE SECTION;
strcpy(h_svc, "ST_FUNDING");
h_mcnt = h_gross = h_refunds = h_cb = h_fees = h_reserve = h_deposit = 0;
h_scnt = h_ccnt = h_chk = 0; h_mdr = 25; h_limit = 0;
strcpy(h_mstatus, "ACTIVE"); strcpy(h_model, "NET_DAILY");
/* ===== 1) 입력 검증부 ===== */
gets_(b, T_MERCHANT, merch, sizeof(merch));
gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
gets_(b, T_STR1, model, sizeof(model)); /* 선택: 펀딩모델 */
if (model[0] == 0) strcpy(model, "NET_DAILY");
userlog("[정산심화] 가맹점 펀딩 항등식 진입 merch=%s biz=%s model=%s", merch, bizdate, model);
if (merch[0] == 0) {
setl(b, T_RC, 8301); Bchg(b, T_MSG, 0, "가맹점 ID(merchant_id) 필수", 0L);
userlog("[ST_FUNDING] 가맹점 ID 누락"); FAIL(b);
}
if (strlen(bizdate) < 8) {
setl(b, T_RC, 8302); Bchg(b, T_MSG, 0, "영업일자(biz_date) 형식 오류", 0L);
userlog("[ST_FUNDING] 영업일 검증 실패 val=[%s]", bizdate); FAIL(b);
}
strncpy(h_merch, merch, sizeof(h_merch)-1); h_merch[sizeof(h_merch)-1] = 0;
strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
strncpy(h_model, model, sizeof(h_model)-1); h_model[sizeof(h_model)-1] = 0;
/* ===== 2) 가맹점 마스터 조회부 ===== */
EXEC SQL SELECT mdr_bps, daily_limit, status
INTO :h_mdr, :h_limit, :h_mstatus FROM merchant WHERE merchant_id = :h_merch;
if (sqlca.sqlcode == 100) {
strcpy(h_mstatus, "NOMERCH"); h_mdr = 25; h_limit = 0;
userlog("[ST_FUNDING] 가맹점 마스터 미존재 mid=[%s] 기본정책 적용", h_merch);
} else if (sqlca.sqlcode < 0) {
setl(b, T_RC, 8310); Bchg(b, T_MSG, 0, "가맹점 마스터 조회 오류", 0L); FAIL(b);
}
/* ===== 3) 매출/환불/채그백/수수료/리저브 집계부 ===== */
/* 총매출(S) + 환불(C) — 상태별 분기 집계 */
EXEC SQL SELECT
coalesce(sum(CASE WHEN status = 'S' THEN amount ELSE 0 END),0),
coalesce(sum(CASE WHEN status = 'C' THEN amount ELSE 0 END),0),
coalesce(sum(CASE WHEN status = 'S' THEN 1 ELSE 0 END),0),
coalesce(sum(CASE WHEN status = 'C' THEN 1 ELSE 0 END),0),
count(*)
INTO :h_gross, :h_refunds, :h_scnt, :h_ccnt, :h_mcnt
FROM purchase WHERE merchant_id = :h_merch AND biz_date = :h_bizdate;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8320); Bchg(b, T_MSG, 0, "매출/환불 집계 오류", 0L); FAIL(b); }
/* 수수료(MDR 합계) = st_fee_component.mdr_total */
EXEC SQL SELECT coalesce(sum(fc.mdr_total),0) INTO :h_fees
FROM st_fee_component fc JOIN purchase p ON p.purchase_id = fc.purchase_id
WHERE p.merchant_id = :h_merch AND p.biz_date = :h_bizdate;
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8321); Bchg(b, T_MSG, 0, "수수료 집계 오류", 0L); FAIL(b); }
/* 채그백 = 확정손실(ac_dispute LOST) 금액 */
EXEC SQL SELECT coalesce(sum(d.amount),0) INTO :h_cb
FROM ac_dispute d JOIN purchase p ON p.purchase_id = d.purchase_id
WHERE p.merchant_id = :h_merch AND p.biz_date = :h_bizdate AND d.status = 'LOST';
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8322); Bchg(b, T_MSG, 0, "채그백 집계 오류", 0L); FAIL(b); }
/* 리저브 = 당일 유보액(없으면 0) */
EXEC SQL SELECT reserve_amt INTO :h_reserve FROM st_reserve
WHERE merchant_id = :h_merch AND biz_date = :h_bizdate;
if (sqlca.sqlcode == 100) { h_reserve = 0;
userlog("[ST_FUNDING] 리저브 미설정 mid=%s biz=%s — 0 처리", h_merch, h_bizdate); }
else if (sqlca.sqlcode < 0) { setl(b, T_RC, 8323); Bchg(b, T_MSG, 0, "리저브 조회 오류", 0L); FAIL(b); }
userlog("[ST_FUNDING] 집계 gross=%ld refunds=%ld cb=%ld fees=%ld reserve=%ld (S=%ld C=%ld)",
h_gross, h_refunds, h_cb, h_fees, h_reserve, h_scnt, h_ccnt);
/* ===== 4) 업무규칙 분기부 (항등식/음수보정) ===== */
if (h_gross <= 0) {
Bchg(b, T_STATUS, 0, "NODATA", 0L);
setl(b, T_AMT1, 0); setl(b, T_AMT2, 0); setl(b, T_AMT3, 0);
Bchg(b, T_SVCNAME, 0, "ST_FUNDING", 0L); setl(b, T_RC, 0);
userlog("[ST_FUNDING] 당일 매출 없음 merch=%s biz=%s — 무처리 종료", h_merch, h_bizdate);
OK(b);
}
h_deposit = h_gross - h_refunds - h_cb - h_fees - h_reserve; /* 펀딩 항등식 */
if (h_deposit < 0)
userlog("[ST_FUNDING] 입금액 음수(차감 초과) merch=%s deposit=%ld — 익일 이월 대상", h_merch, h_deposit);
/* ===== 5) 본처리부 (st_merchant_funding UPSERT) ===== */
EXEC SQL INSERT INTO st_merchant_funding
(funding_id, merchant_id, biz_date, gross_sales, refunds, chargebacks,
fees, reserve, deposit, funding_model, status)
VALUES (nextval('st_funding_seq'), :h_merch, :h_bizdate, :h_gross, :h_refunds, :h_cb,
:h_fees, :h_reserve, :h_deposit, :h_model, 'FUNDED')
ON CONFLICT (merchant_id, biz_date) DO UPDATE SET
gross_sales = excluded.gross_sales, refunds = excluded.refunds,
chargebacks = excluded.chargebacks, fees = excluded.fees,
reserve = excluded.reserve, deposit = excluded.deposit,
funding_model = excluded.funding_model, status = 'FUNDED', funded_at = now();
if (sqlca.sqlcode < 0) {
setl(b, T_RC, 8330); Bchg(b, T_MSG, 0, "펀딩 UPSERT 실패", 0L);
userlog("[ST_FUNDING] st_merchant_funding UPSERT 실패 [%d]", sqlca.sqlcode); FAIL(b);
}
/* ===== 6) 감사이력 적재부 (cm_log) ===== */
snprintf(msgbuf, sizeof(msgbuf), "ST_FUNDING merch=%s biz=%s gross=%ld fees=%ld cb=%ld deposit=%ld",
h_merch, h_bizdate, h_gross, h_fees, h_cb, h_deposit);
strncpy(h_msg, msgbuf, sizeof(h_msg)-1); h_msg[sizeof(h_msg)-1] = 0;
EXEC SQL INSERT INTO cm_log (log_id, svc_name, log_level, msg, biz_date)
VALUES (nextval('cm_log_seq'), :h_svc, 'I', :h_msg, :h_bizdate);
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8340); Bchg(b, T_MSG, 0, "감사 로그 적재 실패", 0L); FAIL(b); }
/* ===== 7) 재검증부 (항등식 검산 — 적재행 재조회) ===== */
EXEC SQL SELECT deposit INTO :h_chk FROM st_merchant_funding
WHERE merchant_id = :h_merch AND biz_date = :h_bizdate;
if (sqlca.sqlcode < 0 || sqlca.sqlcode == 100) {
setl(b, T_RC, 8350); Bchg(b, T_MSG, 0, "펀딩 적재 재검증 실패", 0L);
userlog("[ST_FUNDING] 재검증 실패 code=%d", sqlca.sqlcode); FAIL(b);
}
if (h_chk != h_deposit) {
setl(b, T_RC, 8351); Bchg(b, T_MSG, 0, "펀딩 항등식 불일치(보상 롤백)", 0L);
userlog("[ST_FUNDING] 항등식 불일치 chk=%ld deposit=%ld", h_chk, h_deposit); FAIL(b);
}
/* ===== 8) 응답부 (출력 필드 설정) ===== */
setl(b, T_AMT1, h_gross); /* gross */
setl(b, T_AMT2, h_fees); /* fees */
setl(b, T_AMT3, h_deposit); /* deposit */
setl(b, T_AMT4, h_reserve); /* reserve(참고) */
setl(b, T_GROSS, h_gross);
setl(b, T_FEE, h_fees);
setl(b, T_NET, h_deposit);
setl(b, T_COUNT, h_mcnt);
Bchg(b, T_STATUS, 0, "FUNDED", 0L);
Bchg(b, T_SVCNAME, 0, "ST_FUNDING", 0L);
setl(b, T_RC, 0);
/* ===== 9) 완료 로깅 ===== */
userlog("[정산심화] 가맹점 펀딩 완료 merch=%s biz=%s gross=%ld fees=%ld deposit=%ld",
h_merch, h_bizdate, h_gross, h_fees, h_deposit);
OK(b);
}
/* vim: set ts=4 sw=4 et smartindent: */

View file

@ -0,0 +1,25 @@
/*
* ST_NETSETTLE.h - st ST_NETSETTLE (copybook / record header).
* ( BIN) . st_fee_component+purchase
* BIN (gross/interchange/scheme) net=gross-ic-sf st_net_settlement UPSERT.
*/
#ifndef ST_NETSETTLE_H
#define ST_NETSETTLE_H
#include "st_svc.h"
typedef struct {
char biz_date[16];
char issuer[40]; /* 선택: 특정 발급사(bank_code)만 정산 */
char member_bin[16];
long txn_count;
long gross_amt;
long interchange_amt;
long scheme_fee_amt;
long net_amt;
char direction[16]; /* RECEIVE/PAY */
} st_st_netsettle_rec_t;
void ST_NETSETTLE(TPSVCINFO *p);
#endif /* ST_NETSETTLE_H */

View file

@ -0,0 +1,144 @@
/*
* ST_NETSETTLE.pgc - st (정산/수수료) 모듈 서비스 ST_NETSETTLE (one service = one file).
* 발급사(회원 BIN)별 순액정산 배치(심화판). 해당 영업일의 st_fee_component+purchase 를
* 회원사(st_member_bank) BIN 별로 집계 → gross/interchange/scheme 합산, net=gross-ic-sf
* → st_net_settlement UPSERT(ON CONFLICT scheme,member_bin,biz_date).
* 처리단계: 입력검증 -> 참가회원사/대상건 조회 -> 업무규칙(대상유무) -> 순액배치 UPSERT(집합기반)
* -> 결과 커서순회 로깅 -> 감사이력 -> 총계 재집계 -> 응답 -> 로깅.
* 호출자 XA 브랜치에서 수행(EXEC SQL CONNECT 없음). st_svr 가 tpsvrinit 에서 광고.
* 쓰기 소유자 단일화(owner-writes): 순액배치 UPSERT 는 집합기반 단일 문장으로 수행한다.
*/
#include "ST_NETSETTLE.h"
/* ST_NETSETTLE - 발급사별 순액정산 배치. */
void ST_NETSETTLE(TPSVCINFO *p)
{
UBFH *b = (UBFH *)p->data;
/* ---- 지역 스크래치 (DECLARE SECTION 밖에서 초기화) ---- */
char bizdate[16], issuer[40], msgbuf[200];
long rows = 0;
EXEC SQL BEGIN DECLARE SECTION;
long h_members, h_srccnt, h_upcnt, h_tcnt, h_tgross, h_tic, h_tsf, h_tnet;
long h_rcnt, h_rgross, h_rnet;
char h_bizdate[16], h_issuer[40], h_bin[16], h_dir[16], h_svc[24], h_msg[200];
EXEC SQL END DECLARE SECTION;
strcpy(h_svc, "ST_NETSETTLE");
h_members = h_srccnt = h_upcnt = 0;
h_tcnt = h_tgross = h_tic = h_tsf = h_tnet = 0;
h_rcnt = h_rgross = h_rnet = 0;
strcpy(h_bin, "-"); strcpy(h_dir, "RECEIVE");
/* ===== 1) 입력 검증부 ===== */
gets_(b, T_BIZDATE, bizdate, sizeof(bizdate));
gets_(b, T_ISSUER, issuer, sizeof(issuer)); /* 선택: 특정 발급사만 */
if (strlen(bizdate) < 8) {
setl(b, T_RC, 8201); Bchg(b, T_MSG, 0, "영업일자(biz_date) 형식 오류", 0L);
userlog("[ST_NETSETTLE] 영업일 검증 실패 val=[%s]", bizdate); FAIL(b);
}
strncpy(h_bizdate, bizdate, sizeof(h_bizdate)-1); h_bizdate[sizeof(h_bizdate)-1] = 0;
strncpy(h_issuer, issuer, sizeof(h_issuer)-1); h_issuer[sizeof(h_issuer)-1] = 0;
userlog("[정산심화] 발급사별 순액정산 배치 진입 biz=%s issuer=[%s]", h_bizdate, h_issuer);
/* ===== 2) 참가 회원사/대상건 조회부 ===== */
EXEC SQL SELECT count(*) INTO :h_members FROM st_member_bank
WHERE role IN ('ISSUER', 'BOTH') AND (:h_issuer = '' OR bank_code = :h_issuer);
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8210); Bchg(b, T_MSG, 0, "회원사 조회 오류", 0L); FAIL(b); }
EXEC SQL SELECT count(*) INTO :h_srccnt
FROM st_fee_component fc JOIN purchase p ON p.purchase_id = fc.purchase_id
JOIN st_member_bank mb ON mb.bank_code = p.issuer
WHERE p.biz_date = :h_bizdate AND (:h_issuer = '' OR p.issuer = :h_issuer);
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8211); Bchg(b, T_MSG, 0, "정산 대상 조회 오류", 0L); FAIL(b); }
userlog("[ST_NETSETTLE] 참가 회원사=%ld 대상 분해건=%ld", h_members, h_srccnt);
/* ===== 3) 업무규칙 분기부 (대상 유무) ===== */
if (h_srccnt <= 0) {
Bchg(b, T_STATUS, 0, "NODATA", 0L);
setl(b, T_ARG1, 0); setl(b, T_AMT1, 0); setl(b, T_AMT2, 0);
Bchg(b, T_SVCNAME, 0, "ST_NETSETTLE", 0L); setl(b, T_RC, 0);
userlog("[ST_NETSETTLE] 대상 없음 biz=%s — 무처리 종료", h_bizdate);
OK(b);
}
/* ===== 4) 본처리부: 발급사 BIN×영업일 순액배치 UPSERT (집합기반 단일문장) =====
* net_amt = gross - interchange - scheme_fee (부호=수취/지급 방향). */
EXEC SQL INSERT INTO st_net_settlement
(net_id, scheme, member_bin, biz_date, txn_count,
gross_amt, interchange_amt, scheme_fee_amt, net_amt, direction, status)
SELECT nextval('st_netset_seq'), 'BC', mb.bin, p.biz_date, count(*),
coalesce(sum(fc.amount),0), coalesce(sum(fc.interchange),0), coalesce(sum(fc.scheme_fee),0),
coalesce(sum(fc.amount - fc.interchange - fc.scheme_fee),0),
CASE WHEN coalesce(sum(fc.amount - fc.interchange - fc.scheme_fee),0) >= 0
THEN 'RECEIVE' ELSE 'PAY' END,
'SETTLED'
FROM st_fee_component fc
JOIN purchase p ON p.purchase_id = fc.purchase_id
JOIN st_member_bank mb ON mb.bank_code = p.issuer
WHERE p.biz_date = :h_bizdate AND (:h_issuer = '' OR p.issuer = :h_issuer)
GROUP BY mb.bin, p.biz_date
ON CONFLICT (scheme, member_bin, biz_date) DO UPDATE SET
txn_count = excluded.txn_count, gross_amt = excluded.gross_amt,
interchange_amt = excluded.interchange_amt, scheme_fee_amt = excluded.scheme_fee_amt,
net_amt = excluded.net_amt, direction = excluded.direction, settled_at = now();
if (sqlca.sqlcode < 0) {
setl(b, T_RC, 8220); Bchg(b, T_MSG, 0, "순액정산 배치 UPSERT 실패", 0L);
userlog("[ST_NETSETTLE] st_net_settlement UPSERT 실패 [%d]", sqlca.sqlcode); FAIL(b);
}
h_upcnt = sqlca.sqlerrd[2]; /* 처리 행수 */
userlog("[ST_NETSETTLE] 순액배치 UPSERT 처리행=%ld", h_upcnt);
/* ===== 5) 결과 커서 순회 로깅부 (같은 XA 브랜치 — 방금 UPSERT 가시) ===== */
EXEC SQL DECLARE cur_netset CURSOR FOR
SELECT member_bin, txn_count, gross_amt, net_amt, direction
FROM st_net_settlement
WHERE biz_date = :h_bizdate
AND (:h_issuer = '' OR member_bin IN (SELECT bin FROM st_member_bank WHERE bank_code = :h_issuer))
ORDER BY member_bin;
EXEC SQL OPEN cur_netset;
for (;;) {
EXEC SQL FETCH cur_netset INTO :h_bin, :h_rcnt, :h_rgross, :h_rnet, :h_dir;
if (sqlca.sqlcode == 100) break;
if (sqlca.sqlcode < 0) { EXEC SQL CLOSE cur_netset; setl(b, T_RC, 8230);
Bchg(b, T_MSG, 0, "순액정산 결과 순회 오류", 0L); FAIL(b); }
rows++;
userlog("[ST_NETSETTLE] BIN=%s cnt=%ld gross=%ld net=%ld dir=%s",
h_bin, h_rcnt, h_rgross, h_rnet, h_dir);
}
EXEC SQL CLOSE cur_netset;
/* ===== 6) 감사이력 적재부 (cm_log) ===== */
snprintf(msgbuf, sizeof(msgbuf), "ST_NETSETTLE biz=%s members=%ld src=%ld upsert=%ld rows=%ld",
h_bizdate, h_members, h_srccnt, h_upcnt, rows);
strncpy(h_msg, msgbuf, sizeof(h_msg)-1); h_msg[sizeof(h_msg)-1] = 0;
EXEC SQL INSERT INTO cm_log (log_id, svc_name, log_level, msg, biz_date)
VALUES (nextval('cm_log_seq'), :h_svc, 'I', :h_msg, :h_bizdate);
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8240); Bchg(b, T_MSG, 0, "감사 로그 적재 실패", 0L); FAIL(b); }
/* ===== 7) 총계 재집계부 ===== */
EXEC SQL SELECT coalesce(sum(txn_count),0), coalesce(sum(gross_amt),0),
coalesce(sum(interchange_amt),0), coalesce(sum(scheme_fee_amt),0),
coalesce(sum(net_amt),0)
INTO :h_tcnt, :h_tgross, :h_tic, :h_tsf, :h_tnet
FROM st_net_settlement
WHERE biz_date = :h_bizdate
AND (:h_issuer = '' OR member_bin IN (SELECT bin FROM st_member_bank WHERE bank_code = :h_issuer));
if (sqlca.sqlcode < 0) { setl(b, T_RC, 8250); Bchg(b, T_MSG, 0, "순액정산 총계 재집계 오류", 0L); FAIL(b); }
/* ===== 8) 응답부 (출력 필드 설정) ===== */
setl(b, T_ARG1, h_tcnt); /* txn_count 총계 */
setl(b, T_AMT1, h_tgross); /* gross 총계 */
setl(b, T_AMT2, h_tnet); /* net 총계 */
setl(b, T_AMT3, h_tic); /* interchange 총계(참고) */
setl(b, T_COUNT, rows); /* 정산 BIN 수 */
Bchg(b, T_STATUS, 0, "SETTLED", 0L);
Bchg(b, T_SVCNAME, 0, "ST_NETSETTLE", 0L);
setl(b, T_RC, 0);
/* ===== 9) 완료 로깅 ===== */
userlog("[정산심화] 발급사별 순액정산 완료 biz=%s bins=%ld txn=%ld gross=%ld net=%ld",
h_bizdate, rows, h_tcnt, h_tgross, h_tnet);
OK(b);
}
/* vim: set ts=4 sw=4 et smartindent: */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,2 +1,2 @@
/* 자동생성: tools/gen_metrics.py — 소스 실측 마이그레이션 난이도 지표. 손대지 말 것. */
window.METRICS={"SCALE":[{"k":"소스 파일 (.pgc/.c/.h)","v":4626,"u":"개"},{"k":"총 코드 라인","v":330642,"u":"LOC"},{"k":"온라인 서비스","v":1463,"u":"본"},{"k":"배치 잡","v":506,"u":"본"},{"k":"임베디드 SQL(EXEC SQL)","v":23742,"u":"블록"},{"k":"Tuxedo tpcall 호출","v":989,"u":"곳"},{"k":"전역 트랜잭션 tpbegin","v":1234,"u":"곳"},{"k":"UBF 버퍼 필드접근","v":46904,"u":"곳"},{"k":"DB 테이블","v":75,"u":"개"},{"k":"카피북 헤더(.h)","v":2207,"u":"개"}],"HAZARDS":[{"t":"Tuxedo ATMI (tpcall/tpservice/tpreturn)","n":989,"u":"호출","why":"Java·Spring에 1:1 대응 API가 없다. 서비스 호출 모델 자체를 REST/메시지로 재설계해야 하며, 문법 치환으로는 불가능."},{"t":"XA 2PC 전역 트랜잭션 (최대 6단 중첩)","n":1234,"u":"tpbegin","why":"형제 XA 브랜치 격리·2단계 커밋 의미론이 Spring @Transactional 경계와 다르다. 잘못 옮기면 교착·이중전기가 그대로 재현된다."},{"t":"ECPG 임베디드 SQL (호스트변수·커서·sqlca)","n":23742,"u":"블록","why":"EXEC SQL 블록마다 호스트변수 바인딩·sqlcode 분기·커서 수명을 JPA/MyBatis 의미로 옮겨야 한다. 자동 변환기가 가장 많이 깨지는 지점."},{"t":"UBF(FML32) 타입리스 버퍼","n":46904,"u":"필드접근","why":"필드ID 기반 동적 버퍼라 타입·필수여부가 컴파일타임에 없다. Java DTO로 옮기려면 47,000여 접근점의 계약을 사람이 복원해야 한다."},{"t":"카피북 공유(#include) 팬아웃","n":6892,"u":"참조","why":"한 카피북을 수십 프로그램이 공유해 구조체 변경이 전방위로 파급된다. 경계 없는 결합이라 모듈 단위 절단이 어렵다."},{"t":"전처리 매크로(#define)","n":2247,"u":"정의","why":"매크로가 코드 생성·분기를 숨겨 정적 분석을 방해한다. 전개 후에야 실제 로직이 드러난다."}],"TRACE":[{"root":"ACQUIRE","tree":{"svc":"ACQUIRE","mod":"ac","wr":["cm_log"],"children":[{"svc":"RECONCILE","mod":"rc","wr":["cm_log","ledger"],"children":[{"svc":"SETTLE","mod":"st","wr":["ledger","settlement"],"children":[{"svc":"ST_MDR","mod":"st","wr":["ledger"],"children":[{"svc":"LG_SETTLEPOST","mod":"lg","wr":["cm_log"],"children":[{"svc":"LG_BALCHK","mod":"lg","wr":["cm_log"],"children":[]}]}]}]}]}]},"stat":{"programs":6,"tables":3,"xa_branches":6,"tablelist":["cm_log","ledger","settlement"]}},{"root":"PAY_APPROVE","tree":{"svc":"PAY_APPROVE","mod":"py","wr":["cm_log","py_payout"],"children":[{"svc":"PAY_FILEGEN","mod":"py","wr":["cm_log","py_file","py_payout"],"children":[{"svc":"PAY_ACCTCHK","mod":"py","wr":["cm_log"],"children":[{"svc":"LG_POST","mod":"lg","wr":["cm_log"],"children":[{"svc":"LG_BALCHK","mod":"lg","wr":["cm_log"],"children":[]}]}]}]}]},"stat":{"programs":5,"tables":3,"xa_branches":5,"tablelist":["cm_log","py_file","py_payout"]}},{"root":"CL_DAILY","tree":{"svc":"CL_DAILY","mod":"cl","wr":["cm_log"],"children":[{"svc":"VL_SALES","mod":"vl","wr":[],"children":[{"svc":"RC_EXCEPTION","mod":"rc","wr":["cm_log"],"children":[{"svc":"CM_LOG","mod":"cm","wr":[],"children":[]}]}]},{"svc":"LG_CLOSE_LINK","mod":"lg","wr":["cm_log","lg_ledger_line"],"children":[{"svc":"CL_LOCK","mod":"cl","wr":["SET","cl_close_log","cl_period_lock","cm_log"],"children":[]}]}]},"stat":{"programs":6,"tables":5,"xa_branches":6,"tablelist":["SET","cl_close_log","cl_period_lock","cm_log","lg_ledger_line"]}}],"LEGACY":{"copybook_total":2207,"copybook_shared_max":139,"copybook_shared_name":"acq_common.h","macro_defs":2247,"goto":64,"userlog":17122,"sqlca_checks":18719,"embedded_sql_density":9.8}};
window.METRICS={"SCALE":[{"k":"소스 파일 (.pgc/.c/.h)","v":4638,"u":"개"},{"k":"총 코드 라인","v":331721,"u":"LOC"},{"k":"온라인 서비스","v":1469,"u":"본"},{"k":"배치 잡","v":506,"u":"본"},{"k":"임베디드 SQL(EXEC SQL)","v":23810,"u":"블록"},{"k":"Tuxedo tpcall 호출","v":989,"u":"곳"},{"k":"전역 트랜잭션 tpbegin","v":1234,"u":"곳"},{"k":"UBF 버퍼 필드접근","v":47113,"u":"곳"},{"k":"DB 테이블","v":84,"u":"개"},{"k":"카피북 헤더(.h)","v":2213,"u":"개"}],"HAZARDS":[{"t":"Tuxedo ATMI (tpcall/tpservice/tpreturn)","n":989,"u":"호출","why":"Java·Spring에 1:1 대응 API가 없다. 서비스 호출 모델 자체를 REST/메시지로 재설계해야 하며, 문법 치환으로는 불가능."},{"t":"XA 2PC 전역 트랜잭션 (최대 6단 중첩)","n":1234,"u":"tpbegin","why":"형제 XA 브랜치 격리·2단계 커밋 의미론이 Spring @Transactional 경계와 다르다. 잘못 옮기면 교착·이중전기가 그대로 재현된다."},{"t":"ECPG 임베디드 SQL (호스트변수·커서·sqlca)","n":23810,"u":"블록","why":"EXEC SQL 블록마다 호스트변수 바인딩·sqlcode 분기·커서 수명을 JPA/MyBatis 의미로 옮겨야 한다. 자동 변환기가 가장 많이 깨지는 지점."},{"t":"UBF(FML32) 타입리스 버퍼","n":47113,"u":"필드접근","why":"필드ID 기반 동적 버퍼라 타입·필수여부가 컴파일타임에 없다. Java DTO로 옮기려면 47,000여 접근점의 계약을 사람이 복원해야 한다."},{"t":"카피북 공유(#include) 팬아웃","n":6904,"u":"참조","why":"한 카피북을 수십 프로그램이 공유해 구조체 변경이 전방위로 파급된다. 경계 없는 결합이라 모듈 단위 절단이 어렵다."},{"t":"전처리 매크로(#define)","n":2253,"u":"정의","why":"매크로가 코드 생성·분기를 숨겨 정적 분석을 방해한다. 전개 후에야 실제 로직이 드러난다."}],"TRACE":[{"root":"ACQUIRE","tree":{"svc":"ACQUIRE","mod":"ac","wr":["cm_log"],"children":[{"svc":"RECONCILE","mod":"rc","wr":["cm_log","ledger"],"children":[{"svc":"SETTLE","mod":"st","wr":["ledger","settlement"],"children":[{"svc":"ST_MDR","mod":"st","wr":["ledger"],"children":[{"svc":"LG_SETTLEPOST","mod":"lg","wr":["cm_log"],"children":[{"svc":"LG_BALCHK","mod":"lg","wr":["cm_log"],"children":[]}]}]}]}]}]},"stat":{"programs":6,"tables":3,"xa_branches":6,"tablelist":["cm_log","ledger","settlement"]}},{"root":"PAY_APPROVE","tree":{"svc":"PAY_APPROVE","mod":"py","wr":["cm_log","py_payout"],"children":[{"svc":"PAY_FILEGEN","mod":"py","wr":["cm_log","py_file","py_payout"],"children":[{"svc":"PAY_ACCTCHK","mod":"py","wr":["cm_log"],"children":[{"svc":"LG_POST","mod":"lg","wr":["cm_log"],"children":[{"svc":"LG_BALCHK","mod":"lg","wr":["cm_log"],"children":[]}]}]}]}]},"stat":{"programs":5,"tables":3,"xa_branches":5,"tablelist":["cm_log","py_file","py_payout"]}},{"root":"CL_DAILY","tree":{"svc":"CL_DAILY","mod":"cl","wr":["cm_log"],"children":[{"svc":"VL_SALES","mod":"vl","wr":[],"children":[{"svc":"RC_EXCEPTION","mod":"rc","wr":["cm_log"],"children":[{"svc":"CM_LOG","mod":"cm","wr":[],"children":[]}]}]},{"svc":"LG_CLOSE_LINK","mod":"lg","wr":["cm_log","lg_ledger_line"],"children":[{"svc":"CL_LOCK","mod":"cl","wr":["SET","cl_close_log","cl_period_lock","cm_log"],"children":[]}]}]},"stat":{"programs":6,"tables":5,"xa_branches":6,"tablelist":["SET","cl_close_log","cl_period_lock","cm_log","lg_ledger_line"]}}],"LEGACY":{"copybook_total":2213,"copybook_shared_max":139,"copybook_shared_name":"acq_common.h","macro_defs":2253,"goto":64,"userlog":17179,"sqlca_checks":18788,"embedded_sql_density":9.8}};

148
db/schema.d/70-econ.sql Normal file
View file

@ -0,0 +1,148 @@
-- ===========================================================================
-- 70-econ.sql : 실제 매입사 경제모델 (딥리서치 갭분석 고도화)
-- 수수료 3층 분해(interchange+scheme fee+acquirer markup),
-- 클리어링/프레젠트먼트(정산과 분리), 발급사↔매입사 순액정산(BIN/ICA·회원사),
-- 정산 항등식(총매출−환불−채그백−수수료−리저브=입금), 채그백/분쟁 라이프사이클.
-- 기존 flat MDR 은 markup 으로 재해석하여 하위호환.
-- ===========================================================================
-- ---------------------------------------------------------------------------
-- 1. Interchange 요율표 (발급사 귀속분). 카드종류×MCC×채널×거래유형 차등.
-- 실제 매입사에서 요율이 갈리는 축을 그대로 반영(interchange qualification).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS st_interchange_rate (
card_type TEXT NOT NULL, -- CREDIT/CHECK/PREPAID
mcc TEXT NOT NULL DEFAULT 'DEFAULT',-- 업종(가맹점 category 매핑)
channel TEXT NOT NULL DEFAULT 'DEFAULT',-- POS/EDC/EDI/FOREIGN
txn_type TEXT NOT NULL DEFAULT 'SALE', -- SALE/CASH/REFUND
rate_bps BIGINT NOT NULL DEFAULT 0, -- basis points (1bp=0.01%)
fixed_fee BIGINT NOT NULL DEFAULT 0, -- 건당 고정수수료(원)
eff_date DATE NOT NULL DEFAULT current_date,
PRIMARY KEY (card_type, mcc, channel, txn_type)
);
-- ---------------------------------------------------------------------------
-- 2. Scheme fee (카드망 assessment). 스킴×거래유형.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS st_scheme_fee (
scheme TEXT NOT NULL, -- BC/VISA/MASTER/LOCAL
txn_type TEXT NOT NULL DEFAULT 'SALE',
rate_bps BIGINT NOT NULL DEFAULT 0,
fixed_fee BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (scheme, txn_type)
);
-- ---------------------------------------------------------------------------
-- 3. 거래별 수수료 분해 (MDR = interchange + scheme_fee + markup).
-- 매입사 손익: markup 만 매입사 수익, interchange 는 발급사, scheme_fee 는 카드망.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS st_fee_component (
purchase_id BIGINT PRIMARY KEY,
amount BIGINT NOT NULL,
interchange BIGINT NOT NULL DEFAULT 0, -- 발급사 귀속
scheme_fee BIGINT NOT NULL DEFAULT 0, -- 카드망 귀속
markup BIGINT NOT NULL DEFAULT 0, -- 매입사 수익(협상대상)
mdr_total BIGINT NOT NULL DEFAULT 0, -- = interchange+scheme_fee+markup
card_type TEXT NOT NULL DEFAULT 'CREDIT',
scheme TEXT NOT NULL DEFAULT 'BC',
computed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- 4. 프레젠트먼트(클리어링). 승인·정산과 분리된 단계.
-- 클리어링=데이터 교환(발급사↔매입사 채무 확정). 정산=자금이동.
-- ---------------------------------------------------------------------------
CREATE SEQUENCE IF NOT EXISTS cl_present_seq START 700000;
CREATE TABLE IF NOT EXISTS cl_presentment (
presentment_id BIGINT PRIMARY KEY,
purchase_id BIGINT NOT NULL,
scheme TEXT NOT NULL DEFAULT 'BC',
msg_class TEXT NOT NULL DEFAULT 'FIRST_PRESENT', -- FIRST_PRESENT/REPRESENT/CHARGEBACK
clearing_amt BIGINT NOT NULL, -- 클리어링 대상 금액(net of interchange)
present_date DATE NOT NULL,
cycle TEXT NOT NULL DEFAULT 'T+1', -- 정산주기
status TEXT NOT NULL DEFAULT 'CLEARED', -- CLEARED/REJECTED/PENDING
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- 5. 회원사(정산 참가 은행/발급사) — 한국 BC카드형 다자간 순액정산.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS st_member_bank (
bank_code TEXT PRIMARY KEY, -- BC/KB/SHINHAN/...
bank_name TEXT NOT NULL,
bin TEXT NOT NULL, -- 스킴 회원식별자(Visa BIN / MC ICA 대응)
role TEXT NOT NULL DEFAULT 'BOTH' -- ISSUER/ACQUIRER/BOTH
);
-- ---------------------------------------------------------------------------
-- 6. 순액정산 배치 (발급사↔매입사). BIN/ICA 기준 차대변 합산 → 단일 순액.
-- ---------------------------------------------------------------------------
CREATE SEQUENCE IF NOT EXISTS st_netset_seq START 800000;
CREATE TABLE IF NOT EXISTS st_net_settlement (
net_id BIGINT PRIMARY KEY,
scheme TEXT NOT NULL DEFAULT 'BC',
member_bin TEXT NOT NULL, -- 상대 회원사 BIN/ICA
biz_date DATE NOT NULL,
txn_count BIGINT NOT NULL DEFAULT 0,
gross_amt BIGINT NOT NULL DEFAULT 0,
interchange_amt BIGINT NOT NULL DEFAULT 0,
scheme_fee_amt BIGINT NOT NULL DEFAULT 0,
net_amt BIGINT NOT NULL DEFAULT 0, -- 실제 이체 순액(부호=방향)
direction TEXT NOT NULL DEFAULT 'RECEIVE', -- RECEIVE(수취)/PAY(지급)
status TEXT NOT NULL DEFAULT 'SETTLED',
settled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (scheme, member_bin, biz_date)
);
-- ---------------------------------------------------------------------------
-- 7. 롤링 리저브(유보금).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS st_reserve (
merchant_id TEXT NOT NULL,
biz_date DATE NOT NULL,
reserve_bps BIGINT NOT NULL DEFAULT 0, -- 유보율
reserve_amt BIGINT NOT NULL DEFAULT 0, -- 이번 회차 유보액
release_date DATE, -- 유보 해제 예정일(롤링)
released BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (merchant_id, biz_date)
);
-- ---------------------------------------------------------------------------
-- 8. 가맹점 펀딩 항등식: 총매출 환불 채그백 수수료 리저브 = 입금.
-- ---------------------------------------------------------------------------
CREATE SEQUENCE IF NOT EXISTS st_funding_seq START 900000;
CREATE TABLE IF NOT EXISTS st_merchant_funding (
funding_id BIGINT PRIMARY KEY,
merchant_id TEXT NOT NULL,
biz_date DATE NOT NULL,
gross_sales BIGINT NOT NULL DEFAULT 0,
refunds BIGINT NOT NULL DEFAULT 0,
chargebacks BIGINT NOT NULL DEFAULT 0,
fees BIGINT NOT NULL DEFAULT 0, -- MDR 합계
reserve BIGINT NOT NULL DEFAULT 0,
deposit BIGINT NOT NULL DEFAULT 0, -- 실제 입금액
funding_model TEXT NOT NULL DEFAULT 'NET_DAILY', -- NET_DAILY/MONTHLY_DISCOUNT
status TEXT NOT NULL DEFAULT 'FUNDED',
funded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (merchant_id, biz_date)
);
-- ---------------------------------------------------------------------------
-- 9. 채그백/분쟁 라이프사이클: 1차CB → representment → pre-arb → arbitration.
-- ---------------------------------------------------------------------------
CREATE SEQUENCE IF NOT EXISTS ac_dispute_seq START 600000;
CREATE TABLE IF NOT EXISTS ac_dispute (
dispute_id BIGINT PRIMARY KEY,
purchase_id BIGINT NOT NULL,
merchant_id TEXT NOT NULL,
reason_code TEXT NOT NULL, -- 10.4(사기) / 13.1(상품미수취) / 12.6(중복) ...
amount BIGINT NOT NULL,
stage TEXT NOT NULL DEFAULT 'FIRST_CB', -- FIRST_CB/REPRESENT/PRE_ARB/ARBITRATION
status TEXT NOT NULL DEFAULT 'OPEN', -- OPEN/WON/LOST/ACCEPTED
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
memo TEXT
);
CREATE INDEX IF NOT EXISTS ix_dispute_pid ON ac_dispute(purchase_id);
CREATE INDEX IF NOT EXISTS ix_present_pid ON cl_presentment(purchase_id);

View file

@ -0,0 +1,161 @@
-- ===========================================================================
-- 96-econ-seed.sql : 매입사 경제모델 시드 (70-econ.sql DDL 이후, 실측풍)
-- 요율표(interchange/scheme) + 회원사 은행 + 기존 정산건 소급 분해/클리어링/
-- 순액정산/펀딩. 규제 관행 반영: interchange 가 MDR 의 대부분(70~90%),
-- scheme fee 소액, markup 이 매입사 협상마진.
-- ===========================================================================
-- ---------------------------------------------------------------------------
-- 1. Interchange 요율표 — 카드종류×채널 차등 (interchange qualification)
-- 실제 규제 관행: 체크카드 < 신용카드, 대면(POS) < 비대면(EDI)/해외(FOREIGN).
-- ---------------------------------------------------------------------------
INSERT INTO st_interchange_rate (card_type, mcc, channel, txn_type, rate_bps, fixed_fee) VALUES
('CREDIT','DEFAULT','POS','SALE', 130, 0),
('CREDIT','DEFAULT','EDC','SALE', 140, 0),
('CREDIT','DEFAULT','EDI','SALE', 160, 0), -- 비대면 가산
('CREDIT','DEFAULT','FOREIGN','SALE', 180, 0), -- 해외 가산
('CHECK','DEFAULT','POS','SALE', 70, 0), -- 체크카드 저율
('CHECK','DEFAULT','EDC','SALE', 80, 0),
('CHECK','DEFAULT','EDI','SALE', 95, 0),
('CHECK','DEFAULT','FOREIGN','SALE', 110, 0),
('PREPAID','DEFAULT','POS','SALE', 60, 0),
('CREDIT','DEFAULT','DEFAULT','REFUND', 0, 0), -- 환불은 interchange 역방향(발급사→매입사), 여기선 0 처리
('CREDIT','DEFAULT','DEFAULT','CASH', 100, 0)
ON CONFLICT (card_type, mcc, channel, txn_type) DO NOTHING;
-- ---------------------------------------------------------------------------
-- 2. Scheme fee — 스킴 assessment (소액). BC=국내 표준.
-- ---------------------------------------------------------------------------
INSERT INTO st_scheme_fee (scheme, txn_type, rate_bps, fixed_fee) VALUES
('BC','SALE', 11, 0),
('VISA','SALE', 14, 0),
('MASTER','SALE',13, 0),
('LOCAL','SALE', 9, 0),
('BC','REFUND', 0, 0),
('BC','CASH', 8, 0)
ON CONFLICT (scheme, txn_type) DO NOTHING;
-- ---------------------------------------------------------------------------
-- 3. 회원사 은행 — 발급사↔매입사 다자 순액정산 참가자 (BC카드 컨소시엄형).
-- ---------------------------------------------------------------------------
INSERT INTO st_member_bank (bank_code, bank_name, bin, role) VALUES
('BC', 'BC카드(매입)', '940020','ACQUIRER'),
('KB', 'KB국민카드', '940021','ISSUER'),
('SHINHAN','신한카드', '940022','ISSUER'),
('HYUNDAI','현대카드', '940023','ISSUER'),
('SAMSUNG','삼성카드', '940024','ISSUER'),
('LOTTE', '롯데카드', '940025','ISSUER'),
('NH', 'NH농협카드', '940026','ISSUER'),
('HANA', '하나카드', '940027','ISSUER')
ON CONFLICT (bank_code) DO NOTHING;
-- ---------------------------------------------------------------------------
-- 4. 기존 정산완료 매입건 → 수수료 3층 분해 (소급).
-- 카드종류는 issuer 로 근사(BC=CHECK 30%, 그 외 CREDIT), 채널=purchase.channel.
-- interchange = amount×ic_bps, scheme = amount×scheme_bps,
-- markup = 기존 flat fee interchange scheme (음수면 0 clamp),
-- mdr_total = interchange+scheme+markup (기존 fee 와 근사 일치).
-- ---------------------------------------------------------------------------
INSERT INTO st_fee_component (purchase_id, amount, interchange, scheme_fee, markup, mdr_total, card_type, scheme)
SELECT p.purchase_id, p.amount,
(p.amount * ic.rate_bps / 10000) AS interchange,
(p.amount * sf.rate_bps / 10000) AS scheme_fee,
GREATEST(p.fee - (p.amount*ic.rate_bps/10000) - (p.amount*sf.rate_bps/10000), p.amount*8/10000) AS markup,
(p.amount*ic.rate_bps/10000) + (p.amount*sf.rate_bps/10000)
+ GREATEST(p.fee - (p.amount*ic.rate_bps/10000) - (p.amount*sf.rate_bps/10000), p.amount*8/10000) AS mdr_total,
ct.card_type,
CASE WHEN p.issuer IN ('BC') THEN 'BC' ELSE 'BC' END AS scheme
FROM purchase p
CROSS JOIN LATERAL (SELECT CASE WHEN (p.purchase_id % 10) < 3 THEN 'CHECK' ELSE 'CREDIT' END AS card_type) ct
JOIN st_interchange_rate ic
ON ic.card_type = ct.card_type AND ic.channel = p.channel AND ic.txn_type = 'SALE'
JOIN st_scheme_fee sf ON sf.scheme = 'BC' AND sf.txn_type = 'SALE'
WHERE p.status = 'S'
ON CONFLICT (purchase_id) DO NOTHING;
-- ---------------------------------------------------------------------------
-- 5. 프레젠트먼트(클리어링) — 정산완료건마다 1차 프레젠트먼트 기록.
-- clearing_amt = amount interchange (발급사 귀속분 차감 후 매입사 클리어링액).
-- ---------------------------------------------------------------------------
INSERT INTO cl_presentment (presentment_id, purchase_id, scheme, msg_class, clearing_amt, present_date, cycle, status)
SELECT nextval('cl_present_seq'), fc.purchase_id, fc.scheme, 'FIRST_PRESENT',
fc.amount - fc.interchange, p.biz_date, 'T+1', 'CLEARED'
FROM st_fee_component fc JOIN purchase p ON p.purchase_id = fc.purchase_id
WHERE NOT EXISTS (SELECT 1 FROM cl_presentment c
WHERE c.purchase_id = fc.purchase_id AND c.msg_class = 'FIRST_PRESENT');
-- ---------------------------------------------------------------------------
-- 6. 순액정산 배치 — 발급사(회원 BIN)×영업일 기준 차대변 합산 → 단일 순액.
-- 매입사(BC)가 각 발급사로부터 (매출interchangescheme) 순액을 수취.
-- ---------------------------------------------------------------------------
INSERT INTO st_net_settlement (net_id, scheme, member_bin, biz_date, txn_count,
gross_amt, interchange_amt, scheme_fee_amt, net_amt, direction, status)
SELECT nextval('st_netset_seq'), 'BC', mb.bin, p.biz_date, count(*),
sum(fc.amount), sum(fc.interchange), sum(fc.scheme_fee),
sum(fc.amount - fc.interchange - fc.scheme_fee), 'RECEIVE', 'SETTLED'
FROM st_fee_component fc
JOIN purchase p ON p.purchase_id = fc.purchase_id
JOIN st_member_bank mb ON mb.bank_code = p.issuer
GROUP BY mb.bin, p.biz_date
ON CONFLICT (scheme, member_bin, biz_date) DO NOTHING;
-- ---------------------------------------------------------------------------
-- 7. 롤링 리저브 — 위험업종(HOLD/PENDING 상태였던 가맹점 등) 8% 유보, 그 외 0.
-- 여기선 전 가맹점 일괄 저율(총매출의 0.5%) 유보 예시 + T+30 해제.
-- ---------------------------------------------------------------------------
INSERT INTO st_reserve (merchant_id, biz_date, reserve_bps, reserve_amt, release_date, released)
SELECT p.merchant_id, p.biz_date, 50, sum(p.amount)*50/10000,
p.biz_date + 30, (p.biz_date + 30 < current_date)
FROM purchase p WHERE p.status='S'
GROUP BY p.merchant_id, p.biz_date
ON CONFLICT (merchant_id, biz_date) DO NOTHING;
-- ---------------------------------------------------------------------------
-- 8. 가맹점 펀딩 항등식: 총매출 환불 채그백 수수료 리저브 = 입금.
-- 환불=취소(C)건 금액, 채그백=아래 8단계 시드분, 수수료=MDR합계.
-- ---------------------------------------------------------------------------
INSERT INTO st_merchant_funding (funding_id, merchant_id, biz_date, gross_sales, refunds,
chargebacks, fees, reserve, deposit, funding_model, status)
SELECT nextval('st_funding_seq'), s.merchant_id, s.biz_date,
s.gross, s.refunds, s.cb, s.fees, coalesce(rv.reserve_amt,0),
s.gross - s.refunds - s.cb - s.fees - coalesce(rv.reserve_amt,0),
'NET_DAILY', 'FUNDED'
FROM (
SELECT p.merchant_id, p.biz_date,
sum(CASE WHEN p.status='S' THEN p.amount ELSE 0 END) AS gross,
sum(CASE WHEN p.status='C' THEN p.amount ELSE 0 END) AS refunds,
0::bigint AS cb,
coalesce(sum(fc.mdr_total),0) AS fees
FROM purchase p LEFT JOIN st_fee_component fc ON fc.purchase_id=p.purchase_id
GROUP BY p.merchant_id, p.biz_date
) s
LEFT JOIN st_reserve rv ON rv.merchant_id=s.merchant_id AND rv.biz_date=s.biz_date
WHERE s.gross > 0
ON CONFLICT (merchant_id, biz_date) DO NOTHING;
-- ---------------------------------------------------------------------------
-- 9. 채그백/분쟁 케이스 — 취소·보류였던 매입 일부를 분쟁으로 (reason code 체계).
-- 라이프사이클 단계 혼재: FIRST_CB / REPRESENT / PRE_ARB / ARBITRATION.
-- ---------------------------------------------------------------------------
INSERT INTO ac_dispute (dispute_id, purchase_id, merchant_id, reason_code, amount, stage, status, opened_at, resolved_at, memo)
SELECT nextval('ac_dispute_seq'), p.purchase_id, p.merchant_id,
(ARRAY['10.4','13.1','12.6','13.7','4853'])[1 + (p.purchase_id % 5)],
p.amount,
(ARRAY['FIRST_CB','REPRESENT','PRE_ARB','ARBITRATION'])[1 + (p.purchase_id % 4)],
(ARRAY['OPEN','WON','LOST','ACCEPTED'])[1 + (p.purchase_id % 4)],
p.biz_date::timestamptz + interval '2 days',
CASE WHEN (p.purchase_id % 4) IN (1,2,3) THEN p.biz_date::timestamptz + interval '20 days' ELSE NULL END,
'분쟁 시드: reason ' || (ARRAY['사기','상품미수취','중복청구','품질불량','거래취소'])[1 + (p.purchase_id % 5)]
FROM purchase p
WHERE p.status IN ('C','H') AND (p.purchase_id % 3) = 0
AND NOT EXISTS (SELECT 1 FROM ac_dispute d WHERE d.purchase_id = p.purchase_id);
-- 펀딩의 chargebacks 컬럼을 실제 분쟁(LOST 확정분)으로 보정.
-- 채그백은 원매입의 영업일(biz_date)에 귀속시켜 가맹점×일자 단위로만 차감한다
-- (가맹점 전체 합을 매일 중복 차감하면 입금액이 일수만큼 붕괴한다).
UPDATE st_merchant_funding f SET chargebacks = d.cb,
deposit = f.gross_sales - f.refunds - d.cb - f.fees - f.reserve
FROM (SELECT p.merchant_id, p.biz_date, sum(dp.amount) cb
FROM ac_dispute dp JOIN purchase p ON p.purchase_id = dp.purchase_id
WHERE dp.status = 'LOST' GROUP BY p.merchant_id, p.biz_date) d
WHERE f.merchant_id = d.merchant_id AND f.biz_date = d.biz_date;

45
docs/GAP_ANALYSIS.md Normal file
View file

@ -0,0 +1,45 @@
# 실제 매입시스템 vs acquire-core-x — 갭 분석
> 딥리서치(1차 규제·표준 출처 기반, 검증된 9개 finding)로 확인한 **실제 카드 매입사
> 아키텍처**를 우리 구현과 대조한다. 목적: 리얼리티 갭을 찾아 고도화 우선순위를 정한다.
> 출처: OCC Merchant Processing Handbook, Fed Philadelphia clearing&settlement paper,
> Berlin Group ISO 8583, Marqeta/Checkout/Stripe, BC Card.
## 핵심 대조 (검증된 실제 메커니즘 → 우리 현황)
| # | 실제 매입시스템 메커니즘 (검증됨) | 우리 현황 | 갭 |
|---|---|---|---|
| 1 | **Clearing ≠ Settlement.** 승인은 권한/홀드일 뿐 돈이 안 움직인다. 클리어링=상세 거래데이터 교환(발급사↔매입사 채무 확정), 정산=그 채무를 실제 자금이동으로 해소. | 승인(au)·정산(st)은 있으나 **클리어링(프레젠트먼트) 단계가 통째로 없음**. 정산이 곧 자금이동으로 뭉뚱그려짐. | 🔴 **대** |
| 2 | **이중메시지 vs 단일메시지.** 신용/서명체크=승인과 클리어링 분리(EDC 배치 캡처 후 일 1회+ 제출, 프레젠트먼트 윈도우). PIN/ATM=승인+클리어링 1회 통합. | 채널(POS/EDC/EDI/FOREIGN)은 있으나 **이중/단일 구분·프레젠트먼트 윈도우 없음**. | 🟠 중 |
| 3 | **ISO 8583 클리어링 메시지 클래스.** presentment / chargeback / fee-collection / reconciliation / file-rejection 이 별도 기능. | mg 모듈에 채널·라우팅·큐는 있으나 **클리어링 메시지 클래스 모델 없음**. MTI 언급은 있음. | 🟠 중 |
| 4 | **BIN/ICA 기준 순액정산.** 정산은 매입사 스킴 회원식별자(Visa=BIN, MC=ICA)로 라우팅, 은행별 차대변 합산 후 **단일 순액**을 정산은행 통해 이체. | 정산이 **가맹점 단위**로만 계산됨. **발급사↔매입사 순액정산(net settlement) 계층 없음**. | 🔴 **대** |
| 5 | **수수료 3층 분해.** MDR = interchange(발급사, 최대·일단위 순정산시 징수) + scheme fee(카드망) + acquirer markup(매입사 마진, 유일한 협상대상, 월청구). 요율은 카드종류·MCC·거래유형·채널·사기율에 따라 변동. | **flat MDR 하나**(`fee = amount × mdr_bps`). interchange·scheme fee·markup 분해 **전무**. | 🔴 **대(최우선)** |
| 6 | **Interchange qualification.** 데이터 품질/채널/카드종류로 interchange 등급(tier)이 갈림. | mm_fee_rate 가 merchant×card_type 단일요율. **MCC·채널·등급 차등 없음**. | 🟠 중 |
| 7 | **정산·펀딩 항등식.** 총매출 환불 채그백 처리수수료 롤링리저브 = 예상 입금액. | 정산은 net 만. **환불·채그백·리저브를 반영한 펀딩 계산 없음**. | 🔴 **대** |
| 8 | **채그백/분쟁 라이프사이클.** 1차 채그백 → representment(재청구) → pre-arbitration → arbitration, reason code 체계. | chargeback/dispute 언급 일부(테이블 없음), **representment·중재 단계 없음**. | 🟠 중 |
| 9 | **한국 BC카드형 다자간 순액정산.** 회원은행 간(카드소지자 은행↔가맹점 은행) 다자간 순액정산 = 공유 클리어링/정산망(밴사/VAN). | 발급사(issuer) 값은 있으나 **회원사간 다자정산 계층 없음**. | 🟠 중 |
## 고도화 우선순위 (리얼리티 임팩트 순)
**🔴 1순위 — 수수료 3층 분해 + 정산 항등식 (매입사 경제모델의 핵심)**
- interchange(발급사 귀속) / scheme fee(카드망) / acquirer markup(매입사 마진) 분리
- 요율표: 카드종류 × MCC × 채널 × 거래유형 차등 (interchange qualification)
- 정산 항등식: 총매출 환불 채그백 수수료 리저브 = 가맹점 입금
- 이게 있어야 "이 시스템이 실제로 돈을 어떻게 나누는지" 설명됨
**🔴 2순위 — 클리어링 ≠ 정산 분리 + 순액정산**
- 승인 → 캡처 → **프레젠트먼트(클리어링)****순액정산** → 가맹점 펀딩 을 별도 단계로
- 발급사↔매입사 순액정산(net settlement), BIN/ICA 라우팅
- 한국형: 회원사간 다자 순액정산(밴사/VAN)
**🟠 3순위 — 채그백/분쟁 라이프사이클 + ISO 8583 클리어링 메시지**
- dispute 상태기계: 1차CB → representment → pre-arb → arbitration + reason code
- mg에 클리어링 메시지 클래스(presentment/chargeback/fee-collection/recon/reject)
## 설계 방침
- **기존 구조를 깨지 않는다.** 신규 테이블·서비스를 추가하고, 기존 flat MDR 는 markup 으로
재해석해 하위호환. 6단 XA 체인·477 tpcall 체인은 유지.
- **오너-라이츠·XA 브랜치 격리 규율** 그대로 적용 (Wave F 교훈).
- **실측 데이터로 시드**: interchange 요율표(카드종류×MCC×채널), 프레젠트먼트/클리어링 이력,
채그백 케이스, 순액정산 배치 결과.