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, }; } }