From 7e764d500e4157baa7b1f98827be200a3fd9dea3 Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 7 May 2026 16:14:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(momo=20v0.6):=20=EC=A0=84=EC=9E=90?= =?UTF-8?q?=EC=84=B8=EA=B8=88=EA=B3=84=EC=82=B0=EC=84=9C=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EB=AA=A8=EB=93=88=20=EA=B3=A8=EA=B2=A9=20(?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20=EB=A9=94=EB=89=B4=20+=20=EA=B5=AD?= =?UTF-8?q?=EC=84=B8=EC=B2=AD=20=EC=A7=81=EC=A0=91=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [정책] - 발주/출고/입금 흐름과 분리된 별도 메뉴 (월말 일괄 또는 신고 시점 발행 가능) - 출고 시 자동 발행은 향후 토글 옵션으로 추가 [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) --- db/migrations/012_einvoices.sql | 73 ++++++ docs/MOMO_DISTRIBUTION_SPEC.md | 60 +++++ src/app/(main)/m/admin/einvoices/page.tsx | 273 ++++++++++++++++++++++ src/app/api/m/einvoices/issue/route.ts | 194 +++++++++++++++ src/app/api/m/einvoices/list/route.ts | 50 ++++ src/lib/einvoice/adapters/manual.ts | 15 ++ src/lib/einvoice/adapters/nts-esero.ts | 132 +++++++++++ src/lib/einvoice/index.ts | 23 ++ src/lib/einvoice/types.ts | 65 ++++++ 9 files changed, 885 insertions(+) create mode 100644 db/migrations/012_einvoices.sql create mode 100644 src/app/(main)/m/admin/einvoices/page.tsx create mode 100644 src/app/api/m/einvoices/issue/route.ts create mode 100644 src/app/api/m/einvoices/list/route.ts create mode 100644 src/lib/einvoice/adapters/manual.ts create mode 100644 src/lib/einvoice/adapters/nts-esero.ts create mode 100644 src/lib/einvoice/index.ts create mode 100644 src/lib/einvoice/types.ts diff --git a/db/migrations/012_einvoices.sql b/db/migrations/012_einvoices.sql new file mode 100644 index 0000000..0a84a4d --- /dev/null +++ b/db/migrations/012_einvoices.sql @@ -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; diff --git a/docs/MOMO_DISTRIBUTION_SPEC.md b/docs/MOMO_DISTRIBUTION_SPEC.md index 9b38cb6..679c022 100644 --- a/docs/MOMO_DISTRIBUTION_SPEC.md +++ b/docs/MOMO_DISTRIBUTION_SPEC.md @@ -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` | 국세청 상태 재조회 | diff --git a/src/app/(main)/m/admin/einvoices/page.tsx b/src/app/(main)/m/admin/einvoices/page.tsx new file mode 100644 index 0000000..30ed5c2 --- /dev/null +++ b/src/app/(main)/m/admin/einvoices/page.tsx @@ -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 = { + DRAFT: "작성중", QUEUED: "전송대기", SENT: "전송완료", ACK: "승인완료", FAIL: "실패", CANCELED: "취소", +}; +const STATUS_COLOR: Record = { + 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([]); + const [pending, setPending] = useState([]); + 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: ` +
+
승인번호: ${j.ntsInvoiceNo ?? "-"}
+
상태: ${j.status}
+
처리방식: ${j.provider}
+ ${j.message ? `
${j.message}
` : ""} +
`, + }); + 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 ( +
+
+
+

전자세금계산서 발행

+

출고/입금 완료된 발주에 대해 전자(세금)계산서를 발행하고 이력을 관리합니다.

+
+
+ + +
+
+ + {/* 안내 배너 — STUB 모드 */} +
+ +
+ 국세청 직접 연동 어댑터 골격만 구현된 상태 — 현재 발행은 DB 기록만 됩니다. + 실제 국세청 전송은 (1) 사업자용 공동인증서 발급, (2) 홈택스 ERP 연계 신청 승인, (3) NTS_ESERO_MODE 환경변수를 test/prod 로 설정 후 활성화됩니다. +
+
+ + {/* 발행 가능 발주 (출고/입금 완료) */} +
+
+ 발행 가능 발주 ({pending.length}건) + — 출고완료 / 입금완료 상태 +
+
+ + + + + + + + + + + + + {pending.length === 0 ? ( + + ) : pending.map((o) => ( + + + + + + + + + ))} + +
발주번호발주일거래처합계상태발행
발행 가능한 발주가 없습니다.
{o.ORDER_NO}{o.ORDER_DATE}{o.COMPANY_NAME}₩{fmt(o.TOTAL_AMOUNT)}{o.STATUS} + +
+
+
+ + {/* 발행 이력 */} +
+
+ 발행 이력 ({list.length}건) +
+ setRange([e.target.value, to])} + className="h-8 px-2 rounded border border-slate-200" /> + ~ + setRange([from, e.target.value])} + className="h-8 px-2 rounded border border-slate-200" /> + + +
+
+
+ + + + + + + + + + + + + + + + {list.length === 0 ? ( + + ) : list.map((e) => ( + + + + + + + + + + + + ))} + +
발행일거래처구분처리공급가세액합계승인번호상태
발행 이력이 없습니다.
{e.ISSUE_DATE}{e.BUYER_NAME} + + {e.INVOICE_KIND === "TAX" ? "세금" : "면세"} + + {e.ISSUE_METHOD}{fmt(e.TOTAL_SUPPLY)}{fmt(e.TOTAL_VAT)}₩{fmt(e.TOTAL_AMOUNT)}{e.NTS_INVOICE_NO || "-"} + + {STATUS_LABEL[e.STATUS] ?? e.STATUS} + +
+
+
+
+ ); +} diff --git a/src/app/api/m/einvoices/issue/route.ts b/src/app/api/m/einvoices/issue/route.ts new file mode 100644 index 0000000..ec5ffad --- /dev/null +++ b/src/app/api/m/einvoices/issue/route.ts @@ -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; + 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, + }); +} diff --git a/src/app/api/m/einvoices/list/route.ts b/src/app/api/m/einvoices/list/route.ts new file mode 100644 index 0000000..0ddbcd4 --- /dev/null +++ b/src/app/api/m/einvoices/list/route.ts @@ -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 }); +} diff --git a/src/lib/einvoice/adapters/manual.ts b/src/lib/einvoice/adapters/manual.ts new file mode 100644 index 0000000..24b4fd2 --- /dev/null +++ b/src/lib/einvoice/adapters/manual.ts @@ -0,0 +1,15 @@ +// 자체 거래명세표 어댑터 — 국세청 전송 X, 단순 기록만. +// 부가세 신고는 홈택스에서 사업자가 직접. 초기/소규모 거래용. +import type { InvoiceProvider, InvoiceRequest, InvoiceResponse } from "../types"; + +export const manualProvider: InvoiceProvider = { + method: "MANUAL", + async issue(_req: InvoiceRequest): Promise { + return { + success: true, + status: "DRAFT", + responseCode: "MANUAL", + responseMessage: "자체 발행 모드 — DB 기록만 됨. 국세청 전송은 별도 수동 신고 필요.", + }; + }, +}; diff --git a/src/lib/einvoice/adapters/nts-esero.ts b/src/lib/einvoice/adapters/nts-esero.ts new file mode 100644 index 0000000..38e4b44 --- /dev/null +++ b/src/lib/einvoice/adapters/nts-esero.ts @@ -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. 블록 추가 (XMLDSig, RSA-SHA256, exclusive c14n) + // 3. SOAP envelope 으로 감싸기 + // 라이브러리 후보: xml-crypto + node-forge + return ` + + + + + + + + + +`; +} + +function escapeXml(s: string): string { + return s.replace(/[<>&'"]/g, (c) => + ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]!)); +} + +export const ntsEseroProvider: InvoiceProvider = { + method: "NTS", + async issue(req: InvoiceRequest): Promise { + 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 { + if (MODE === "stub") { + return { success: true, status: "ACK", ntsInvoiceNo }; + } + return { + success: false, + status: "FAIL", + responseMessage: "상태 조회 미구현", + }; + }, + + async cancel(ntsInvoiceNo: string, reason?: string): Promise { + 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", +}; diff --git a/src/lib/einvoice/index.ts b/src/lib/einvoice/index.ts new file mode 100644 index 0000000..e3973f0 --- /dev/null +++ b/src/lib/einvoice/index.ts @@ -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"; diff --git a/src/lib/einvoice/types.ts b/src/lib/einvoice/types.ts new file mode 100644 index 0000000..4b6568c --- /dev/null +++ b/src/lib/einvoice/types.ts @@ -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; + /** 발행 상태 조회 */ + status?(ntsInvoiceNo: string): Promise; + /** 발행 취소 */ + cancel?(ntsInvoiceNo: string, reason?: string): Promise; +}