Files
sam-api/app/Services/BarobillService.php
hskwon 7781253491 feat: Phase 8.3 회사 추가 API 구현
- 사업자등록번호 유효성 검사 API (바로빌 연동)
- 회사 추가 신청/승인/반려 워크플로우
- 신청 승인 시 테넌트 자동 생성 및 사용자 연결
- 관리자용 신청 목록/상세 조회
- 사용자용 내 신청 목록 조회
- Swagger 문서 및 i18n 메시지 추가
2025-12-22 15:30:38 +09:00

549 lines
19 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());
}
}
// =========================================================================
// 사업자등록번호 검증
// =========================================================================
/**
* 사업자등록번호 유효성 검사 (휴폐업 조회)
*
* 바로빌 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'),
];
}
// 바로빌 API 조회 시도
try {
$response = $this->callApi('CheckCorpNum', [
'CorpNum' => $businessNumber,
]);
// 바로빌 응답 해석
if (isset($response['CorpState'])) {
$state = $response['CorpState'];
$isValid = in_array($state, ['01', '02']); // 01: 사업중, 02: 휴업
$statusLabel = match ($state) {
'01' => '사업중',
'02' => '휴업',
'03' => '폐업',
default => '조회 불가',
};
return [
'valid' => $isValid,
'status' => $state,
'status_label' => $statusLabel,
'corp_name' => $response['CorpName'] ?? null,
'ceo_name' => $response['CEOName'] ?? null,
'message' => $isValid
? __('message.company.business_number_valid')
: __('error.company.business_closed'),
];
}
// 응답 형식이 다른 경우 (결과 코드 방식)
if (isset($response['Result'])) {
$isValid = $response['Result'] >= 0;
return [
'valid' => $isValid,
'status' => $isValid ? 'active' : 'unknown',
'status_label' => $isValid ? '유효함' : '조회 불가',
'corp_name' => $response['CorpName'] ?? null,
'ceo_name' => $response['CEOName'] ?? null,
'message' => $isValid
? __('message.company.business_number_valid')
: ($response['Message'] ?? __('error.company.check_failed')),
];
}
// 기본 응답 (체크섬만 통과한 경우)
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]);
}
// =========================================================================
// 세금계산서 발행
// =========================================================================
/**
* 세금계산서 발행
*/
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 => '정발행',
};
}
}