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: */