feat(momo v0.6): 전자세금계산서 발행 모듈 골격 (별도 메뉴 + 국세청 직접 연동 준비)
Deploy momo-erp / deploy (push) Successful in 51s
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:
@@ -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;
|
||||
@@ -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` | 국세청 상태 재조회 |
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 기록만 됨. 국세청 전송은 별도 수동 신고 필요.",
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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) =>
|
||||
({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[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",
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user