Files
sam-api/app/Services/BarobillService.php
김보곤 3b116c980b feat: [tax-invoice] 바로빌 SOAP 연동 및 공급자 설정 API 추가
- BarobillService HTTP→SOAP 전환 (MNG EtaxController 포팅)
- TI SOAP 클라이언트, callSoap(), buildTaxInvoiceData MNG 형식 적용
- issueTaxInvoice/cancelTaxInvoice/checkNtsSendStatus SOAP 방식
- 공급자 설정 조회/저장 API (GET/PUT /supplier-settings)
- 생성+즉시발행 통합 API (POST /issue-direct)
- SaveSupplierSettingsRequest FormRequest 추가
2026-02-21 17:19:18 +09:00

634 lines
22 KiB
PHP

<?php
namespace App\Services;
use App\Models\Tenants\BarobillSetting;
use App\Models\Tenants\TaxInvoice;
use Illuminate\Support\Facades\Log;
use SoapClient;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* 바로빌 API 연동 서비스 (SOAP)
*
* 바로빌 개발자센터: https://dev.barobill.co.kr/
* 바로빌은 SOAP API만 제공하므로 SoapClient를 사용합니다.
*/
class BarobillService extends Service
{
/**
* 바로빌 SOAP 기본 URL
*/
private const SOAP_BASE_URL = 'https://ws.baroservice.com';
/**
* 바로빌 SOAP 테스트 URL
*/
private const SOAP_TEST_URL = 'https://testws.baroservice.com';
/**
* 테스트 모드 여부
*/
private bool $testMode;
/**
* TI(Tax Invoice) SOAP 클라이언트
*/
private ?SoapClient $tiSoapClient = null;
public function __construct()
{
$this->testMode = config('services.barobill.test_mode', true);
}
// =========================================================================
// SOAP 클라이언트
// =========================================================================
/**
* TI SOAP 클라이언트 초기화/반환
*/
private function getTiSoapClient(): SoapClient
{
if ($this->tiSoapClient === null) {
$baseUrl = $this->testMode ? self::SOAP_TEST_URL : self::SOAP_BASE_URL;
$context = stream_context_create([
'ssl' => [
'verify_peer' => ! $this->testMode,
'verify_peer_name' => ! $this->testMode,
'allow_self_signed' => $this->testMode,
],
]);
$this->tiSoapClient = new SoapClient($baseUrl.'/TI.asmx?WSDL', [
'trace' => true,
'encoding' => 'UTF-8',
'exceptions' => true,
'connection_timeout' => 30,
'stream_context' => $context,
'cache_wsdl' => WSDL_CACHE_NONE,
]);
}
return $this->tiSoapClient;
}
/**
* SOAP API 호출
*
* MNG EtaxController::callBarobillSOAP() 포팅
* 음수 반환값 = 에러 코드 (바로빌 규격)
*/
private function callSoap(string $method, array $params): array
{
$client = $this->getTiSoapClient();
if (! isset($params['CERTKEY'])) {
$setting = $this->getSetting();
if (! $setting) {
return [
'success' => false,
'error' => '바로빌 설정이 없습니다.',
];
}
$params['CERTKEY'] = $setting->cert_key;
}
try {
$result = $client->$method($params);
$resultProperty = $method.'Result';
if (isset($result->$resultProperty)) {
$resultData = $result->$resultProperty;
// 바로빌 규격: 음수 반환값은 에러 코드
if (is_numeric($resultData) && $resultData < 0) {
return [
'success' => false,
'error' => '바로빌 API 오류 코드: '.$resultData,
'error_code' => (int) $resultData,
];
}
return ['success' => true, 'data' => $resultData];
}
return ['success' => true, 'data' => $result];
} catch (\SoapFault $e) {
return [
'success' => false,
'error' => 'SOAP 오류: '.$e->getMessage(),
];
} catch (\Throwable $e) {
return [
'success' => false,
'error' => 'API 호출 오류: '.$e->getMessage(),
];
}
}
// =========================================================================
// 설정 관리
// =========================================================================
/**
* 바로빌 설정 조회
*/
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();
}
/**
* 연동 테스트 (SOAP)
*/
public function testConnection(): array
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
try {
$response = $this->callSoap('GetAccessToken', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'ID' => $setting->barobill_id,
]);
if ($response['success']) {
$resultData = $response['data'];
// 양수 또는 문자열 토큰 = 성공
if (is_string($resultData) || (is_numeric($resultData) && $resultData > 0)) {
$setting->verified_at = now();
$setting->save();
return [
'success' => true,
'message' => __('message.barobill.connection_success'),
'verified_at' => $setting->verified_at->toDateTimeString(),
];
}
}
throw new \Exception($response['error'] ?? __('error.barobill.connection_failed'));
} catch (BadRequestHttpException $e) {
throw $e;
} catch (\Exception $e) {
Log::error('바로빌 연동 테스트 실패', [
'tenant_id' => $this->tenantId(),
'error' => $e->getMessage(),
]);
throw new BadRequestHttpException(__('error.barobill.connection_failed').': '.$e->getMessage());
}
}
// =========================================================================
// 사업자등록번호 검증
// =========================================================================
/**
* 사업자등록번호 유효성 검사 (휴폐업 조회)
*
* 바로빌 API를 통해 사업자등록번호의 유효성을 검증합니다.
* 바로빌 설정이 없는 경우 기본 형식 검증만 수행합니다.
*
* @param string $businessNumber 사업자등록번호 (10자리, 하이픈 제거)
* @return array{valid: bool, status: string, status_label: string, corp_name: ?string, ceo_name: ?string, message: string}
*/
public function checkBusinessNumber(string $businessNumber): array
{
// 하이픈 제거 및 숫자만 추출
$businessNumber = preg_replace('/[^0-9]/', '', $businessNumber);
// 기본 형식 검증 (10자리)
if (strlen($businessNumber) !== 10) {
return [
'valid' => false,
'status' => 'invalid_format',
'status_label' => '형식 오류',
'corp_name' => null,
'ceo_name' => null,
'message' => __('error.company.invalid_business_number_format'),
];
}
// 체크섬 검증 (사업자등록번호 자체 유효성)
if (! $this->validateBusinessNumberChecksum($businessNumber)) {
return [
'valid' => false,
'status' => 'invalid_checksum',
'status_label' => '유효하지 않음',
'corp_name' => null,
'ceo_name' => null,
'message' => __('error.company.invalid_business_number'),
];
}
// 바로빌 SOAP API 조회 시도
try {
$response = $this->callSoap('CheckCorpNum', [
'CorpNum' => $businessNumber,
]);
if ($response['success']) {
$resultData = $response['data'];
// 양수 결과 = 유효한 사업자
if (is_numeric($resultData) && $resultData >= 0) {
return [
'valid' => true,
'status' => 'active',
'status_label' => '유효함',
'corp_name' => null,
'ceo_name' => null,
'message' => __('message.company.business_number_valid'),
];
}
}
// API 실패 시 형식 검증 결과만 반환
return [
'valid' => true,
'status' => 'format_valid',
'status_label' => '형식 유효',
'corp_name' => null,
'ceo_name' => null,
'message' => __('message.company.business_number_format_valid'),
];
} catch (\Exception $e) {
// API 호출 실패 시 형식 검증 결과만 반환
Log::warning('바로빌 사업자번호 조회 실패', [
'business_number' => $businessNumber,
'error' => $e->getMessage(),
]);
return [
'valid' => true,
'status' => 'format_valid',
'status_label' => '형식 유효 (API 조회 불가)',
'corp_name' => null,
'ceo_name' => null,
'message' => __('message.company.business_number_format_valid'),
];
}
}
/**
* 사업자등록번호 체크섬 검증
*
* @param string $businessNumber 10자리 사업자등록번호
*/
private function validateBusinessNumberChecksum(string $businessNumber): bool
{
if (strlen($businessNumber) !== 10) {
return false;
}
$digits = str_split($businessNumber);
$multipliers = [1, 3, 7, 1, 3, 7, 1, 3, 5];
$sum = 0;
for ($i = 0; $i < 9; $i++) {
$sum += intval($digits[$i]) * $multipliers[$i];
}
// 8번째 자리 (인덱스 8)에 대한 추가 처리
$sum += intval(floor(intval($digits[8]) * 5 / 10));
$remainder = $sum % 10;
$checkDigit = (10 - $remainder) % 10;
return $checkDigit === intval($digits[9]);
}
// =========================================================================
// 세금계산서 발행
// =========================================================================
/**
* 세금계산서 발행 (SOAP RegistAndIssueTaxInvoice)
*
* MNG EtaxController::issueTaxInvoice() 포팅
*/
public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
{
$setting = $this->getSetting();
if (! $setting || ! $setting->canConnect()) {
throw new BadRequestHttpException(__('error.barobill.setting_not_configured'));
}
try {
$apiData = $this->buildTaxInvoiceData($taxInvoice, $setting);
$response = $this->callSoap('RegistAndIssueTaxInvoice', $apiData);
if ($response['success']) {
$resultData = $response['data'];
// 바로빌 규격: 양수 반환값이 Invoice ID
$taxInvoice->barobill_invoice_id = is_numeric($resultData) ? (string) $resultData : 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' => $taxInvoice->barobill_invoice_id,
]);
return $taxInvoice->fresh();
}
throw new \Exception($response['error'] ?? '발행 실패');
} catch (BadRequestHttpException $e) {
throw $e;
} 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());
}
}
/**
* 세금계산서 취소 (SOAP CancelTaxInvoice)
*/
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 {
$response = $this->callSoap('ProcTaxInvoice', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'MgtNum' => $taxInvoice->barobill_invoice_id,
'ProcType' => 4, // 4: 발행취소
'Memo' => $reason,
]);
if ($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['error'] ?? '취소 실패');
} catch (BadRequestHttpException $e) {
throw $e;
} 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());
}
}
/**
* 국세청 전송 상태 조회 (SOAP GetTaxInvoiceState)
*/
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->callSoap('GetTaxInvoiceState', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
'MgtNum' => $taxInvoice->barobill_invoice_id,
]);
if ($response['success'] && $response['data']) {
$stateData = $response['data'];
// SOAP 결과 객체에서 상태 추출
$state = is_object($stateData) ? ($stateData->NTSSendState ?? null) : null;
if ($state !== null) {
$taxInvoice->nts_send_status = (string) $state;
// 국세청 전송 완료 시 상태 업데이트
if (in_array($state, [3, '3', '전송완료']) && ! $taxInvoice->sent_at) {
$taxInvoice->status = TaxInvoice::STATUS_SENT;
$taxInvoice->sent_at = now();
if (is_object($stateData) && ! empty($stateData->NTSConfirmNum)) {
$taxInvoice->nts_confirm_num = $stateData->NTSConfirmNum;
}
}
$taxInvoice->save();
}
}
return $taxInvoice->fresh();
} catch (BadRequestHttpException $e) {
throw $e;
} 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 메서드
// =========================================================================
/**
* 세금계산서 발행용 데이터 구성 (MNG SOAP 형식)
*
* MNG EtaxController::issueTaxInvoice() 의 Invoice 구조를 포팅
* InvoicerParty/InvoiceeParty/TaxInvoiceTradeLineItems 중첩 구조
*/
private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $setting): array
{
$supplyAmt = (int) $taxInvoice->supply_amount;
$taxAmt = (int) $taxInvoice->tax_amount;
$total = (int) $taxInvoice->total_amount;
$taxType = $taxAmt == 0 ? 2 : 1; // 1:과세, 2:영세, 3:면세
// 관리번호 (유니크)
$mgtNum = 'SAM'.date('YmdHis').$taxInvoice->id;
// 품목 구성
$tradeLineItems = [];
foreach ($taxInvoice->items ?? [] as $item) {
$tradeLineItems[] = [
'PurchaseExpiry' => $taxInvoice->issue_date->format('Ymd'),
'Name' => $item['name'] ?? '',
'Information' => $item['spec'] ?? '',
'ChargeableUnit' => (string) ($item['qty'] ?? 1),
'UnitPrice' => (string) ($item['unit_price'] ?? 0),
'Amount' => (string) ($item['supply_amt'] ?? 0),
'Tax' => (string) ($item['tax_amt'] ?? 0),
'Description' => $item['remark'] ?? '',
];
}
// 품목이 없는 경우 기본 품목 추가
if (empty($tradeLineItems)) {
$tradeLineItems[] = [
'PurchaseExpiry' => $taxInvoice->issue_date->format('Ymd'),
'Name' => $taxInvoice->description ?? '품목',
'Information' => '',
'ChargeableUnit' => '1',
'UnitPrice' => (string) $supplyAmt,
'Amount' => (string) $supplyAmt,
'Tax' => (string) $taxAmt,
'Description' => '',
];
}
return [
'CorpNum' => $setting->corp_num,
'Invoice' => [
'IssueDirection' => 1, // 1: 정발행
'TaxInvoiceType' => $this->mapInvoiceTypeToCode($taxInvoice->invoice_type),
'ModifyCode' => '',
'TaxType' => $taxType,
'TaxCalcType' => 1, // 1: 소계합계
'PurposeType' => 2, // 2: 청구
'WriteDate' => $taxInvoice->issue_date->format('Ymd'),
'AmountTotal' => (string) $supplyAmt,
'TaxTotal' => (string) $taxAmt,
'TotalAmount' => (string) $total,
'Cash' => '0',
'ChkBill' => '0',
'Note' => '0',
'Credit' => (string) $total,
'Remark1' => $taxInvoice->description ?? '',
'Remark2' => '',
'Remark3' => '',
'InvoicerParty' => [
'MgtNum' => $mgtNum,
'CorpNum' => $taxInvoice->supplier_corp_num,
'TaxRegID' => '',
'CorpName' => $taxInvoice->supplier_corp_name,
'CEOName' => $taxInvoice->supplier_ceo_name ?? '',
'Addr' => $taxInvoice->supplier_addr ?? '',
'BizType' => $taxInvoice->supplier_biz_type ?? '',
'BizClass' => $taxInvoice->supplier_biz_class ?? '',
'ContactID' => $setting->barobill_id,
'ContactName' => $setting->contact_name ?? '',
'TEL' => $setting->contact_tel ?? '',
'HP' => '',
'Email' => $setting->contact_id ?? '',
],
'InvoiceeParty' => [
'MgtNum' => '',
'CorpNum' => str_replace('-', '', $taxInvoice->buyer_corp_num ?? ''),
'TaxRegID' => '',
'CorpName' => $taxInvoice->buyer_corp_name ?? '',
'CEOName' => $taxInvoice->buyer_ceo_name ?? '',
'Addr' => $taxInvoice->buyer_addr ?? '',
'BizType' => $taxInvoice->buyer_biz_type ?? '',
'BizClass' => $taxInvoice->buyer_biz_class ?? '',
'ContactID' => '',
'ContactName' => '',
'TEL' => '',
'HP' => '',
'Email' => $taxInvoice->buyer_contact_id ?? '',
],
'BrokerParty' => [],
'TaxInvoiceTradeLineItems' => [
'TaxInvoiceTradeLineItem' => $tradeLineItems,
],
],
'SendSMS' => false,
'ForceIssue' => false,
'MailTitle' => '',
];
}
/**
* 세금계산서 유형을 바로빌 코드로 매핑
*
* @return int 1: 세금계산서, 2: 계산서, 4: 수정세금계산서
*/
private function mapInvoiceTypeToCode(string $type): int
{
return match ($type) {
TaxInvoice::TYPE_TAX_INVOICE => 1,
TaxInvoice::TYPE_INVOICE => 2,
TaxInvoice::TYPE_MODIFIED_TAX_INVOICE => 4,
default => 1,
};
}
}