instruction.md
# Popbill all-service k-skill
## 제공 범위
`scripts/popbill_cli.py`는 아래 SDK service class를 모두 노출한다.
- `taxinvoice`: 전자세금계산서 발행/임시저장/수정/취소/국세청 전송/조회/인쇄 URL/인증서 확인/첨부/메일·문자·팩스 재전송
- `statement`: 전자명세서 등록/발행/수정/취소/조회/인쇄 URL/메일·문자·팩스 전송
- `cashbill`: 현금영수증 등록/발행/취소/역발행/조회/메일·문자·팩스 전송
- `message`: SMS/LMS/MMS 단건·대량 발송, 예약취소, 결과조회, 발신번호 관리 조회
- `kakao`: 알림톡/친구톡/브랜드메시지 템플릿·채널·발신번호 조회와 발송/예약취소/결과조회
- `fax`: 팩스 발송/예약취소/결과조회/발신번호 관리
- `closedown`: 휴폐업 조회
- `bizinfo`: 기업정보조회
- `easyfin-bank`: 계좌 등록/수집작업/내역조회 등 팝빌 계좌조회 서비스
- `account-check`: 예금주·계좌성명 조회
- `ht-taxinvoice`: 홈택스 전자세금계산서 수집 작업/검색/상세/XML
- `ht-cashbill`: 홈택스 현금영수증 수집 작업/검색/요약
모든 SDK method는 `call` 서브커맨드로 호출 가능하다. 안전한 조회는 바로 실행하고, 발행·전송·취소·삭제·계좌검증 등 상태 변경/과금 가능 작업은 `--yes-i-understand`가 있어야 실행된다. 운영환경 mutation은 추가로 `--no-test --allow-production`이 필요하다.
## 인증/시크릿
시크릿은 저장소에 넣지 않는다. k-skill 표준 resolution order를 따른다.
1. 이미 환경변수에 있으면 그대로 사용
2. 에이전트 vault(1Password/Bitwarden/macOS Keychain 등)를 쓰면 거기서 주입
3. `~/.config/k-skill/secrets.env` fallback, 권한 `0600`
4. 없으면 사용자에게 물어 위 경로에 저장
지원 환경변수:
```dotenv
KSKILL_POPBILL_LINK_ID=...
KSKILL_POPBILL_SECRET_KEY=...
KSKILL_POPBILL_CORP_NUM=...
KSKILL_POPBILL_USER_ID=
```
호환 alias도 읽는다: `POPBILL_LINKID`, `POPBILL_LINK_ID`, `POPBILL_SECRET_KEY`, `POPBILL_TEST_CORP_NUM`, `POPBILL_CORP_NUM`, `POPBILL_USER_ID`.
기본은 팝빌 테스트 환경이다. 운영환경은 명시적으로 `--no-test`를 붙여야 한다.
## 필수 사전조건
서비스별로 팝빌 계정 준비가 다르다.
- 전자세금계산서: 테스트/운영 환경 각각 공동인증서 등록 필요
- 홈택스 수집: 홈택스 수집용 인증서/정액제 상태 필요
- 문자: 발신번호 사전 등록 필요
- 카카오톡/알림톡: 비즈니스 채널, 발신번호, 승인 템플릿 필요
- 팩스: 발신번호 등록 권장 또는 필요할 수 있음
- 계좌/예금주조회: 팝빌 서비스 신청·과금·조회 대상 입력값 필요
- 테스트와 운영 환경은 분리되어 있으며 테스트 데이터가 운영으로 이관되지 않는다
## 기본 명령
```bash
# 시크릿 파일 권한과 필수 env 존재 확인. 실제 secret 값은 출력하지 않는다.
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- config-check
# 특정 SDK service method 목록과 approval 필요 여부 확인
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- methods taxinvoice
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- methods kakao
# 안전한 SDK 객체 템플릿 출력
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- object-template taxinvoice
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- object-template cashbill
# 테스트 환경에서 안전한 회원/파트너 잔액 smoke test
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- health taxinvoice
# 휴폐업 단건 조회
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- closedown-check --target-corp-num 123-45-67890
```
## Generic SDK call
전체 Popbill SDK 기능은 아래 형태로 호출한다.
```bash
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- call <service> <method> \
--args-json '["@corp", "arg2"]' \
--kwargs-json '{"UserID":"optional-user"}'
```
`--args-json` 첫 번째 원소가 `"@corp"`이면 `--corp-num` 또는 `KSKILL_POPBILL_CORP_NUM` 값으로 대체된다. JSON 파일을 쓰려면 `@path/to/file.json` 형식을 사용한다. 중첩 SDK 객체는 `__kind__` 필드로 지정한다.
예: 전자세금계산서 임시저장 payload skeleton
```json
{
"__kind__": "taxinvoice",
"issueType": "정발행",
"chargeDirection": "정과금",
"purposeType": "청구",
"taxType": "과세",
"writeDate": "20260701",
"invoicerMgtKey": "TEST-20260701-001",
"invoicerCorpNum": "1234567890",
"invoicerCorpName": "공급자 상호",
"invoicerCEOName": "대표자",
"invoiceeType": "사업자",
"invoiceeCorpNum": "0987654321",
"invoiceeCorpName": "공급받는자 상호",
"invoiceeCEOName": "대표자",
"supplyCostTotal": "91",
"taxTotal": "9",
"totalAmount": "100",
"detailList": [
{"__kind__":"taxinvoice-detail", "serialNum":1, "itemName":"테스트 품목", "supplyCost":"91", "tax":"9"}
]
}
```
발행·전송·삭제·취소처럼 되돌리기 어려운 동작은 사용자의 현재 턴 승인을 받은 뒤에만 실행한다.
```bash
npx -y @nomadamas/k-skill@0 exec popbill scripts/popbill_cli.py -- call taxinvoice registIssue \
--args-json '["@corp", {"__kind__":"taxinvoice", "...":"..."}]' \
--yes-i-understand
```
## 안전 경계
- 실제 `LinkID`, `SecretKey`, 사업자번호, 인증서, 수신자 전화번호/이메일, 계좌번호를 저장소·PR·스크린샷에 넣지 않는다.
- 테스트 환경에서도 발행/전송/취소/삭제는 사용자 승인 없이 실행하지 않는다.
- 운영환경에서 발행/전송/취소/삭제는 `--no-test --allow-production --yes-i-understand`와 현재 턴 승인 둘 다 필요하다.
- 메시지·알림톡·팩스 발송은 테스트라도 수신자에게 도달하거나 과금될 수 있으므로 기본적으로 dry-run/템플릿/조회부터 한다.
- 계좌조회/예금주조회는 개인정보·금융정보이므로 목적·보관·파기 기준이 명확할 때만 실행한다.
## 실패 모드
- `missing link_id/secret_key/corp_num`: k-skill secrets 파일 또는 환경변수 설정 필요
- `permissions ... expected 0600`: `chmod 600 ~/.config/k-skill/secrets.env`
- `-99003008`: 해당 테스트/운영 환경에 연동회원 사업자번호가 없음
- `-10004000`: 전자세금계산서 공동인증서 미등록
- 문자/카카오/팩스 발신번호·채널·템플릿 미등록: 서비스별 관리 URL에서 등록 후 재시도
- 테스트 환경 성공이 운영환경 성공을 보장하지 않음: 운영 전환 신청과 운영 계정 세팅이 별도로 필요
## 완료 기준
- `config-check`가 필수 시크릿 존재를 확인한다.
- `methods <service>`로 필요한 SDK method가 확인된다.
- 가능한 경우 `health <service>` 또는 조회형 API가 테스트 환경에서 성공한다.
- mutation 작업은 payload를 파일로 저장해 사용자 확인을 받은 뒤 `--yes-i-understand`로 실행하고 응답 code/message를 보관한다.
references/DISCLAIMER.md
# DISCLAIMER — `popbill`
이 스킬은 팝빌 또는 관련 사업자의 공식 기능 또는 공식 지원 도구가 아니며, 공식 제휴·후원·승인·인증 또는 협업한 사실이 전혀 없습니다. 상표와 서비스명은 API 호환 기능과 대상을 설명하기 위해서만 사용합니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)(소니용 리모컨 사건)은 기능 설명용 표장과 출처표시를 구별했습니다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)에 따른 출처 혼동 판단은 별도입니다.
대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)은 공개정보 접근에 관한 판단이며, 인증된 세금·계좌·메시지 데이터에 대한 권한을 부여하지 않습니다. 공개되어 누구나 얻을 수 있는 정보의 기계적 수집만으로 곧바로 [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조) 위반이 되는 것은 아니지만, API 계약·개인정보·[저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조)·[형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조)은 별도입니다.
- 공개정보 자동 수집 부분은 개인의 정보 조회용으로만 사용합니다. 비공개 사업 데이터는 본인 또는 정당한 권한을 받은 조직의 계정 범위에서만 처리합니다.
- 조직적 크롤링이나 세금문서·식별자·수신자 데이터의 별도 DB 구축·외부 공유를 하지 않습니다.
- 인증·quota·접근통제·차단을 우회하거나 서비스 장애·영업 방해를 일으키지 않습니다.
이 문서는 적법성을 보증하는 법률 자문이 아닙니다.
references/TRADEMARK-LEGAL-STATEMENT.md
# 상표 사용 법적 고지 — `popbill`
이 스킬에서 `팝빌` 및 `Popbill` 명칭은 전자세금계산서·전자명세서·현금영수증·메시징 등 **대상 API 서비스**를 식별하기 위해 사용한다. k-skill의 출처를 팝빌 운영사로 표시하려는 사용이 아니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 상품 기능 또는 적용 기종을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우 침해가 아니라고 판시했다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 대법원 [2011다18802](https://www.law.go.kr/LSW/precInfoP.do?precSeq=167457), [2019후10418](https://law.go.kr/LSW/precInfoP.do?mode=0&precSeq=230725) 판결에 따라 실제 거래계에서 출처표시로 기능하는지는 표시 태양과 사용 경위 등을 종합해 판단해야 한다.
따라서 이 명칭은 필요한 범위의 평문으로만 사용하고, 별도 근거 없이 로고, 공식·제휴·후원·인증·파트너 표현을 사용하지 않는다. 각 상표의 권리는 해당 권리자에게 있다. 이 문서는 API 계약, 세무·전자문서 규제, 메시지 발송 권한, 개인정보 또는 개별 거래의 적법성을 판단하지 않는다.
전체 검토: [제3자 상표의 기능 설명·호환 대상 표시 검토](https://github.com/NomaDamas/k-skill/blob/dev/docs/legal/trademark-use-review.md)
scripts/popbill_cli.py
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "popbill==1.64.2",
# ]
# ///
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import stat
import sys
from pathlib import Path
from typing import Any, Callable
from popbill import PopbillException
from popbill_registry import OBJECT_CLASSES, SERVICE_CLASSES
from popbill_safety import is_dangerous_method
from popbill_templates import object_templates
SECRET_FILE = Path.home() / ".config" / "k-skill" / "secrets.env"
ENV_ALIASES = {
"link_id": ("KSKILL_POPBILL_LINK_ID", "POPBILL_LINKID", "POPBILL_LINK_ID"),
"secret_key": ("KSKILL_POPBILL_SECRET_KEY", "POPBILL_SECRET_KEY"),
"corp_num": ("KSKILL_POPBILL_CORP_NUM", "POPBILL_TEST_CORP_NUM", "POPBILL_CORP_NUM"),
"user_id": ("KSKILL_POPBILL_USER_ID", "POPBILL_USER_ID"),
}
DEFAULT_TEST_CORP_NUMS = ("1231212312", "1248100998", "1208800767")
def load_dotenv(path: Path = SECRET_FILE) -> None:
if not path.exists():
return
mode = stat.S_IMODE(path.stat().st_mode)
if mode & 0o077:
raise RuntimeError(f"{path} permissions are {oct(mode)}; expected 0600")
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def env_value(name: str, explicit: str | None = None, *, required: bool = False) -> str | None:
if explicit:
return explicit.strip()
for key in ENV_ALIASES[name]:
value = os.environ.get(key, "").strip()
if value and value != "replace-me":
return value
if required:
names = ", ".join(ENV_ALIASES[name])
raise ValueError(f"missing {name}; set one of: {names}")
return None
def normalize_corp_num(value: str | None) -> str:
if not value:
raise ValueError("missing corp_num; set KSKILL_POPBILL_CORP_NUM or pass --corp-num")
normalized = re.sub(r"\D", "", value)
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("corp_num must be a 10 digit Korean business registration number")
return normalized
def create_service(service_name: str, *, link_id: str | None, secret_key: str | None, is_test: bool) -> Any:
cls = SERVICE_CLASSES[service_name]
service = cls(env_value("link_id", link_id, required=True), env_value("secret_key", secret_key, required=True))
service.IsTest = bool(is_test)
service.IPRestrictOnOff = True
return service
def to_jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (dt.date, dt.datetime)):
return value.isoformat()
if isinstance(value, (list, tuple, set)):
return [to_jsonable(v) for v in value]
if isinstance(value, dict):
return {str(k): to_jsonable(v) for k, v in value.items()}
if hasattr(value, "__dict__"):
return {k: to_jsonable(v) for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def print_json(payload: Any) -> None:
print(json.dumps(to_jsonable(payload), ensure_ascii=False, indent=2, sort_keys=True))
def parse_json_arg(value: str | None, default: Any) -> Any:
if not value:
return default
if value.startswith("@"):
value = Path(value[1:]).read_text(encoding="utf-8")
return json.loads(value)
def object_from_payload(kind: str, payload: dict[str, Any]) -> Any:
if kind not in OBJECT_CLASSES:
raise ValueError(f"unsupported object kind: {kind}")
return OBJECT_CLASSES[kind](**payload)
def nested_object(value: Any) -> Any:
if isinstance(value, dict):
kind = value.pop("__kind__", None)
converted = {k: nested_object(v) for k, v in value.items()}
if kind:
return object_from_payload(kind, converted)
return converted
if isinstance(value, list):
return [nested_object(v) for v in value]
return value
def guard_dangerous(method_name: str, args: argparse.Namespace) -> None:
if not is_dangerous_method(method_name):
return
if not args.yes_i_understand:
raise ValueError(f"{method_name} can mutate Popbill/test data or send messages; pass --yes-i-understand after current-turn approval")
if not args.test and not args.allow_production:
raise ValueError("production mutation requires --allow-production and current-turn approval")
def command_config_check(args: argparse.Namespace) -> int:
load_dotenv()
missing = []
for logical, aliases in ENV_ALIASES.items():
if logical == "user_id":
continue
if logical == "corp_num" and os.environ.get("KSKILL_POPBILL_TRY_DEFAULT_TEST_CORP_NUMS") == "1":
continue
if not env_value(logical, getattr(args, logical, None)):
missing.append({"field": logical, "accepted_env": aliases})
result = {
"ok": not missing,
"secrets_file": str(SECRET_FILE),
"secrets_file_exists": SECRET_FILE.exists(),
"missing": missing,
"mode": "test" if args.test else "production",
}
print_json(result)
return 0 if not missing else 1
def command_methods(args: argparse.Namespace) -> int:
cls = SERVICE_CLASSES[args.service]
import inspect
methods = []
for name, func in inspect.getmembers(cls, inspect.isfunction):
if name.startswith("_") or name == "__init__":
continue
methods.append({"name": name, "signature": str(inspect.signature(func)), "requires_approval": is_dangerous_method(name)})
print_json({"service": args.service, "methods": methods})
return 0
def command_object_template(args: argparse.Namespace) -> int:
templates = object_templates()
print_json({"kind": args.kind, "template": templates.get(args.kind, {})})
return 0
def command_call(args: argparse.Namespace) -> int:
load_dotenv()
guard_dangerous(args.method, args)
service = create_service(args.service, link_id=args.link_id, secret_key=args.secret_key, is_test=args.test)
if not hasattr(service, args.method):
raise ValueError(f"{args.service} has no method {args.method}")
method: Callable[..., Any] = getattr(service, args.method)
positional = nested_object(parse_json_arg(args.args_json, []))
keywords = nested_object(parse_json_arg(args.kwargs_json, {}))
if not isinstance(positional, list):
raise ValueError("--args-json must be a JSON array")
if not isinstance(keywords, dict):
raise ValueError("--kwargs-json must be a JSON object")
if positional and positional[0] == "@corp":
positional[0] = resolve_corp_candidates(args.corp_num)[0]
result = method(*positional, **keywords)
print_json({"ok": True, "service": args.service, "method": args.method, "result": result})
return 0
def resolve_corp_candidates(explicit: str | None = None) -> list[str]:
configured = env_value("corp_num", explicit)
if configured:
return [normalize_corp_num(configured)]
if os.environ.get("KSKILL_POPBILL_TRY_DEFAULT_TEST_CORP_NUMS") == "1":
return list(DEFAULT_TEST_CORP_NUMS)
raise ValueError("missing corp_num; set KSKILL_POPBILL_CORP_NUM or pass --corp-num")
def command_health(args: argparse.Namespace) -> int:
load_dotenv()
service = create_service(args.service, link_id=args.link_id, secret_key=args.secret_key, is_test=args.test)
all_checks = []
for corp_num in resolve_corp_candidates(args.corp_num):
checks: dict[str, Any] = {"service": args.service, "corp_num": corp_num, "mode": "test" if args.test else "production"}
success = False
for method_name in ("checkIsMember", "getPartnerBalance"):
if hasattr(service, method_name):
try:
checks[method_name] = getattr(service, method_name)(corp_num)
success = True
except PopbillException as exc:
checks[method_name] = {"popbill_error": int(exc.code), "message": str(exc.message)}
all_checks.append(checks)
if success:
print_json(checks)
return 0
print_json({"ok": False, "checks": all_checks})
return 2
def command_closedown_check(args: argparse.Namespace) -> int:
load_dotenv()
corp_num = normalize_corp_num(env_value("corp_num", args.corp_num, required=True))
target = normalize_corp_num(args.target_corp_num)
service = create_service("closedown", link_id=args.link_id, secret_key=args.secret_key, is_test=args.test)
print_json({"ok": True, "result": service.checkCorpNum(corp_num, target)})
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Popbill all-service k-skill CLI")
parser.add_argument("--link-id")
parser.add_argument("--secret-key")
parser.add_argument("--corp-num")
parser.add_argument("--test", action=argparse.BooleanOptionalAction, default=True, help="use Popbill test environment (default: true)")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("config-check", help="check local k-skill Popbill secret wiring without printing secrets")
p.set_defaults(func=command_config_check)
p = sub.add_parser("methods", help="list SDK methods for a Popbill service")
p.add_argument("service", choices=sorted(SERVICE_CLASSES))
p.set_defaults(func=command_methods)
p = sub.add_parser("object-template", help="print safe starter JSON for SDK input objects")
p.add_argument("kind", choices=sorted(set(OBJECT_CLASSES) | {"message-receiver", "fax-receiver", "kakao-receiver"}))
p.set_defaults(func=command_object_template)
p = sub.add_parser("health", help="safe membership/balance smoke test")
p.add_argument("service", choices=sorted(SERVICE_CLASSES), nargs="?", default="taxinvoice")
p.set_defaults(func=command_health)
p = sub.add_parser("closedown-check", help="휴폐업 단건 조회")
p.add_argument("--target-corp-num", required=True)
p.set_defaults(func=command_closedown_check)
p = sub.add_parser("call", help="generic SDK method call; covers all Popbill SDK service features")
p.add_argument("service", choices=sorted(SERVICE_CLASSES))
p.add_argument("method")
p.add_argument("--args-json", default="[]", help="JSON array; use @path to read from file. Use @corp as first item to inject --corp-num/env corp num.")
p.add_argument("--kwargs-json", default="{}", help="JSON object; use @path to read from file")
p.add_argument("--yes-i-understand", action="store_true", help="required for mutation/send/issue operations")
p.add_argument("--allow-production", action="store_true", help="required together with --no-test for production mutation")
p.set_defaults(func=command_call)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return args.func(args)
except PopbillException as exc:
print_json({"ok": False, "popbill_error": int(exc.code), "message": str(exc.message)})
return 2
except Exception as exc:
print_json({"ok": False, "error": str(exc)})
return 1
if __name__ == "__main__":
raise SystemExit(main())
scripts/popbill_registry.py
from __future__ import annotations
from popbill import (
AccountCheckService,
BizInfoCheckService,
Cashbill,
CashbillService,
ClosedownService,
EasyFinBankService,
FaxReceiver,
FaxService,
FileData,
HTCashbillService,
HTTaxinvoiceService,
KakaoButton,
KakaoReceiver,
KakaoService,
MessageReceiver,
MessageService,
Statement,
StatementDetail,
StatementService,
Taxinvoice,
TaxinvoiceDetail,
TaxinvoiceService,
)
SERVICE_CLASSES: dict[str, type] = {
"taxinvoice": TaxinvoiceService,
"statement": StatementService,
"cashbill": CashbillService,
"message": MessageService,
"kakao": KakaoService,
"fax": FaxService,
"closedown": ClosedownService,
"bizinfo": BizInfoCheckService,
"easyfin-bank": EasyFinBankService,
"account-check": AccountCheckService,
"ht-taxinvoice": HTTaxinvoiceService,
"ht-cashbill": HTCashbillService,
}
OBJECT_CLASSES: dict[str, type] = {
"taxinvoice": Taxinvoice,
"taxinvoice-detail": TaxinvoiceDetail,
"statement": Statement,
"statement-detail": StatementDetail,
"cashbill": Cashbill,
"message-receiver": MessageReceiver,
"fax-receiver": FaxReceiver,
"file-data": FileData,
"kakao-receiver": KakaoReceiver,
"kakao-button": KakaoButton,
}
scripts/popbill_safety.py
from __future__ import annotations
DANGEROUS_METHOD_PREFIXES = (
"accept",
"assign",
"attach",
"bulk",
"cancel",
"checkaccountinfo",
"checkdepositorinfo",
"close",
"delete",
"deny",
"detach",
"faxsend",
"issue",
"join",
"payment",
"quit",
"refund",
"refuse",
"regist",
"register",
"request",
"resend",
"revoke",
"send",
"update",
)
SAFE_URL_METHODS = frozenset(
{"getaccessurl", "getchargeurl", "getpaymenturl", "getpopbillurl", "geturl", "getusehistoryurl"}
)
def is_dangerous_method(method_name: str) -> bool:
lowered = method_name.lower()
return lowered.startswith(DANGEROUS_METHOD_PREFIXES) and lowered not in SAFE_URL_METHODS
scripts/popbill_templates.py
from __future__ import annotations
import datetime as dt
from typing import Any
def object_templates(today: dt.date | None = None) -> dict[str, dict[str, Any]]:
write_date = (today or dt.date.today()).strftime("%Y%m%d")
return {
"taxinvoice": {
"issueType": "정발행",
"chargeDirection": "정과금",
"purposeType": "청구",
"taxType": "과세",
"writeDate": write_date,
"invoicerMgtKey": "TEST-YYYYMMDD-001",
"invoicerCorpNum": "1234567890",
"invoicerCorpName": "공급자 상호",
"invoicerCEOName": "대표자",
"invoicerAddr": "공급자 주소",
"invoicerEmail": "tax@example.com",
"invoiceeType": "사업자",
"invoiceeCorpNum": "0987654321",
"invoiceeCorpName": "공급받는자 상호",
"invoiceeCEOName": "대표자",
"invoiceeAddr": "공급받는자 주소",
"invoiceeEmail1": "receiver@example.com",
"supplyCostTotal": "91",
"taxTotal": "9",
"totalAmount": "100",
"detailList": [
{
"__kind__": "taxinvoice-detail",
"serialNum": 1,
"itemName": "테스트 품목",
"supplyCost": "91",
"tax": "9",
}
],
},
"statement": {
"itemCode": 121,
"mgtKey": "STMT-YYYYMMDD-001",
"senderCorpNum": "1234567890",
"receiverCorpNum": "0987654321",
"detailList": [
{
"__kind__": "statement-detail",
"serialNum": 1,
"itemName": "테스트 품목",
"supplyCost": "91",
"tax": "9",
}
],
},
"cashbill": {
"mgtKey": "CASH-YYYYMMDD-001",
"tradeType": "승인거래",
"tradeUsage": "소득공제용",
"taxationType": "과세",
"totalAmount": "100",
"supplyCost": "91",
"tax": "9",
"serviceFee": "0",
"identityNum": "01000000000",
"customerName": "테스트",
},
"message-receiver": {"receiveNum": "01000000000", "receiveName": "테스트"},
"fax-receiver": {"receiveNum": "0200000000", "receiveName": "테스트"},
"kakao-receiver": {"rcv": "01000000000", "rcvnm": "테스트", "msg": "알림톡 테스트"},
}
skill.json
{
"name": "popbill",
"description": "팝빌 전자세금계산서·전자명세서·현금영수증·문자·카카오톡·팩스·휴폐업/기업정보·계좌조회·홈택스 수집을 테스트/운영 환경에서 안전하게 호출한다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.",
"profiles": [
"vault",
"action:submission"
],
"frontmatter": "name: popbill\ndescription: 팝빌 전자세금계산서·전자명세서·현금영수증·문자·카카오톡·팩스·휴폐업/기업정보·계좌조회·홈택스 수집을 테스트/운영 환경에서 안전하게 호출한다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.\nlicense: MIT\nmetadata:\n category: business\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: popbill
description: 팝빌 전자세금계산서·전자명세서·현금영수증·문자·카카오톡·팩스·휴폐업/기업정보·계좌조회·홈택스 수집을 테스트/운영 환경에서 안전하게 호출한다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# popbill
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct popbill
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files popbill
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/popbill/instruction.md.
## Legal disclaimer (required)
This skill is not an official feature of, officially supported by, affiliated with, sponsored by, approved by, or developed in collaboration with any third-party trademark owner or service operator it identifies. Third-party names are used only to describe the skill's function, lookup target, or compatibility.
Any automated collection of publicly accessible information must be limited to personal, non-organizational lookup. Do not use this skill for systematic or bulk crawling, database building, access-control or block circumvention, or conduct that interferes with a third party's business or service.
Read the full Korean legal disclaimer, including the cited Korean Supreme Court precedents and statutory limits, before use:
```bash
npx -y @nomadamas/k-skill@0 read popbill references/DISCLAIMER.md
```
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.
tests/fake_popbill.py
from __future__ import annotations
import sys
import types
from types import SimpleNamespace
def install_fake_popbill() -> None:
fake = types.ModuleType("popbill")
class FakeService:
def __init__(self, *args):
self.args = args
class FakeObject(SimpleNamespace):
pass
for name in (
"AccountCheckService",
"BizInfoCheckService",
"CashbillService",
"ClosedownService",
"EasyFinBankService",
"FaxService",
"HTCashbillService",
"HTTaxinvoiceService",
"KakaoService",
"MessageService",
"StatementService",
"TaxinvoiceService",
):
setattr(fake, name, type(name, (FakeService,), {}))
for name in (
"Cashbill",
"FaxReceiver",
"FileData",
"KakaoButton",
"KakaoReceiver",
"MessageReceiver",
"Statement",
"StatementDetail",
"Taxinvoice",
"TaxinvoiceDetail",
):
setattr(fake, name, type(name, (FakeObject,), {}))
setattr(fake, "PopbillException", type("PopbillException", (Exception,), {}))
sys.modules["popbill"] = fake
tests/run_tests.py
from __future__ import annotations
import importlib.util
import json
import sys
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from fake_popbill import install_fake_popbill
install_fake_popbill()
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "popbill_cli.py"
sys.path.insert(0, str(MODULE_PATH.parent))
spec = importlib.util.spec_from_file_location("popbill_cli", MODULE_PATH)
popbill_cli = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(popbill_cli)
def test_normalize_corp_num_accepts_hyphenated_number():
assert popbill_cli.normalize_corp_num("123-45-67890") == "1234567890"
def test_dangerous_methods_require_approval():
assert popbill_cli.is_dangerous_method("registIssue") is True
assert popbill_cli.is_dangerous_method("sendSMS") is True
assert popbill_cli.is_dangerous_method("CancelReserveRNbyRCV") is True
assert popbill_cli.is_dangerous_method("resendFax_multi") is True
assert popbill_cli.is_dangerous_method("quitMember") is True
assert popbill_cli.is_dangerous_method("delete") is True
assert popbill_cli.is_dangerous_method("getInfo") is False
assert popbill_cli.is_dangerous_method("getURL") is False
assert popbill_cli.is_dangerous_method("getPartnerBalance") is False
def test_nested_object_converts_kind_payloads():
value = popbill_cli.nested_object({"__kind__": "taxinvoice-detail", "serialNum": 1, "itemName": "테스트"})
assert value.__class__.__name__ == "TaxinvoiceDetail"
assert value.serialNum == 1
assert value.itemName == "테스트"
def test_service_coverage_contains_all_popbill_modules():
expected = {
"taxinvoice",
"statement",
"cashbill",
"message",
"kakao",
"fax",
"closedown",
"bizinfo",
"easyfin-bank",
"account-check",
"ht-taxinvoice",
"ht-cashbill",
}
assert expected <= set(popbill_cli.SERVICE_CLASSES)
def test_config_check_does_not_print_secret_values():
import os
old = dict(os.environ)
try:
os.environ["KSKILL_POPBILL_LINK_ID"] = "LINKSECRET"
os.environ["KSKILL_POPBILL_SECRET_KEY"] = "SUPERSECRET"
os.environ["KSKILL_POPBILL_CORP_NUM"] = "1234567890"
args = popbill_cli.build_parser().parse_args(["config-check"])
output = StringIO()
with redirect_stdout(output):
result = popbill_cli.command_config_check(args)
assert result == 0
printed = output.getvalue()
payload = json.loads(printed)
assert payload["ok"] is True
assert "LINKSECRET" not in printed
assert "SUPERSECRET" not in printed
finally:
os.environ.clear()
os.environ.update(old)
def main() -> int:
tests = [value for name, value in globals().items() if name.startswith("test_")]
for test in tests:
test()
print(f"{len(tests)} tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
tests/test_popbill_cli.py
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from fake_popbill import install_fake_popbill
install_fake_popbill()
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "popbill_cli.py"
sys.path.insert(0, str(MODULE_PATH.parent))
spec = importlib.util.spec_from_file_location("popbill_cli", MODULE_PATH)
popbill_cli = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(popbill_cli)
def test_normalize_corp_num_accepts_hyphenated_number():
assert popbill_cli.normalize_corp_num("123-45-67890") == "1234567890"
def test_dangerous_methods_require_approval():
assert popbill_cli.is_dangerous_method("registIssue") is True
assert popbill_cli.is_dangerous_method("sendSMS") is True
assert popbill_cli.is_dangerous_method("CancelReserveRNbyRCV") is True
assert popbill_cli.is_dangerous_method("resendFax_multi") is True
assert popbill_cli.is_dangerous_method("quitMember") is True
assert popbill_cli.is_dangerous_method("delete") is True
assert popbill_cli.is_dangerous_method("getInfo") is False
assert popbill_cli.is_dangerous_method("getURL") is False
assert popbill_cli.is_dangerous_method("getPartnerBalance") is False
def test_nested_object_converts_kind_payloads():
value = popbill_cli.nested_object({"__kind__": "taxinvoice-detail", "serialNum": 1, "itemName": "테스트"})
assert value.__class__.__name__ == "TaxinvoiceDetail"
assert value.serialNum == 1
assert value.itemName == "테스트"
def test_service_coverage_contains_all_popbill_modules():
expected = {
"taxinvoice",
"statement",
"cashbill",
"message",
"kakao",
"fax",
"closedown",
"bizinfo",
"easyfin-bank",
"account-check",
"ht-taxinvoice",
"ht-cashbill",
}
assert expected <= set(popbill_cli.SERVICE_CLASSES)
def test_config_check_does_not_print_secret_values(monkeypatch, capsys):
monkeypatch.setattr(popbill_cli, "load_dotenv", lambda: None)
monkeypatch.setenv("KSKILL_POPBILL_LINK_ID", "LINKSECRET")
monkeypatch.setenv("KSKILL_POPBILL_SECRET_KEY", "SUPERSECRET")
monkeypatch.setenv("KSKILL_POPBILL_CORP_NUM", "1234567890")
args = popbill_cli.build_parser().parse_args(["config-check"])
assert popbill_cli.command_config_check(args) == 0
output = capsys.readouterr().out
assert "LINKSECRET" not in output
assert "SUPERSECRET" not in output