feat(momo v0.6): 전자세금계산서 발행 모듈 골격 (별도 메뉴 + 국세청 직접 연동 준비)
Deploy momo-erp / deploy (push) Successful in 51s

[정책]
- 발주/출고/입금 흐름과 분리된 별도 메뉴 (월말 일괄 또는 신고 시점 발행 가능)
- 출고 시 자동 발행은 향후 토글 옵션으로 추가

[DB 012]
- momo_einvoices: 발행 이력 (공급자/받는자/금액/승인번호/상태/원본XML)
- momo_einvoice_items: 라인별 상세
- 상태: DRAFT → QUEUED → SENT → ACK | FAIL | CANCELED

[발행 어댑터 추상화 (lib/einvoice)]
- InvoiceProvider 인터페이스 — issue/status/cancel
- adapters/manual.ts: 자체 거래명세서 (국세청 전송 X, 기본)
- adapters/nts-esero.ts: 국세청 e-세로 직접 연동 골격
  · NTS_ESERO_MODE: stub | test | prod
  · stub 모드는 DB 기록만 (개발/CI 안전)
  · 실 통신은 사업자 공동인증서 + ERP 연계 승인 후 활성화
  · SOAP/XMLDSig 페이로드 빌더 골격 작성, 인증서 받으면 서명+전송 추가
- index.ts: EINVOICE_PROVIDER 환경변수로 어댑터 선택

[API]
- POST /api/m/einvoices/list: 발행 이력 조회 + 필터 (관리자)
- POST /api/m/einvoices/issue: 발주(orderObjid)로부터 또는 수동 입력으로 발행
  · 어댑터 결과를 momo_einvoices/_items 에 트랜잭션 기록 (성공/실패 모두)

[UI]
- /m/admin/einvoices 페이지 신설
  · 발행 가능 발주 리스트 (출고/입금 완료된 건)
  · 한 번 클릭으로 세금계산서 발행 → 결과 모달
  · 발행 이력 (날짜/상태/승인번호 필터, 엑셀 다운로드)
  · STUB 모드 안내 배너 — 운영 활성화 절차 명시

[문서]
- docs/MOMO_DISTRIBUTION_SPEC.md 부록 B (v0.6) 추가

다음 단계 (인증서 + ERP 연계 승인 후):
- nts-esero.ts 의 SOAP + XMLDSig 실제 구현
- NTS_ESERO_MODE=test 로 100건 검증
- NTS_ESERO_MODE=prod 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-07 16:14:02 +09:00
parent a336191153
commit 7e764d500e
9 changed files with 885 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
-- 012_einvoices.sql
-- v0.6 (2026-05-07)
-- 전자세금계산서 발행 이력 테이블 — 국세청 e-세로 직접 연동 + 향후 다른 어댑터 호환
BEGIN;
CREATE TABLE IF NOT EXISTS momo_einvoices (
objid TEXT PRIMARY KEY,
order_objid TEXT, -- 연결된 발주(있으면)
customer_objid TEXT NOT NULL, -- user_info.user_id (공급받는자)
invoice_kind VARCHAR(20) NOT NULL DEFAULT 'TAX', -- TAX(세금계산서) / TAXFREE(계산서) / RECEIPT(영수)
invoice_type VARCHAR(20) NOT NULL DEFAULT 'NORMAL', -- NORMAL / MODIFIED(수정) / CANCELED(취소)
issue_method VARCHAR(20) NOT NULL DEFAULT 'NTS', -- NTS(국세청 직접) / POPBILL / MANUAL
-- 공급자
supplier_biz_no VARCHAR(20),
supplier_name VARCHAR(200),
supplier_ceo VARCHAR(100),
supplier_address TEXT,
supplier_business VARCHAR(100), -- 업태
supplier_item VARCHAR(100), -- 종목
-- 공급받는자
buyer_biz_no VARCHAR(20),
buyer_name VARCHAR(200),
buyer_ceo VARCHAR(100),
buyer_address TEXT,
buyer_email VARCHAR(200),
buyer_phone VARCHAR(50),
-- 금액
total_supply NUMERIC(15,2) NOT NULL DEFAULT 0,
total_vat NUMERIC(15,2) NOT NULL DEFAULT 0,
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
-- 국세청 식별자
nts_invoice_no VARCHAR(40), -- 국세청 승인번호 (24자리)
nts_response_code VARCHAR(10), -- 응답코드
nts_response_msg TEXT,
nts_sent_at TIMESTAMP, -- 국세청 전송 시각
nts_acknowledged CHAR(1) DEFAULT 'N', -- 승인 여부
-- 상태
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
-- DRAFT(작성중) / QUEUED(전송대기) / SENT(전송완료) / ACK(승인완료) / FAIL(실패) / CANCELED(취소)
issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
-- 원본 XML / 응답 (디버깅용)
request_xml TEXT,
response_xml TEXT,
-- 메타
memo TEXT,
is_del CHAR(1) DEFAULT 'N',
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT,
update_date TIMESTAMP,
update_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_order ON momo_einvoices(order_objid);
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_buyer ON momo_einvoices(customer_objid, issue_date);
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_status ON momo_einvoices(status, issue_date);
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_nts ON momo_einvoices(nts_invoice_no);
CREATE TABLE IF NOT EXISTS momo_einvoice_items (
objid TEXT PRIMARY KEY,
einvoice_objid TEXT NOT NULL REFERENCES momo_einvoices(objid) ON DELETE CASCADE,
seq INT NOT NULL,
item_date DATE,
item_name VARCHAR(200) NOT NULL,
spec VARCHAR(100),
qty NUMERIC(15,2),
unit_price NUMERIC(15,2),
supply_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
remark VARCHAR(200)
);
CREATE INDEX IF NOT EXISTS idx_momo_einvoice_items ON momo_einvoice_items(einvoice_objid, seq);
COMMIT;
+60
View File
@@ -507,3 +507,63 @@ db/migrations/
| 관리자 (ADMIN) | 전체 표시 | 별도 발주는 없음 (관리자는 승인자) |
→ 권한 검증은 백엔드(`/api/m/items/list`, `/api/m/orders/save`)에서 강제. 프론트는 시각 표시만.
---
## 부록 B — v0.6 전자세금계산서 발행 (2026-05-07)
### B.1 정책
- **발주/출고/입금 흐름과 분리된 별도 메뉴** (`/m/admin/einvoices`)
- 출고/입금 완료 후 **수동 발행** — 월말 일괄 또는 부가세 신고 시점 일괄 가능하도록
- 향후 "출고 시 자동 발행" 옵션은 토글로 추가 예정 (지금은 명시적 발행만)
### B.2 발행 어댑터 (3종)
```
[발행 인터페이스] InvoiceProvider
├─ adapters/manual.ts (자체 거래명세서, 국세청 전송 X — 기본)
├─ adapters/nts-esero.ts (국세청 e-세로 직접 연동) ← 최종 목표
└─ adapters/popbill.ts (Popbill REST API, 향후 추가)
```
- `EINVOICE_PROVIDER` 환경변수로 선택 (`manual` | `nts` | `popbill`)
- 어댑터 교체만으로 비즈니스 로직 영향 없음
### B.3 국세청 e-세로 직접 연동 (어댑터 C)
- **장점**: 발행 비용 0원 (인증서 연 5만원만), ERP 패키지 판매 시 차별화
- **필요 조건**:
1. 사업자용 공동인증서 (`.pfx` + 비밀번호)
2. 홈택스 → 전자세금계산서 ERP 연계 신청 승인 (1~3일)
3. 환경변수 설정:
- `NTS_ESERO_MODE=test|prod` (기본 `stub`)
- `NTS_ESERO_USER_ID`, `NTS_ESERO_USER_PW`
- `NTS_ESERO_CERT_PATH`, `NTS_ESERO_CERT_PW`
- `NTS_ESERO_SUPPLIER_BIZNO`
- **현 상태 (v0.6)**: SOAP 페이로드 빌더 + 응답 처리 골격, **stub 모드**로 동작 (DB 기록만, 국세청 전송 X). XMLDSig 디지털 서명 + 실제 국세청 통신은 인증서 받은 후 추가 구현.
### B.4 DB 스키마 (마이그레이션 012)
| 테이블 | 역할 |
|---|---|
| `momo_einvoices` | 발행 이력 (공급자/받는자/금액/승인번호/상태/원본 XML) |
| `momo_einvoice_items` | 라인별 상세 (품목/공급가/세액) |
상태 머신: `DRAFT → QUEUED → SENT → ACK | FAIL | CANCELED`
### B.5 화면
| 경로 | 역할 |
|---|---|
| `/m/admin/einvoices` | 발행 가능 발주 리스트 + 발행 이력 + 엑셀 다운로드 |
| (예정) `/m/admin/einvoices/new` | 수동 발행 폼 (발주 없이도 발행 가능) |
### B.6 API
| 엔드포인트 | 역할 |
|---|---|
| `POST /api/m/einvoices/list` | 발행 이력 조회 (관리자) |
| `POST /api/m/einvoices/issue` | 발행 요청 (orderObjid 또는 수동 입력) |
| (예정) `POST /api/m/einvoices/cancel` | 승인 후 취소 |
| (예정) `POST /api/m/einvoices/status` | 국세청 상태 재조회 |
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { FileText, Send, Download, RefreshCcw, AlertCircle } from "lucide-react";
import Swal from "sweetalert2";
import { downloadXlsx } from "@/lib/xlsx-export";
interface Einvoice {
OBJID: string;
ORDER_OBJID: string | null;
CUSTOMER_OBJID: string;
BUYER_NAME: string;
INVOICE_KIND: "TAX" | "TAXFREE";
INVOICE_TYPE: "NORMAL" | "MODIFIED" | "CANCELED";
ISSUE_METHOD: "NTS" | "POPBILL" | "MANUAL";
ISSUE_DATE: string;
TOTAL_SUPPLY: number;
TOTAL_VAT: number;
TOTAL_AMOUNT: number;
NTS_INVOICE_NO: string | null;
NTS_RESPONSE_CODE: string | null;
NTS_RESPONSE_MSG: string | null;
STATUS: string;
REGDATE: string;
}
interface PendingOrder {
OBJID: string;
ORDER_NO: string;
ORDER_DATE: string;
COMPANY_NAME: string;
TOTAL_AMOUNT: number;
STATUS: string;
}
const fmt = (n: number | string | null | undefined) => Number(n || 0).toLocaleString("ko-KR");
const STATUS_LABEL: Record<string, string> = {
DRAFT: "작성중", QUEUED: "전송대기", SENT: "전송완료", ACK: "승인완료", FAIL: "실패", CANCELED: "취소",
};
const STATUS_COLOR: Record<string, string> = {
DRAFT: "bg-slate-100 text-slate-600",
QUEUED: "bg-amber-100 text-amber-700",
SENT: "bg-blue-100 text-blue-700",
ACK: "bg-emerald-100 text-emerald-700",
FAIL: "bg-rose-100 text-rose-700",
CANCELED:"bg-slate-100 text-slate-500",
};
function defaultRange() {
const e = new Date(), s = new Date();
s.setDate(s.getDate() - 30);
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
}
export default function EinvoicesPage() {
const [[from, to], setRange] = useState(defaultRange());
const [statusFilter, setStatusFilter] = useState("");
const [list, setList] = useState<Einvoice[]>([]);
const [pending, setPending] = useState<PendingOrder[]>([]);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const res = await fetch("/api/m/einvoices/list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom: from, dateTo: to, status: statusFilter || undefined }),
});
setList((await res.json()).RESULTLIST ?? []);
}, [from, to, statusFilter]);
const loadPending = useCallback(async () => {
// 출고완료 또는 입금완료된 발주 중 아직 세금계산서 미발행 건 조회 (간단화: orders/list)
const res = await fetch("/api/m/orders/list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const j = await res.json();
const all: PendingOrder[] = (j.RESULTLIST ?? []).filter((o: PendingOrder) =>
["APPROVED", "SHIPPED", "PAID"].includes(o.STATUS)
);
setPending(all);
}, []);
useEffect(() => { load(); loadPending(); }, [load, loadPending]);
const issueFromOrder = async (orderObjid: string, kind: "TAX" | "TAXFREE" = "TAX") => {
const ok = await Swal.fire({
icon: "question",
title: `${kind === "TAX" ? "세금계산서" : "계산서(면세)"} 발행`,
text: "선택한 발주에 대해 전자세금계산서를 발행하시겠습니까?",
showCancelButton: true,
confirmButtonText: "발행",
cancelButtonText: "취소",
confirmButtonColor: "#0f766e",
});
if (!ok.isConfirmed) return;
setBusy(true);
try {
const res = await fetch("/api/m/einvoices/issue", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid, invoiceKind: kind, invoiceType: "NORMAL" }),
});
const j = await res.json();
if (j.success) {
await Swal.fire({
icon: "success",
title: "발행 완료",
html: `
<div class="text-sm">
<div>승인번호: <b>${j.ntsInvoiceNo ?? "-"}</b></div>
<div>상태: ${j.status}</div>
<div>처리방식: ${j.provider}</div>
${j.message ? `<div class="text-xs text-slate-500 mt-2">${j.message}</div>` : ""}
</div>`,
});
load();
} else {
Swal.fire({ icon: "error", title: "발행 실패", text: j.message ?? "오류" });
}
} finally { setBusy(false); }
};
const onExport = () => {
if (list.length === 0) return;
downloadXlsx("전자세금계산서_발행이력", list, [
{ header: "발행일", key: "ISSUE_DATE", width: 12 },
{ header: "거래처", key: "BUYER_NAME", width: 24 },
{ header: "구분", key: (r) => r.INVOICE_KIND === "TAX" ? "세금계산서" : "계산서", width: 12 },
{ header: "유형", key: (r) => r.INVOICE_TYPE, width: 10 },
{ header: "처리방식", key: "ISSUE_METHOD", width: 10 },
{ header: "공급가", key: (r) => Number(r.TOTAL_SUPPLY), width: 14 },
{ header: "세액", key: (r) => Number(r.TOTAL_VAT), width: 12 },
{ header: "합계", key: (r) => Number(r.TOTAL_AMOUNT), width: 14 },
{ header: "승인번호", key: "NTS_INVOICE_NO", width: 30 },
{ header: "상태", key: (r) => STATUS_LABEL[r.STATUS] ?? r.STATUS, width: 10 },
]);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1>
<p className="text-xs text-slate-500 mt-0.5">/ () .</p>
</div>
<div className="flex gap-2">
<button onClick={() => { load(); loadPending(); }} className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg border border-slate-200 text-xs font-bold hover:bg-slate-50">
<RefreshCcw size={14} />
</button>
<button onClick={onExport} disabled={list.length === 0}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
<Download size={14} />
</button>
</div>
</div>
{/* 안내 배너 — STUB 모드 */}
<div className="border border-amber-200 bg-amber-50 rounded-lg p-3 flex items-start gap-2 text-xs text-amber-900">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<div>
<b> </b> DB .
(1) , (2) ERP , (3) <code>NTS_ESERO_MODE</code> <code>test</code>/<code>prod</code> .
</div>
</div>
{/* 발행 가능 발주 (출고/입금 완료) */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-4 py-2.5 bg-slate-50 border-b border-slate-200 text-sm font-bold text-slate-700 flex items-center gap-2">
<FileText size={14} /> ({pending.length})
<span className="text-xs font-normal text-slate-500"> / </span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs min-w-[600px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-center px-3 py-2"></th>
<th className="text-right px-3 py-2 w-[180px]"></th>
</tr>
</thead>
<tbody>
{pending.length === 0 ? (
<tr><td colSpan={6} className="text-center py-8 text-slate-400"> .</td></tr>
) : pending.map((o) => (
<tr key={o.OBJID} className="border-t border-slate-100">
<td className="px-3 py-2 font-semibold">{o.ORDER_NO}</td>
<td className="px-3 py-2">{o.ORDER_DATE}</td>
<td className="px-3 py-2">{o.COMPANY_NAME}</td>
<td className="px-3 py-2 text-right tabular-nums font-bold">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-3 py-2 text-center text-[11px] text-slate-500">{o.STATUS}</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => issueFromOrder(o.OBJID, "TAX")}
disabled={busy}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-emerald-700 text-white text-[11px] font-bold hover:bg-emerald-800 disabled:opacity-50"
>
<Send size={11} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 발행 이력 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-4 py-2.5 bg-slate-50 border-b border-slate-200 text-sm font-bold text-slate-700 flex flex-wrap items-center gap-2 justify-between">
<span> ({list.length})</span>
<div className="flex gap-2 items-center text-xs font-normal">
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
className="h-8 px-2 rounded border border-slate-200" />
<span className="text-slate-400">~</span>
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
className="h-8 px-2 rounded border border-slate-200" />
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="h-8 px-2 rounded border border-slate-200 bg-white">
<option value=""> </option>
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button onClick={load} className="h-8 px-3 rounded bg-slate-800 text-white text-xs font-semibold"></button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs min-w-[800px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-center px-3 py-2"></th>
<th className="text-center px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-right px-3 py-2"></th>
<th className="text-left px-3 py-2"></th>
<th className="text-center px-3 py-2"></th>
</tr>
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={9} className="text-center py-8 text-slate-400"> .</td></tr>
) : list.map((e) => (
<tr key={e.OBJID} className="border-t border-slate-100">
<td className="px-3 py-2">{e.ISSUE_DATE}</td>
<td className="px-3 py-2 font-semibold">{e.BUYER_NAME}</td>
<td className="px-3 py-2 text-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${e.INVOICE_KIND === "TAX" ? "bg-rose-100 text-rose-700" : "bg-violet-100 text-violet-700"}`}>
{e.INVOICE_KIND === "TAX" ? "세금" : "면세"}
</span>
</td>
<td className="px-3 py-2 text-center text-[11px] text-slate-500">{e.ISSUE_METHOD}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmt(e.TOTAL_SUPPLY)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmt(e.TOTAL_VAT)}</td>
<td className="px-3 py-2 text-right tabular-nums font-bold">{fmt(e.TOTAL_AMOUNT)}</td>
<td className="px-3 py-2 font-mono text-[10px] text-slate-600">{e.NTS_INVOICE_NO || "-"}</td>
<td className="px-3 py-2 text-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[e.STATUS] ?? "bg-slate-100 text-slate-500"}`}>
{STATUS_LABEL[e.STATUS] ?? e.STATUS}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
// 전자세금계산서 발행 — 발주(orderObjid)로부터 또는 수동 입력으로
// 관리자 전용. 발행 후 momo_einvoices + momo_einvoice_items 에 이력 기록.
import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { getProvider, PROVIDER_NAME } from "@/lib/einvoice";
import type { InvoiceLine, InvoiceParty, InvoiceRequest } from "@/lib/einvoice";
interface IssueBody {
orderObjid?: string; // 있으면 자동 채움
invoiceKind?: "TAX" | "TAXFREE";
invoiceType?: "NORMAL" | "MODIFIED" | "CANCELED";
issueDate?: string; // YYYY-MM-DD
buyer?: Partial<InvoiceParty>;
lines?: InvoiceLine[];
memo?: string;
// 수정/취소 시 원본 승인번호
originNtsInvoiceNo?: string;
}
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const userId = g.user.objid || g.user.userId;
const body: IssueBody = await req.json().catch(() => ({}));
const today = new Date().toISOString().slice(0, 10);
const supplier: InvoiceParty = {
bizNo: process.env.NTS_ESERO_SUPPLIER_BIZNO || "0000000000",
name: process.env.MOMO_COMPANY_NAME || "모모유통",
ceoName: process.env.MOMO_COMPANY_CEO || "한신숙",
address: process.env.MOMO_COMPANY_ADDR || "",
business: "도매",
item: "식품 유통",
};
let buyer: InvoiceParty;
let lines: InvoiceLine[];
let totalSupply = 0, totalVat = 0, totalAmount = 0;
let customerObjid: string | null = null;
let orderObjid: string | null = body.orderObjid ?? null;
const invoiceKind = body.invoiceKind ?? "TAX";
if (orderObjid) {
// 발주로부터 자동 채움
const r = await pool.query(
`SELECT
O.customer_objid, O.order_date, O.total_supply, O.total_vat, O.total_amount,
U.user_name, U.email, U.cell_phone, U.biz_no, U.ceo_name, U.address
FROM momo_orders O
LEFT JOIN user_info U ON U.user_id = O.customer_objid
WHERE O.objid = $1 AND COALESCE(O.is_del,'N') != 'Y'`,
[orderObjid]
);
if (r.rowCount === 0) {
return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
}
const o = r.rows[0];
customerObjid = o.customer_objid as string;
buyer = {
bizNo: o.biz_no || "0000000000",
name: o.user_name || customerObjid,
ceoName: o.ceo_name || undefined,
address: o.address || undefined,
email: o.email || undefined,
phone: o.cell_phone || undefined,
};
totalSupply = Number(o.total_supply);
totalVat = Number(o.total_vat);
totalAmount = Number(o.total_amount);
const itemsRes = await pool.query(
`SELECT seq, item_name_snap, unit_price, qty, supply_amount, vat_amount,
COALESCE(kind,'ITEM') AS kind, extra_label
FROM momo_order_items WHERE order_objid = $1 ORDER BY seq`,
[orderObjid]
);
lines = itemsRes.rows.map((row, idx) => ({
seq: idx + 1,
itemDate: o.order_date as string,
itemName: row.kind === "ITEM" ? row.item_name_snap : (row.extra_label || row.item_name_snap),
qty: Number(row.qty),
unitPrice: Number(row.unit_price),
supplyAmount: Number(row.supply_amount),
vatAmount: Number(row.vat_amount),
}));
} else {
// 수동 발행 — body.buyer + body.lines 필수
if (!body.buyer?.bizNo || !body.buyer?.name) {
return NextResponse.json({ success: false, message: "공급받는자 정보가 부족합니다." }, { status: 400 });
}
if (!Array.isArray(body.lines) || body.lines.length === 0) {
return NextResponse.json({ success: false, message: "라인을 1개 이상 입력하세요." }, { status: 400 });
}
buyer = body.buyer as InvoiceParty;
lines = body.lines.map((l, i) => ({ ...l, seq: l.seq ?? i + 1 }));
for (const ln of lines) {
totalSupply += Number(ln.supplyAmount) || 0;
totalVat += Number(ln.vatAmount) || 0;
}
totalAmount = totalSupply + totalVat;
}
const invoiceReq: InvoiceRequest = {
kind: invoiceKind,
type: body.invoiceType ?? "NORMAL",
issueDate: body.issueDate ?? today,
supplier,
buyer,
lines,
totalSupply,
totalVat: invoiceKind === "TAXFREE" ? 0 : totalVat,
totalAmount: invoiceKind === "TAXFREE" ? totalSupply : totalAmount,
memo: body.memo,
originNtsInvoiceNo: body.originNtsInvoiceNo,
};
const provider = getProvider();
const result = await provider.issue(invoiceReq);
// 이력 저장 (성공/실패 모두)
const objid = createObjectId();
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
`INSERT INTO momo_einvoices (
objid, order_objid, customer_objid, invoice_kind, invoice_type, issue_method,
supplier_biz_no, supplier_name, supplier_ceo, supplier_address,
supplier_business, supplier_item,
buyer_biz_no, buyer_name, buyer_ceo, buyer_address, buyer_email, buyer_phone,
total_supply, total_vat, total_amount,
nts_invoice_no, nts_response_code, nts_response_msg, nts_sent_at, nts_acknowledged,
status, issue_date, request_xml, response_xml,
memo, regdate, regid
) VALUES (
$1,$2,$3,$4,$5,$6,
$7,$8,$9,$10,
$11,$12,
$13,$14,$15,$16,$17,$18,
$19,$20,$21,
$22,$23,$24, NOW(), $25,
$26,$27,$28,$29,
$30, NOW(), $31
)`,
[objid, orderObjid, customerObjid || buyer.bizNo,
invoiceReq.kind, invoiceReq.type, provider.method,
supplier.bizNo, supplier.name, supplier.ceoName ?? null, supplier.address ?? null,
supplier.business ?? null, supplier.item ?? null,
buyer.bizNo, buyer.name, buyer.ceoName ?? null, buyer.address ?? null,
buyer.email ?? null, buyer.phone ?? null,
invoiceReq.totalSupply, invoiceReq.totalVat, invoiceReq.totalAmount,
result.ntsInvoiceNo ?? null, result.responseCode ?? null, result.responseMessage ?? null,
result.success ? "Y" : "N",
result.status, invoiceReq.issueDate,
result.rawRequest ?? null, result.rawResponse ?? null,
body.memo ?? null, userId]
);
for (const ln of invoiceReq.lines) {
await client.query(
`INSERT INTO momo_einvoice_items (
objid, einvoice_objid, seq, item_date, item_name, spec, qty, unit_price,
supply_amount, vat_amount, remark
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[createObjectId(), objid, ln.seq, ln.itemDate ?? null,
ln.itemName, ln.spec ?? null, ln.qty ?? null, ln.unitPrice ?? null,
ln.supplyAmount, ln.vatAmount, ln.remark ?? null]
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
console.error("[einvoice/issue] DB 기록 실패:", err);
return NextResponse.json({
success: false,
message: "발행 결과 저장 중 오류가 발생했습니다.",
providerResult: result,
}, { status: 500 });
} finally {
client.release();
}
return NextResponse.json({
success: result.success,
objid,
provider: PROVIDER_NAME,
status: result.status,
ntsInvoiceNo: result.ntsInvoiceNo,
responseCode: result.responseCode,
message: result.responseMessage,
});
}
+50
View File
@@ -0,0 +1,50 @@
// 전자세금계산서 발행 이력 — 관리자 전용
import { NextRequest, NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const body = await req.json().catch(() => ({}));
const { dateFrom, dateTo, status, customerObjid } = body as {
dateFrom?: string; dateTo?: string; status?: string; customerObjid?: string;
};
const cond: string[] = ["COALESCE(E.is_del, 'N') != 'Y'"];
const params: unknown[] = [];
let i = 1;
if (dateFrom) { cond.push(`E.issue_date >= $${i++}::date`); params.push(dateFrom); }
if (dateTo) { cond.push(`E.issue_date <= $${i++}::date`); params.push(dateTo); }
if (status) { cond.push(`E.status = $${i++}`); params.push(status); }
if (customerObjid) { cond.push(`E.customer_objid = $${i++}`); params.push(customerObjid); }
const rows = await queryRows(
`SELECT
E.objid AS "OBJID",
E.order_objid AS "ORDER_OBJID",
E.customer_objid AS "CUSTOMER_OBJID",
U.user_name AS "BUYER_NAME",
E.invoice_kind AS "INVOICE_KIND",
E.invoice_type AS "INVOICE_TYPE",
E.issue_method AS "ISSUE_METHOD",
TO_CHAR(E.issue_date, 'YYYY-MM-DD') AS "ISSUE_DATE",
E.total_supply AS "TOTAL_SUPPLY",
E.total_vat AS "TOTAL_VAT",
E.total_amount AS "TOTAL_AMOUNT",
E.nts_invoice_no AS "NTS_INVOICE_NO",
E.nts_response_code AS "NTS_RESPONSE_CODE",
E.nts_response_msg AS "NTS_RESPONSE_MSG",
E.status AS "STATUS",
TO_CHAR(E.regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE"
FROM momo_einvoices E
LEFT JOIN user_info U ON U.user_id = E.customer_objid
WHERE ${cond.join(" AND ")}
ORDER BY E.issue_date DESC, E.regdate DESC
LIMIT 500`,
params
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
+15
View File
@@ -0,0 +1,15 @@
// 자체 거래명세표 어댑터 — 국세청 전송 X, 단순 기록만.
// 부가세 신고는 홈택스에서 사업자가 직접. 초기/소규모 거래용.
import type { InvoiceProvider, InvoiceRequest, InvoiceResponse } from "../types";
export const manualProvider: InvoiceProvider = {
method: "MANUAL",
async issue(_req: InvoiceRequest): Promise<InvoiceResponse> {
return {
success: true,
status: "DRAFT",
responseCode: "MANUAL",
responseMessage: "자체 발행 모드 — DB 기록만 됨. 국세청 전송은 별도 수동 신고 필요.",
};
},
};
+132
View File
@@ -0,0 +1,132 @@
// 국세청 e-세로 직접 연동 어댑터 (어댑터 C)
//
// 동작 모드:
// - NTS_ESERO_MODE=test → 테스트 환경 (https://testesero.go.kr)
// - NTS_ESERO_MODE=prod → 운영 환경 (https://esero.go.kr)
// - NTS_ESERO_MODE=stub → 통신 안 함, mock 응답 (개발/CI)
//
// 필요 환경변수 (운영 모드):
// - NTS_ESERO_USER_ID, NTS_ESERO_USER_PW : 국세청 ERP 연계 신청 후 받는 ID/PW
// - NTS_ESERO_CERT_PATH : 사업자용 공동인증서 .pfx 파일 경로 (컨테이너 내부 마운트)
// - NTS_ESERO_CERT_PW : 인증서 비밀번호
// - NTS_ESERO_SUPPLIER_BIZNO : 공급자(모모유통) 사업자번호
//
// 주의:
// - 첫 운영 발행 전 반드시 테스트 환경에서 ≥ 100건 검증 필요
// - XMLDSig (XML 디지털 서명) — 한국 국세청 변형 표준 사용
// - 응답 파싱 후 momo_einvoices.nts_invoice_no, status, response_xml 갱신
//
// 현재 상태: 골격 (실제 SOAP 통신 + XMLDSig 구현은 인증서 + 스펙 받은 후 추가)
import type { InvoiceProvider, InvoiceRequest, InvoiceResponse } from "../types";
type Mode = "stub" | "test" | "prod";
const MODE: Mode = (process.env.NTS_ESERO_MODE as Mode) || "stub";
const ENDPOINT = MODE === "prod"
? "https://esero.go.kr/svc/IssueService"
: "https://testesero.go.kr/svc/IssueService";
function ymd(d: string): string {
return d.replace(/-/g, "");
}
/** 국세청 24자리 승인번호 형식: YYYYMMDD-XXXXXXXX-XXXXXXXX (실제 발행 후 받음) */
function makeStubInvoiceNo(): string {
const now = new Date();
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const rand = () => Math.random().toString(36).slice(2, 10).toUpperCase();
return `${date}-${rand()}-${rand()}`;
}
/** XMLDSig + SOAP 페이로드 빌더 (TODO: 국세청 가이드 받은 후 구현) */
function buildSoapEnvelope(req: InvoiceRequest): string {
// 실제 구현 시:
// 1. 국세청 표준 TaxInvoice XML 생성 (TaxInvoiceType v3.0+)
// 2. <ds:Signature> 블록 추가 (XMLDSig, RSA-SHA256, exclusive c14n)
// 3. SOAP envelope 으로 감싸기
// 라이브러리 후보: xml-crypto + node-forge
return `<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<TaxInvoice writeDate="${ymd(req.issueDate)}">
<Supplier bizNo="${req.supplier.bizNo}" name="${escapeXml(req.supplier.name)}" />
<Buyer bizNo="${req.buyer.bizNo}" name="${escapeXml(req.buyer.name)}" />
<Total supply="${req.totalSupply}" vat="${req.totalVat}" amount="${req.totalAmount}" />
<!-- 라인은 실제 구현 시 추가 -->
</TaxInvoice>
</soap:Body>
</soap:Envelope>`;
}
function escapeXml(s: string): string {
return s.replace(/[<>&'"]/g, (c) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[c]!));
}
export const ntsEseroProvider: InvoiceProvider = {
method: "NTS",
async issue(req: InvoiceRequest): Promise<InvoiceResponse> {
const requestXml = buildSoapEnvelope(req);
if (MODE === "stub") {
return {
success: true,
status: "ACK",
ntsInvoiceNo: makeStubInvoiceNo(),
responseCode: "STUB-OK",
responseMessage: `[STUB] 국세청 통신 비활성. NTS_ESERO_MODE=test|prod 로 변경 후 인증서 설정 필요.`,
rawRequest: requestXml,
};
}
// ----- 실제 통신 (인증서 받은 후 활성화) -----
// TODO:
// 1. 인증서 로드 (PKCS#12)
// 2. requestXml 의 SignedInfo 영역에 XMLDSig 서명
// 3. fetch(ENDPOINT, { method:"POST", headers:{ "SOAPAction": "issue", ... }, body: signed })
// 4. 응답 XML 파싱 → 승인번호/응답코드 추출
// 5. ACK / FAIL 반환
return {
success: false,
status: "FAIL",
responseCode: "NOT_IMPL",
responseMessage:
"국세청 e-세로 실통신 미구현 — 사업자 공동인증서 + ERP 연계 신청 승인 후 활성화 예정.",
rawRequest: requestXml,
};
},
async status(ntsInvoiceNo: string): Promise<InvoiceResponse> {
if (MODE === "stub") {
return { success: true, status: "ACK", ntsInvoiceNo };
}
return {
success: false,
status: "FAIL",
responseMessage: "상태 조회 미구현",
};
},
async cancel(ntsInvoiceNo: string, reason?: string): Promise<InvoiceResponse> {
if (MODE === "stub") {
return {
success: true,
status: "CANCELED",
ntsInvoiceNo,
responseMessage: `[STUB] 취소 처리 — 사유: ${reason ?? "-"}`,
};
}
return {
success: false,
status: "FAIL",
responseMessage: "취소 미구현",
};
},
};
export const NTS_ESERO_INFO = {
mode: MODE,
endpoint: ENDPOINT,
isStub: MODE === "stub",
};
+23
View File
@@ -0,0 +1,23 @@
// 발행 어댑터 라우터 — 환경변수 EINVOICE_PROVIDER 로 선택
// manual = 자체 거래명세서 (기본)
// nts = 국세청 e-세로 직접 연동
// popbill = (향후 추가)
import type { InvoiceProvider } from "./types";
import { manualProvider } from "./adapters/manual";
import { ntsEseroProvider } from "./adapters/nts-esero";
const PROVIDER_KEY = (process.env.EINVOICE_PROVIDER || "manual").toLowerCase();
export function getProvider(): InvoiceProvider {
switch (PROVIDER_KEY) {
case "nts":
case "esero":
return ntsEseroProvider;
case "manual":
default:
return manualProvider;
}
}
export const PROVIDER_NAME = PROVIDER_KEY;
export * from "./types";
+65
View File
@@ -0,0 +1,65 @@
// 전자세금계산서 발행 추상화 — 어댑터 인터페이스
// 국세청 직접 연동(어댑터 C) / Popbill(B) / 자체 거래명세서(A) 모두 같은 인터페이스로 호출.
export type InvoiceKind = "TAX" | "TAXFREE" | "RECEIPT";
export type InvoiceType = "NORMAL" | "MODIFIED" | "CANCELED";
export type IssueMethod = "NTS" | "POPBILL" | "MANUAL";
export type EinvoiceStatus = "DRAFT" | "QUEUED" | "SENT" | "ACK" | "FAIL" | "CANCELED";
export interface InvoiceParty {
bizNo: string; // 사업자등록번호 (000-00-00000 또는 0000000000)
name: string;
ceoName?: string;
address?: string;
business?: string; // 업태
item?: string; // 종목
email?: string;
phone?: string;
}
export interface InvoiceLine {
seq: number;
itemDate?: string; // YYYY-MM-DD
itemName: string;
spec?: string;
qty?: number;
unitPrice?: number;
supplyAmount: number; // 공급가
vatAmount: number; // 세액 (면세는 0)
remark?: string;
}
export interface InvoiceRequest {
kind: InvoiceKind; // TAX / TAXFREE / RECEIPT
type: InvoiceType; // NORMAL / MODIFIED / CANCELED
issueDate: string; // YYYY-MM-DD
supplier: InvoiceParty;
buyer: InvoiceParty;
lines: InvoiceLine[];
totalSupply: number;
totalVat: number;
totalAmount: number;
memo?: string;
// 수정/취소 시 원본 승인번호
originNtsInvoiceNo?: string;
}
export interface InvoiceResponse {
success: boolean;
status: EinvoiceStatus;
ntsInvoiceNo?: string; // 국세청 승인번호 (24자리)
responseCode?: string;
responseMessage?: string;
rawRequest?: string; // 디버그용 (XML)
rawResponse?: string;
}
export interface InvoiceProvider {
readonly method: IssueMethod;
/** 발행 (TAX/TAXFREE) */
issue(req: InvoiceRequest): Promise<InvoiceResponse>;
/** 발행 상태 조회 */
status?(ntsInvoiceNo: string): Promise<InvoiceResponse>;
/** 발행 취소 */
cancel?(ntsInvoiceNo: string, reason?: string): Promise<InvoiceResponse>;
}