이 글은 누구를 위한 것인가
- B2B 주문이 늘면서 세금계산서 발행이 수동으로 힘든 팀
- 국세청 API 연동 방법을 모르는 개발자
- 월말 세금계산서 일괄 발행으로 CS가 쌓이는 운영팀
들어가며
B2B 이커머스에서 세금계산서는 필수다. 수동 발행은 오류가 많고 인력이 많이 든다. 국세청 전자세금계산서 API를 연동하면 주문 완료 즉시 자동 발행이 가능하다.
이 글은 bluefoxdev.kr의 B2B 이커머스 시스템 설계 를 참고하여 작성했습니다.
1. 전자세금계산서 발행 흐름
[세금계산서 자동 발행 흐름]
주문 완료
↓
사업자 정보 확인
↓ (사업자 주문인 경우)
사업자번호 유효성 검증 (국세청 API)
↓
전자세금계산서 발행 (공급자 → 공급받는자)
↓
국세청 전송 (발행일 익일 17시까지)
↓
이메일 발송 (고객 + 사본 보관)
↓
회계 시스템 연동 (ERP)
[세금계산서 DB 스키마]
CREATE TABLE tax_invoices (
id UUID PRIMARY KEY,
order_id UUID NOT NULL,
invoice_number VARCHAR(24) UNIQUE, -- 일련번호 24자리
supplier_biz_num VARCHAR(10) NOT NULL, -- 공급자 사업자번호
buyer_biz_num VARCHAR(10) NOT NULL, -- 공급받는자 사업자번호
issue_date DATE NOT NULL,
supply_amount BIGINT NOT NULL, -- 공급가액
tax_amount BIGINT NOT NULL, -- 세액
total_amount BIGINT NOT NULL, -- 합계금액
status VARCHAR(20) DEFAULT 'issued', -- issued, sent, cancelled, amended
nts_id VARCHAR(50), -- 국세청 승인번호
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
2. 국세청 API 연동
import httpx
from datetime import date
import hashlib
import hmac
class NTSInvoiceClient:
"""국세청 전자세금계산서 API 클라이언트"""
BASE_URL = "https://api.hometax.go.kr/etax"
def __init__(self, cert_path: str, cert_password: str):
self.cert_path = cert_path
self.cert_password = cert_password
async def validate_business_number(self, biz_num: str) -> dict:
"""사업자번호 유효성 검증"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/validate/business",
json={"business_number": biz_num},
headers=self._get_auth_headers(),
)
return response.json()
async def issue_invoice(self, invoice_data: dict) -> dict:
"""전자세금계산서 발행"""
payload = {
"invoiceType": "01", # 일반 세금계산서
"issueDate": invoice_data["issue_date"],
"supplier": {
"businessNumber": invoice_data["supplier_biz_num"],
"businessName": invoice_data["supplier_name"],
"ceoName": invoice_data["supplier_ceo"],
"email": invoice_data["supplier_email"],
},
"buyer": {
"businessNumber": invoice_data["buyer_biz_num"],
"businessName": invoice_data["buyer_name"],
"email": invoice_data["buyer_email"],
},
"items": [
{
"name": item["name"],
"quantity": item["quantity"],
"unitPrice": item["unit_price"],
"supplyAmount": item["supply_amount"],
"taxAmount": item["tax_amount"],
}
for item in invoice_data["items"]
],
"totalSupplyAmount": invoice_data["supply_amount"],
"totalTaxAmount": invoice_data["tax_amount"],
"totalAmount": invoice_data["total_amount"],
}
async with httpx.AsyncClient(cert=(self.cert_path, self.cert_password)) as client:
response = await client.post(
f"{self.BASE_URL}/issue",
json=payload,
)
return response.json()
def _get_auth_headers(self) -> dict:
timestamp = str(int(datetime.now().timestamp()))
return {
"X-NTS-Timestamp": timestamp,
"X-NTS-Signature": self._sign(timestamp),
}
async def auto_issue_tax_invoice(order_id: str):
"""주문 완료 시 세금계산서 자동 발행"""
order = await get_order_with_buyer_info(order_id)
# 사업자 주문이 아니면 스킵
if not order.get("buyer_biz_num"):
return
# 사업자번호 검증
nts_client = NTSInvoiceClient(CERT_PATH, CERT_PASSWORD)
validation = await nts_client.validate_business_number(order["buyer_biz_num"])
if not validation["valid"]:
await notify_ops_team(f"유효하지 않은 사업자번호: {order['buyer_biz_num']}")
return
# 세금 계산
supply_amount = round(order["total_amount"] / 1.1) # VAT 역산
tax_amount = order["total_amount"] - supply_amount
invoice_data = {
"issue_date": date.today().isoformat(),
"supplier_biz_num": MY_BUSINESS_NUMBER,
"buyer_biz_num": order["buyer_biz_num"],
"buyer_name": order["company_name"],
"buyer_email": order["billing_email"],
"supply_amount": supply_amount,
"tax_amount": tax_amount,
"total_amount": order["total_amount"],
"items": build_invoice_items(order["items"]),
}
result = await nts_client.issue_invoice(invoice_data)
# DB 저장
await save_tax_invoice(order_id, result)
# 이메일 발송
await send_invoice_email(order["billing_email"], result["invoice_pdf_url"])
3. 수정 세금계산서 처리
async def issue_amended_invoice(original_invoice_id: str, reason: str):
"""수정 세금계산서 발행 (환불, 취소 시)"""
original = await get_tax_invoice(original_invoice_id)
# 음수 금액으로 수정 계산서 발행
amended_data = {
**original,
"invoiceType": "02", # 수정 세금계산서
"originalInvoiceId": original["nts_id"],
"amendReason": reason,
"supply_amount": -original["supply_amount"],
"tax_amount": -original["tax_amount"],
"total_amount": -original["total_amount"],
}
nts_client = NTSInvoiceClient(CERT_PATH, CERT_PASSWORD)
result = await nts_client.issue_invoice(amended_data)
# 원본 계산서 상태 업데이트
await db.execute(
"UPDATE tax_invoices SET status='amended' WHERE id=$1",
original_invoice_id
)
return result
마무리
전자세금계산서 자동화의 핵심은 주문 완료 직후 즉시 발행이다. 월말에 몰아서 처리하면 오류가 생기고, 고객도 불편하다. 사업자번호 검증을 주문 단계에서 하면 발행 실패를 사전에 막을 수 있다.