'/TI.asmx', // 세금계산서 'CORPSTATE' => '/CORPSTATE.asmx', // 회원/사업자 관리 'BANKACCOUNT' => '/BANKACCOUNT.asmx', // 계좌 조회 'CARD' => '/CARD.asmx', // 카드 조회 ]; /** * 메서드별 서비스 매핑 */ private const METHOD_SERVICE_MAP = [ 'GetAccessToken' => 'CORPSTATE', 'CheckCorpNum' => 'CORPSTATE', 'RegistCorp' => 'CORPSTATE', 'RegistAndIssueTaxInvoice' => 'TI', 'CancelTaxInvoice' => 'TI', 'GetTaxInvoiceState' => 'TI', ]; /** * 테스트 모드 여부 */ private bool $testMode; public function __construct() { $this->testMode = (bool) config('services.barobill.test_mode', true); } // ========================================================================= // 설정 관리 // ========================================================================= /** * 바로빌 설정 조회 */ public function getSetting(): ?BarobillSetting { return BarobillSetting::query()->first(); } /** * 바로빌 설정 저장 */ public function saveSetting(array $data): BarobillSetting { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $setting = BarobillSetting::query()->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 { $response = $this->callApi('GetAccessToken', [ 'CERTKEY' => $setting->cert_key, 'CorpNum' => $setting->corp_num, 'ID' => $setting->barobill_id, ]); if (! empty($response['AccessToken'])) { $setting->verified_at = now(); $setting->save(); return [ 'success' => true, 'message' => __('message.barobill.connection_success'), 'verified_at' => $setting->verified_at->toDateTimeString(), ]; } return [ 'success' => false, 'message' => $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 checkBusinessNumber(string $businessNumber): array { $businessNumber = preg_replace('/[^0-9]/', '', $businessNumber); 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'), ]; } try { $response = $this->callApi('CheckCorpNum', [ 'CorpNum' => $businessNumber, ]); if (isset($response['CorpState'])) { $state = $response['CorpState']; $isValid = in_array($state, ['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) { 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'), ]; } } /** * 사업자등록번호 체크섬 검증 */ 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]; } $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 { $apiData = $this->buildTaxInvoiceData($taxInvoice, $setting); $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 \RuntimeException($response['Message'] ?? '발행 실패'); } 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()); } } /** * 세금계산서 취소 */ 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->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 \RuntimeException($response['Message'] ?? '취소 실패'); } 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()); } } /** * 국세청 전송 상태 조회 */ 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()); } } // ========================================================================= // URL 헬퍼 // ========================================================================= /** * 바로빌 API base URL 반환 */ public function getBaseUrl(): string { return $this->testMode ? self::API_TEST_URL : self::API_BASE_URL; } /** * 테스트 모드 여부 */ public function isTestMode(): bool { return $this->testMode; } // ========================================================================= // Private 메서드 // ========================================================================= /** * 바로빌 API 호출 */ private function callApi(string $method, array $data): array { $baseUrl = $this->getBaseUrl(); $servicePath = self::METHOD_SERVICE_MAP[$method] ?? 'TI'; $path = self::SERVICE_PATHS[$servicePath] ?? '/TI.asmx'; $url = $baseUrl.$path.'/'.$method; $response = Http::timeout(30) ->withHeaders([ 'Content-Type' => 'application/json', ]) ->post($url, $data); if ($response->failed()) { throw new \RuntimeException('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 => '정발행', }; } }