- 마이그레이션: barobill_settings, tax_invoices 테이블 생성 - 모델: BarobillSetting (인증서 암호화), TaxInvoice (상태/유형 상수) - 서비스: BarobillService (API 연동), TaxInvoiceService (CRUD, 발행/취소) - 컨트롤러: BarobillSettingController, TaxInvoiceController - FormRequest: 6개 요청 검증 클래스 - Swagger: API 문서 완성 (BarobillSettingApi, TaxInvoiceApi)
407 lines
14 KiB
PHP
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 => '정발행',
|
|
};
|
|
}
|
|
}
|