Files
sam-api/app/Services/BarobillService.php
hskwon 8ad4d7c0ce feat: Phase 3.8 바로빌 세금계산서 연동 API 구현
- 마이그레이션: barobill_settings, tax_invoices 테이블 생성
- 모델: BarobillSetting (인증서 암호화), TaxInvoice (상태/유형 상수)
- 서비스: BarobillService (API 연동), TaxInvoiceService (CRUD, 발행/취소)
- 컨트롤러: BarobillSettingController, TaxInvoiceController
- FormRequest: 6개 요청 검증 클래스
- Swagger: API 문서 완성 (BarobillSettingApi, TaxInvoiceApi)
2025-12-18 15:31:59 +09:00

407 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\Tenants\BarobillSetting;
use App\Models\Tenants\TaxInvoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* 바로빌 API 연동 서비스
*
* 바로빌 개발자센터: https://dev.barobill.co.kr/
*/
class BarobillService extends Service
{
/**
* 바로빌 API 기본 URL
*/
private const API_BASE_URL = 'https://ws.barobill.co.kr';
/**
* 바로빌 API 테스트 URL
*/
private const API_TEST_URL = 'https://testws.barobill.co.kr';
/**
* 테스트 모드 여부
*/
private bool $testMode;
public function __construct()
{
$this->testMode = config('services.barobill.test_mode', true);
}
// =========================================================================
// 설정 관리
// =========================================================================
/**
* 바로빌 설정 조회
*/
public function getSetting(): ?BarobillSetting
{
$tenantId = $this->tenantId();
return BarobillSetting::query()
->where('tenant_id', $tenantId)
->first();
}
/**
* 바로빌 설정 저장
*/
public function saveSetting(array $data): BarobillSetting
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$setting = BarobillSetting::query()
->where('tenant_id', $tenantId)
->first();
if ($setting) {
$setting->fill(array_merge($data, ['updated_by' => $userId]));
$setting->save();
} else {
$setting = BarobillSetting::create(array_merge($data, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
]));
}
return $setting->fresh();
}
/**
* 연동 테스트
*/
public function testConnection(): array
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
try {
// 바로빌 API 토큰 조회로 연동 테스트
$response = $this->callApi('GetAccessToken', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
]);
if (! empty($response['AccessToken'])) {
// 검증 성공 시 verified_at 업데이트
$setting->verified_at = now();
$setting->save();
return [
'success' => true,
'message' => __('message.barobill.connection_success'),
'verified_at' => $setting->verified_at->toDateTimeString(),
];
}
throw new BadRequestHttpException($response['Message'] ?? __('error.barobill.connection_failed'));
} catch (\Exception $e) {
Log::error('바로빌 연동 테스트 실패', [
'tenant_id' => $this->tenantId(),
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.connection_failed').': '.$e->getMessage());
}
}
// =========================================================================
// 세금계산서 발행
// =========================================================================
/**
* 세금계산서 발행
*/
public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
try {
// 바로빌 API 호출을 위한 데이터 구성
$apiData = $this->buildTaxInvoiceData($taxInvoice, $setting);
// 세금계산서 발행 API 호출
$response = $this->callApi('RegistAndIssueTaxInvoice', $apiData);
if (! empty($response['InvoiceID'])) {
// 발행 성공
$taxInvoice->barobill_invoice_id = $response['InvoiceID'];
$taxInvoice->nts_confirm_num = $response['NTSConfirmNum'] ?? null;
$taxInvoice->status = TaxInvoice::STATUS_ISSUED;
$taxInvoice->issued_at = now();
$taxInvoice->error_message = null;
$taxInvoice->save();
Log::info('세금계산서 발행 성공', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'barobill_invoice_id' => $response['InvoiceID'],
]);
return $taxInvoice->fresh();
}
throw new \Exception($response['Message'] ?? '발행 실패');
} catch (\Exception $e) {
// 발행 실패
$taxInvoice->status = TaxInvoice::STATUS_FAILED;
$taxInvoice->error_message = $e->getMessage();
$taxInvoice->save();
Log::error('세금계산서 발행 실패', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.issue_failed').': '.$e->getMessage());
}
}
/**
* 세금계산서 취소
*/
public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInvoice
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
if (! $taxInvoice->canCancel()) {
throw new BadRequestHttpException(__('error.barobill.cannot_cancel'));
}
try {
// 세금계산서 취소 API 호출
$response = $this->callApi('CancelTaxInvoice', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
'InvoiceID' => $taxInvoice->barobill_invoice_id,
'Memo' => $reason,
]);
if ($response['Result'] === 0 || ! empty($response['Success'])) {
$taxInvoice->status = TaxInvoice::STATUS_CANCELLED;
$taxInvoice->cancelled_at = now();
$taxInvoice->description = ($taxInvoice->description ? $taxInvoice->description."\n" : '').'취소 사유: '.$reason;
$taxInvoice->save();
Log::info('세금계산서 취소 성공', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'reason' => $reason,
]);
return $taxInvoice->fresh();
}
throw new \Exception($response['Message'] ?? '취소 실패');
} catch (\Exception $e) {
Log::error('세금계산서 취소 실패', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.cancel_failed').': '.$e->getMessage());
}
}
/**
* 국세청 전송 상태 조회
*/
public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
if (empty($taxInvoice->barobill_invoice_id)) {
throw new BadRequestHttpException(__('error.barobill.not_issued'));
}
try {
$response = $this->callApi('GetTaxInvoiceState', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
'InvoiceID' => $taxInvoice->barobill_invoice_id,
]);
if (! empty($response['State'])) {
$taxInvoice->nts_send_status = $response['State'];
// 국세청 전송 완료 시 상태 업데이트
if ($response['State'] === '전송완료' && ! $taxInvoice->sent_at) {
$taxInvoice->status = TaxInvoice::STATUS_SENT;
$taxInvoice->sent_at = now();
$taxInvoice->nts_confirm_num = $response['NTSConfirmNum'] ?? $taxInvoice->nts_confirm_num;
}
$taxInvoice->save();
}
return $taxInvoice->fresh();
} catch (\Exception $e) {
Log::error('국세청 전송 상태 조회 실패', [
'tenant_id' => $this->tenantId(),
'tax_invoice_id' => $taxInvoice->id,
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.status_check_failed').': '.$e->getMessage());
}
}
// =========================================================================
// Private 메서드
// =========================================================================
/**
* 바로빌 API 호출
*/
private function callApi(string $method, array $data): array
{
$baseUrl = $this->testMode ? self::API_TEST_URL : self::API_BASE_URL;
$url = $baseUrl.'/TI/'.$method;
$response = Http::timeout(30)
->withHeaders([
'Content-Type' => 'application/json',
])
->post($url, $data);
if ($response->failed()) {
throw new \Exception('API 호출 실패: '.$response->status());
}
return $response->json() ?? [];
}
/**
* 세금계산서 발행용 데이터 구성
*/
private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $setting): array
{
// 품목 데이터 구성
$items = [];
foreach ($taxInvoice->items ?? [] as $index => $item) {
$items[] = [
'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'),
'ItemName' => $item['name'] ?? '',
'Spec' => $item['spec'] ?? '',
'Qty' => $item['qty'] ?? 1,
'UnitCost' => $item['unit_price'] ?? 0,
'SupplyCost' => $item['supply_amt'] ?? 0,
'Tax' => $item['tax_amt'] ?? 0,
'Remark' => $item['remark'] ?? '',
];
}
// 품목이 없는 경우 기본 품목 추가
if (empty($items)) {
$items[] = [
'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'),
'ItemName' => $taxInvoice->description ?? '품목',
'Spec' => '',
'Qty' => 1,
'UnitCost' => (float) $taxInvoice->supply_amount,
'SupplyCost' => (float) $taxInvoice->supply_amount,
'Tax' => (float) $taxInvoice->tax_amount,
'Remark' => '',
];
}
return [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
'TaxInvoice' => [
'InvoiceType' => $this->mapInvoiceType($taxInvoice->invoice_type),
'IssueType' => $this->mapIssueType($taxInvoice->issue_type),
'TaxType' => '과세',
'PurposeType' => '영수',
'WriteDate' => $taxInvoice->issue_date->format('Ymd'),
// 공급자 정보
'InvoicerCorpNum' => $taxInvoice->supplier_corp_num,
'InvoicerCorpName' => $taxInvoice->supplier_corp_name,
'InvoicerCEOName' => $taxInvoice->supplier_ceo_name,
'InvoicerAddr' => $taxInvoice->supplier_addr,
'InvoicerBizType' => $taxInvoice->supplier_biz_type,
'InvoicerBizClass' => $taxInvoice->supplier_biz_class,
'InvoicerContactID' => $taxInvoice->supplier_contact_id,
// 공급받는자 정보
'InvoiceeCorpNum' => $taxInvoice->buyer_corp_num,
'InvoiceeCorpName' => $taxInvoice->buyer_corp_name,
'InvoiceeCEOName' => $taxInvoice->buyer_ceo_name,
'InvoiceeAddr' => $taxInvoice->buyer_addr,
'InvoiceeBizType' => $taxInvoice->buyer_biz_type,
'InvoiceeBizClass' => $taxInvoice->buyer_biz_class,
'InvoiceeContactID' => $taxInvoice->buyer_contact_id,
// 금액 정보
'SupplyCostTotal' => (int) $taxInvoice->supply_amount,
'TaxTotal' => (int) $taxInvoice->tax_amount,
'TotalAmount' => (int) $taxInvoice->total_amount,
// 품목 정보
'TaxInvoiceTradeLineItems' => $items,
// 비고
'Remark1' => $taxInvoice->description ?? '',
],
];
}
/**
* 세금계산서 유형 매핑
*/
private function mapInvoiceType(string $type): string
{
return match ($type) {
TaxInvoice::TYPE_TAX_INVOICE => '세금계산서',
TaxInvoice::TYPE_INVOICE => '계산서',
TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => '수정세금계산서',
default => '세금계산서',
};
}
/**
* 발행 유형 매핑
*/
private function mapIssueType(string $type): string
{
return match ($type) {
TaxInvoice::ISSUE_TYPE_NORMAL => '정발행',
TaxInvoice::ISSUE_TYPE_REVERSE => '역발행',
TaxInvoice::ISSUE_TYPE_TRUSTEE => '위수탁',
default => '정발행',
};
}
}