From 18a6f3e7aa37078b62fd4910c907e8232df10329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 18:16:59 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20[barobill]=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=EB=B9=8C=20=EC=97=B0=EB=8F=99=20=EC=BD=94=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config/services.php에 barobill 설정 등록 (운영/테스트 모드 분기 정상화) - BarobillSetting 모델에 BelongsToTenant 적용 및 use_* 필드 casts 추가 - BarobillService API URL을 baroservice.com(SOAP)으로 수정 - BarobillService callApi 메서드 경로 하드코딩 제거 (서비스별 분기) - BarobillService 예외 이중 래핑 문제 수정 - BarobillController URL 메서드 중복 코드 제거 - 누락 모델 16개 생성 (MNG 패턴 준수, BelongsToTenant 적용) - 바로빌 전 테이블 options JSON 컬럼 추가 마이그레이션 --- .../Controllers/Api/V1/BarobillController.php | 36 ++-- .../Barobill/BarobillBankSyncStatus.php | 34 ++++ .../Barobill/BarobillBankTransaction.php | 97 +++++++++++ .../BarobillBankTransactionOverride.php | 49 ++++++ .../Barobill/BarobillBankTransactionSplit.php | 69 ++++++++ app/Models/Barobill/BarobillBillingRecord.php | 82 +++++++++ .../Barobill/BarobillCardTransaction.php | 108 ++++++++++++ .../BarobillCardTransactionAmountLog.php | 41 +++++ .../Barobill/BarobillCardTransactionHide.php | 61 +++++++ .../Barobill/BarobillCardTransactionSplit.php | 74 ++++++++ app/Models/Barobill/BarobillConfig.php | 61 +++++++ app/Models/Barobill/BarobillMember.php | 82 +++++++++ .../Barobill/BarobillMonthlySummary.php | 53 ++++++ app/Models/Barobill/BarobillPricingPolicy.php | 82 +++++++++ app/Models/Barobill/BarobillSubscription.php | 76 +++++++++ app/Models/Barobill/HometaxInvoice.php | 158 ++++++++++++++++++ app/Models/Barobill/HometaxInvoiceJournal.php | 78 +++++++++ app/Models/Tenants/BarobillSetting.php | 33 ++++ app/Services/BarobillService.php | 123 +++++++------- config/services.php | 13 ++ ..._100000_add_options_to_barobill_tables.php | 59 +++++++ 21 files changed, 1390 insertions(+), 79 deletions(-) create mode 100644 app/Models/Barobill/BarobillBankSyncStatus.php create mode 100644 app/Models/Barobill/BarobillBankTransaction.php create mode 100644 app/Models/Barobill/BarobillBankTransactionOverride.php create mode 100644 app/Models/Barobill/BarobillBankTransactionSplit.php create mode 100644 app/Models/Barobill/BarobillBillingRecord.php create mode 100644 app/Models/Barobill/BarobillCardTransaction.php create mode 100644 app/Models/Barobill/BarobillCardTransactionAmountLog.php create mode 100644 app/Models/Barobill/BarobillCardTransactionHide.php create mode 100644 app/Models/Barobill/BarobillCardTransactionSplit.php create mode 100644 app/Models/Barobill/BarobillConfig.php create mode 100644 app/Models/Barobill/BarobillMember.php create mode 100644 app/Models/Barobill/BarobillMonthlySummary.php create mode 100644 app/Models/Barobill/BarobillPricingPolicy.php create mode 100644 app/Models/Barobill/BarobillSubscription.php create mode 100644 app/Models/Barobill/HometaxInvoice.php create mode 100644 app/Models/Barobill/HometaxInvoiceJournal.php create mode 100644 database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php diff --git a/app/Http/Controllers/Api/V1/BarobillController.php b/app/Http/Controllers/Api/V1/BarobillController.php index 9e90b91..a93307c 100644 --- a/app/Http/Controllers/Api/V1/BarobillController.php +++ b/app/Http/Controllers/Api/V1/BarobillController.php @@ -28,7 +28,7 @@ public function status() 'barobill_id' => $setting->barobill_id, 'biz_no' => $setting->corp_num, 'status' => $setting->isVerified() ? 'active' : 'inactive', - 'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production', + 'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production', ] : null, ]; }, __('message.fetched')); @@ -86,17 +86,21 @@ public function signup(Request $request) }, __('message.saved')); } + /** + * 바로빌 서비스 URL 조회 (공통) + */ + private function getServiceUrl(string $path): array + { + return ['url' => $this->barobillService->getBaseUrl().$path]; + } + /** * 은행 빠른조회 서비스 URL 조회 */ - public function bankServiceUrl(Request $request) + public function bankServiceUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Bank/BankAccountService']; + return $this->getServiceUrl('/BANKACCOUNT.asmx'); }, __('message.fetched')); } @@ -106,11 +110,7 @@ public function bankServiceUrl(Request $request) public function accountLinkUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Bank/AccountLink']; + return $this->getServiceUrl('/BANKACCOUNT.asmx'); }, __('message.fetched')); } @@ -120,11 +120,7 @@ public function accountLinkUrl() public function cardLinkUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Card/CardLink']; + return $this->getServiceUrl('/CARD.asmx'); }, __('message.fetched')); } @@ -134,11 +130,7 @@ public function cardLinkUrl() public function certificateUrl() { return ApiResponse::handle(function () { - $baseUrl = config('services.barobill.test_mode', true) - ? 'https://testws.barobill.co.kr' - : 'https://ws.barobill.co.kr'; - - return ['url' => $baseUrl.'/Certificate/Register']; + return $this->getServiceUrl('/CORPSTATE.asmx'); }, __('message.fetched')); } } diff --git a/app/Models/Barobill/BarobillBankSyncStatus.php b/app/Models/Barobill/BarobillBankSyncStatus.php new file mode 100644 index 0000000..e4431bb --- /dev/null +++ b/app/Models/Barobill/BarobillBankSyncStatus.php @@ -0,0 +1,34 @@ + 'datetime', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } +} diff --git a/app/Models/Barobill/BarobillBankTransaction.php b/app/Models/Barobill/BarobillBankTransaction.php new file mode 100644 index 0000000..3bc7c0e --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransaction.php @@ -0,0 +1,97 @@ + 'decimal:2', + 'withdraw' => 'decimal:2', + 'balance' => 'decimal:2', + 'is_manual' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 거래 고유 키 (계좌번호|거래일시|입금|출금|잔액) + */ + public function getUniqueKeyAttribute(): string + { + return static::generateUniqueKey([ + 'bank_account_num' => $this->bank_account_num, + 'trans_dt' => $this->trans_dt, + 'deposit' => $this->deposit, + 'withdraw' => $this->withdraw, + 'balance' => $this->balance, + ]); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function generateUniqueKey(array $data): string + { + return implode('|', [ + $data['bank_account_num'] ?? '', + $data['trans_dt'] ?? '', + $data['deposit'] ?? '0', + $data['withdraw'] ?? '0', + $data['balance'] ?? '0', + ]); + } + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $accountNum = null) + { + $query = static::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]); + + if ($accountNum) { + $query->where('bank_account_num', $accountNum); + } + + return $query->orderBy('trans_date')->orderBy('trans_dt')->get(); + } +} diff --git a/app/Models/Barobill/BarobillBankTransactionOverride.php b/app/Models/Barobill/BarobillBankTransactionOverride.php new file mode 100644 index 0000000..1d8d78a --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransactionOverride.php @@ -0,0 +1,49 @@ +where('unique_key', $uniqueKey); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByUniqueKeys(int $tenantId, array $uniqueKeys) + { + return static::where('tenant_id', $tenantId) + ->whereIn('unique_key', $uniqueKeys) + ->get() + ->keyBy('unique_key'); + } + + public static function saveOverride(int $tenantId, string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): self + { + return static::updateOrCreate( + ['tenant_id' => $tenantId, 'unique_key' => $uniqueKey], + ['modified_summary' => $modifiedSummary, 'modified_cast' => $modifiedCast] + ); + } +} diff --git a/app/Models/Barobill/BarobillBankTransactionSplit.php b/app/Models/Barobill/BarobillBankTransactionSplit.php new file mode 100644 index 0000000..35e5184 --- /dev/null +++ b/app/Models/Barobill/BarobillBankTransactionSplit.php @@ -0,0 +1,69 @@ + 'decimal:2', + 'original_deposit' => 'decimal:2', + 'original_withdraw' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('trans_date', [$startDate, $endDate]) + ->orderBy('original_unique_key') + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + } + + public static function getByUniqueKey(int $tenantId, string $uniqueKey) + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->orderBy('sort_order') + ->get(); + } +} diff --git a/app/Models/Barobill/BarobillBillingRecord.php b/app/Models/Barobill/BarobillBillingRecord.php new file mode 100644 index 0000000..5a87228 --- /dev/null +++ b/app/Models/Barobill/BarobillBillingRecord.php @@ -0,0 +1,82 @@ + 'integer', + 'unit_price' => 'integer', + 'total_amount' => 'integer', + 'billed_at' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeOfMonth($query, string $billingMonth) + { + return $query->where('billing_month', $billingMonth); + } + + public function scopeSubscription($query) + { + return $query->where('billing_type', 'subscription'); + } + + public function scopeUsage($query) + { + return $query->where('billing_type', 'usage'); + } + + public function scopeOfService($query, string $serviceType) + { + return $query->where('service_type', $serviceType); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + 'tax_invoice' => '전자세금계산서', + 'bank_account' => '계좌조회', + 'card' => '카드조회', + 'hometax' => '홈택스', + default => $this->service_type, + }; + } +} diff --git a/app/Models/Barobill/BarobillCardTransaction.php b/app/Models/Barobill/BarobillCardTransaction.php new file mode 100644 index 0000000..49611f9 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransaction.php @@ -0,0 +1,108 @@ + 'decimal:2', + 'tax' => 'decimal:2', + 'service_charge' => 'decimal:2', + 'modified_supply_amount' => 'decimal:2', + 'modified_tax' => 'decimal:2', + 'is_manual' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 거래 고유 키 (cardNum|useDt|approvalNum|approvalAmount) + */ + public function getUniqueKeyAttribute(): string + { + return static::generateUniqueKey([ + 'card_num' => $this->card_num, + 'use_dt' => $this->use_dt, + 'approval_num' => $this->approval_num, + 'approval_amount' => $this->approval_amount, + ]); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function generateUniqueKey(array $data): string + { + return implode('|', [ + $data['card_num'] ?? '', + $data['use_dt'] ?? '', + $data['approval_num'] ?? '', + $data['approval_amount'] ?? '0', + ]); + } + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $cardNum = null) + { + $query = static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]); + + if ($cardNum) { + $query->where('card_num', $cardNum); + } + + return $query->orderBy('use_date')->orderBy('use_dt')->get(); + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionAmountLog.php b/app/Models/Barobill/BarobillCardTransactionAmountLog.php new file mode 100644 index 0000000..0109c11 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionAmountLog.php @@ -0,0 +1,41 @@ + 'decimal:2', + 'before_tax' => 'decimal:2', + 'after_supply_amount' => 'decimal:2', + 'after_tax' => 'decimal:2', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function cardTransaction(): BelongsTo + { + return $this->belongsTo(BarobillCardTransaction::class, 'card_transaction_id'); + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionHide.php b/app/Models/Barobill/BarobillCardTransactionHide.php new file mode 100644 index 0000000..3112665 --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionHide.php @@ -0,0 +1,61 @@ + 'decimal:2', + ]; + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getHiddenKeys(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->pluck('original_unique_key') + ->toArray(); + } + + public static function hideTransaction(int $tenantId, string $uniqueKey, array $originalData, int $userId): self + { + return static::create([ + 'tenant_id' => $tenantId, + 'original_unique_key' => $uniqueKey, + 'card_num' => $originalData['card_num'] ?? '', + 'use_date' => $originalData['use_date'] ?? '', + 'approval_num' => $originalData['approval_num'] ?? '', + 'original_amount' => $originalData['approval_amount'] ?? 0, + 'merchant_name' => $originalData['merchant_name'] ?? '', + 'hidden_by' => $userId, + ]); + } + + public static function restoreTransaction(int $tenantId, string $uniqueKey): bool + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->delete() > 0; + } +} diff --git a/app/Models/Barobill/BarobillCardTransactionSplit.php b/app/Models/Barobill/BarobillCardTransactionSplit.php new file mode 100644 index 0000000..8daf73c --- /dev/null +++ b/app/Models/Barobill/BarobillCardTransactionSplit.php @@ -0,0 +1,74 @@ + 'decimal:2', + 'split_supply_amount' => 'decimal:2', + 'split_tax' => 'decimal:2', + 'original_amount' => 'decimal:2', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByDateRange(int $tenantId, string $startDate, string $endDate) + { + return static::where('tenant_id', $tenantId) + ->whereBetween('use_date', [$startDate, $endDate]) + ->orderBy('original_unique_key') + ->orderBy('sort_order') + ->get() + ->groupBy('original_unique_key'); + } + + public static function getByUniqueKey(int $tenantId, string $uniqueKey) + { + return static::where('tenant_id', $tenantId) + ->where('original_unique_key', $uniqueKey) + ->orderBy('sort_order') + ->get(); + } +} diff --git a/app/Models/Barobill/BarobillConfig.php b/app/Models/Barobill/BarobillConfig.php new file mode 100644 index 0000000..5962e02 --- /dev/null +++ b/app/Models/Barobill/BarobillConfig.php @@ -0,0 +1,61 @@ + 'boolean', + ]; + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getActiveTest(): ?self + { + return static::where('environment', 'test')->where('is_active', true)->first(); + } + + public static function getActiveProduction(): ?self + { + return static::where('environment', 'production')->where('is_active', true)->first(); + } + + public static function getActive(bool $isTestMode): ?self + { + return $isTestMode ? static::getActiveTest() : static::getActiveProduction(); + } + + public function getEnvironmentLabelAttribute(): string + { + return $this->environment === 'test' ? '테스트' : '운영'; + } + + public function getMaskedCertKeyAttribute(): string + { + $key = $this->cert_key; + if (strlen($key) <= 8) { + return str_repeat('*', strlen($key)); + } + + return substr($key, 0, 4).'****'.substr($key, -4); + } +} diff --git a/app/Models/Barobill/BarobillMember.php b/app/Models/Barobill/BarobillMember.php new file mode 100644 index 0000000..341bae4 --- /dev/null +++ b/app/Models/Barobill/BarobillMember.php @@ -0,0 +1,82 @@ + 'encrypted', + 'last_sales_fetch_at' => 'datetime', + 'last_purchases_fetch_at' => 'datetime', + ]; + + protected $hidden = [ + 'barobill_pwd', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getFormattedBizNoAttribute(): string + { + $num = $this->biz_no; + if (strlen($num) === 10) { + return substr($num, 0, 3).'-'.substr($num, 3, 2).'-'.substr($num, 5); + } + + return $num ?? ''; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + 'active' => '활성', + 'inactive' => '비활성', + 'pending' => '대기', + default => $this->status, + }; + } + + public function isTestMode(): bool + { + return $this->server_mode === 'test'; + } +} diff --git a/app/Models/Barobill/BarobillMonthlySummary.php b/app/Models/Barobill/BarobillMonthlySummary.php new file mode 100644 index 0000000..0ce3325 --- /dev/null +++ b/app/Models/Barobill/BarobillMonthlySummary.php @@ -0,0 +1,53 @@ + 'integer', + 'card_fee' => 'integer', + 'hometax_fee' => 'integer', + 'subscription_total' => 'integer', + 'tax_invoice_count' => 'integer', + 'tax_invoice_amount' => 'integer', + 'usage_total' => 'integer', + 'grand_total' => 'integer', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeOfMonth($query, string $billingMonth) + { + return $query->where('billing_month', $billingMonth); + } +} diff --git a/app/Models/Barobill/BarobillPricingPolicy.php b/app/Models/Barobill/BarobillPricingPolicy.php new file mode 100644 index 0000000..f816a35 --- /dev/null +++ b/app/Models/Barobill/BarobillPricingPolicy.php @@ -0,0 +1,82 @@ + 'integer', + 'additional_unit' => 'integer', + 'additional_price' => 'integer', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByServiceType(string $serviceType): ?self + { + return static::active()->where('service_type', $serviceType)->first(); + } + + public static function getAllActive() + { + return static::active()->orderBy('sort_order')->get(); + } + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + self::TYPE_CARD => '카드조회', + self::TYPE_TAX_INVOICE => '전자세금계산서', + self::TYPE_BANK_ACCOUNT => '계좌조회', + default => $this->service_type, + }; + } + + public function calculateBilling(int $usageCount): int + { + if ($usageCount <= $this->free_quota) { + return 0; + } + + $excess = $usageCount - $this->free_quota; + $units = (int) ceil($excess / max($this->additional_unit, 1)); + + return $units * $this->additional_price; + } +} diff --git a/app/Models/Barobill/BarobillSubscription.php b/app/Models/Barobill/BarobillSubscription.php new file mode 100644 index 0000000..bd6acbe --- /dev/null +++ b/app/Models/Barobill/BarobillSubscription.php @@ -0,0 +1,76 @@ + 10000, + 'card' => 10000, + 'hometax' => 0, + ]; + + protected $fillable = [ + 'member_id', + 'service_type', + 'monthly_fee', + 'started_at', + 'ended_at', + 'is_active', + 'memo', + ]; + + protected $casts = [ + 'monthly_fee' => 'integer', + 'started_at' => 'date', + 'ended_at' => 'date', + 'is_active' => 'boolean', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOfService($query, string $serviceType) + { + return $query->where('service_type', $serviceType); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getServiceTypeLabelAttribute(): string + { + return match ($this->service_type) { + 'bank_account' => '계좌조회', + 'card' => '카드조회', + 'hometax' => '홈택스', + default => $this->service_type, + }; + } +} diff --git a/app/Models/Barobill/HometaxInvoice.php b/app/Models/Barobill/HometaxInvoice.php new file mode 100644 index 0000000..f1ffab0 --- /dev/null +++ b/app/Models/Barobill/HometaxInvoice.php @@ -0,0 +1,158 @@ + 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'item_count' => 'integer', + 'item_unit_price' => 'integer', + 'item_supply_amount' => 'integer', + 'item_tax_amount' => 'integer', + 'is_modified' => 'boolean', + 'write_date' => 'date', + 'issue_date' => 'date', + 'send_date' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + public function journals(): HasMany + { + return $this->hasMany(HometaxInvoiceJournal::class, 'hometax_invoice_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeSales($query) + { + return $query->where('invoice_type', 'sales'); + } + + public function scopePurchase($query) + { + return $query->where('invoice_type', 'purchase'); + } + + public function scopePeriod($query, string $startDate, string $endDate) + { + return $query->whereBetween('write_date', [$startDate, $endDate]); + } + + // ========================================================================= + // 접근자 + // ========================================================================= + + public function getTaxTypeNameAttribute(): string + { + return match ($this->tax_type) { + self::TAX_TYPE_TAXABLE => '과세', + self::TAX_TYPE_ZERO => '영세', + self::TAX_TYPE_EXEMPT => '면세', + default => $this->tax_type ?? '', + }; + } + + public function getPurposeTypeNameAttribute(): string + { + return match ($this->purpose_type) { + self::PURPOSE_TYPE_RECEIPT => '영수', + self::PURPOSE_TYPE_CLAIM => '청구', + default => $this->purpose_type ?? '', + }; + } + + public function getIssueTypeNameAttribute(): string + { + return match ($this->issue_type) { + self::ISSUE_TYPE_NORMAL => '정발행', + self::ISSUE_TYPE_REVERSE => '역발행', + default => $this->issue_type ?? '', + }; + } +} diff --git a/app/Models/Barobill/HometaxInvoiceJournal.php b/app/Models/Barobill/HometaxInvoiceJournal.php new file mode 100644 index 0000000..c328c89 --- /dev/null +++ b/app/Models/Barobill/HometaxInvoiceJournal.php @@ -0,0 +1,78 @@ + 'integer', + 'credit_amount' => 'integer', + 'sort_order' => 'integer', + 'supply_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'write_date' => 'date', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function tenant(): BelongsTo + { + return $this->belongsTo(\App\Models\Tenants\Tenant::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(HometaxInvoice::class, 'hometax_invoice_id'); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public static function getByInvoiceId(int $tenantId, int $invoiceId) + { + return static::where('tenant_id', $tenantId) + ->where('hometax_invoice_id', $invoiceId) + ->orderBy('sort_order') + ->get(); + } + + public static function getJournaledInvoiceIds(int $tenantId, array $invoiceIds): array + { + return static::where('tenant_id', $tenantId) + ->whereIn('hometax_invoice_id', $invoiceIds) + ->distinct() + ->pluck('hometax_invoice_id') + ->toArray(); + } +} diff --git a/app/Models/Tenants/BarobillSetting.php b/app/Models/Tenants/BarobillSetting.php index 45ad09e..32e3a6e 100644 --- a/app/Models/Tenants/BarobillSetting.php +++ b/app/Models/Tenants/BarobillSetting.php @@ -2,12 +2,15 @@ namespace App\Models\Tenants; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Crypt; class BarobillSetting extends Model { + use BelongsToTenant; + protected $fillable = [ 'tenant_id', 'corp_num', @@ -23,6 +26,10 @@ class BarobillSetting extends Model 'contact_tel', 'is_active', 'auto_issue', + 'use_tax_invoice', + 'use_bank_account', + 'use_card_usage', + 'use_hometax', 'verified_at', 'created_by', 'updated_by', @@ -31,6 +38,10 @@ class BarobillSetting extends Model protected $casts = [ 'is_active' => 'boolean', 'auto_issue' => 'boolean', + 'use_tax_invoice' => 'boolean', + 'use_bank_account' => 'boolean', + 'use_card_usage' => 'boolean', + 'use_hometax' => 'boolean', 'verified_at' => 'datetime', ]; @@ -129,4 +140,26 @@ public function getFormattedCorpNumAttribute(): string return $num; } + + /** + * 활성화된 서비스 목록 + */ + public function getActiveServicesAttribute(): array + { + $services = []; + if ($this->use_tax_invoice) { + $services[] = 'tax_invoice'; + } + if ($this->use_bank_account) { + $services[] = 'bank_account'; + } + if ($this->use_card_usage) { + $services[] = 'card_usage'; + } + if ($this->use_hometax) { + $services[] = 'hometax'; + } + + return $services; + } } diff --git a/app/Services/BarobillService.php b/app/Services/BarobillService.php index 1d8451f..e63420f 100644 --- a/app/Services/BarobillService.php +++ b/app/Services/BarobillService.php @@ -12,18 +12,41 @@ * 바로빌 API 연동 서비스 * * 바로빌 개발자센터: https://dev.barobill.co.kr/ + * SOAP 서비스 URL: https://ws.baroservice.com/ (운영) / https://testws.baroservice.com/ (테스트) */ class BarobillService extends Service { /** - * 바로빌 API 기본 URL + * 바로빌 SOAP 서비스 기본 URL (운영) */ - private const API_BASE_URL = 'https://ws.barobill.co.kr'; + private const API_BASE_URL = 'https://ws.baroservice.com'; /** - * 바로빌 API 테스트 URL + * 바로빌 SOAP 서비스 테스트 URL */ - private const API_TEST_URL = 'https://testws.barobill.co.kr'; + private const API_TEST_URL = 'https://testws.baroservice.com'; + + /** + * API 서비스 경로 매핑 + */ + private const SERVICE_PATHS = [ + 'TI' => '/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', + ]; /** * 테스트 모드 여부 @@ -32,7 +55,7 @@ class BarobillService extends Service public function __construct() { - $this->testMode = config('services.barobill.test_mode', true); + $this->testMode = (bool) config('services.barobill.test_mode', true); } // ========================================================================= @@ -44,11 +67,7 @@ public function __construct() */ public function getSetting(): ?BarobillSetting { - $tenantId = $this->tenantId(); - - return BarobillSetting::query() - ->where('tenant_id', $tenantId) - ->first(); + return BarobillSetting::query()->first(); } /** @@ -59,9 +78,7 @@ public function saveSetting(array $data): BarobillSetting $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $setting = BarobillSetting::query() - ->where('tenant_id', $tenantId) - ->first(); + $setting = BarobillSetting::query()->first(); if ($setting) { $setting->fill(array_merge($data, ['updated_by' => $userId])); @@ -89,7 +106,6 @@ public function testConnection(): array } try { - // 바로빌 API 토큰 조회로 연동 테스트 $response = $this->callApi('GetAccessToken', [ 'CERTKEY' => $setting->cert_key, 'CorpNum' => $setting->corp_num, @@ -97,7 +113,6 @@ public function testConnection(): array ]); if (! empty($response['AccessToken'])) { - // 검증 성공 시 verified_at 업데이트 $setting->verified_at = now(); $setting->save(); @@ -108,7 +123,10 @@ public function testConnection(): array ]; } - throw new BadRequestHttpException($response['Message'] ?? __('error.barobill.connection_failed')); + return [ + 'success' => false, + 'message' => $response['Message'] ?? __('error.barobill.connection_failed'), + ]; } catch (\Exception $e) { Log::error('바로빌 연동 테스트 실패', [ 'tenant_id' => $this->tenantId(), @@ -125,19 +143,11 @@ public function testConnection(): array /** * 사업자등록번호 유효성 검사 (휴폐업 조회) - * - * 바로빌 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, @@ -149,7 +159,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 체크섬 검증 (사업자등록번호 자체 유효성) if (! $this->validateBusinessNumberChecksum($businessNumber)) { return [ 'valid' => false, @@ -161,16 +170,14 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 바로빌 API 조회 시도 try { $response = $this->callApi('CheckCorpNum', [ 'CorpNum' => $businessNumber, ]); - // 바로빌 응답 해석 if (isset($response['CorpState'])) { $state = $response['CorpState']; - $isValid = in_array($state, ['01', '02']); // 01: 사업중, 02: 휴업 + $isValid = in_array($state, ['01', '02']); $statusLabel = match ($state) { '01' => '사업중', '02' => '휴업', @@ -190,7 +197,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 응답 형식이 다른 경우 (결과 코드 방식) if (isset($response['Result'])) { $isValid = $response['Result'] >= 0; @@ -206,7 +212,6 @@ public function checkBusinessNumber(string $businessNumber): array ]; } - // 기본 응답 (체크섬만 통과한 경우) return [ 'valid' => true, 'status' => 'format_valid', @@ -216,7 +221,6 @@ public function checkBusinessNumber(string $businessNumber): array 'message' => __('message.company.business_number_format_valid'), ]; } catch (\Exception $e) { - // API 호출 실패 시 형식 검증 결과만 반환 Log::warning('바로빌 사업자번호 조회 실패', [ 'business_number' => $businessNumber, 'error' => $e->getMessage(), @@ -235,8 +239,6 @@ public function checkBusinessNumber(string $businessNumber): array /** * 사업자등록번호 체크섬 검증 - * - * @param string $businessNumber 10자리 사업자등록번호 */ private function validateBusinessNumberChecksum(string $businessNumber): bool { @@ -252,7 +254,6 @@ private function validateBusinessNumberChecksum(string $businessNumber): bool $sum += intval($digits[$i]) * $multipliers[$i]; } - // 8번째 자리 (인덱스 8)에 대한 추가 처리 $sum += intval(floor(intval($digits[8]) * 5 / 10)); $remainder = $sum % 10; @@ -277,14 +278,11 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice } 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; @@ -301,9 +299,10 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice return $taxInvoice->fresh(); } - throw new \Exception($response['Message'] ?? '발행 실패'); + 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(); @@ -334,7 +333,6 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv } try { - // 세금계산서 취소 API 호출 $response = $this->callApi('CancelTaxInvoice', [ 'CERTKEY' => $setting->cert_key, 'CorpNum' => $setting->corp_num, @@ -358,7 +356,9 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv return $taxInvoice->fresh(); } - throw new \Exception($response['Message'] ?? '취소 실패'); + throw new \RuntimeException($response['Message'] ?? '취소 실패'); + } catch (BadRequestHttpException $e) { + throw $e; } catch (\Exception $e) { Log::error('세금계산서 취소 실패', [ 'tenant_id' => $this->tenantId(), @@ -396,7 +396,6 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice 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(); @@ -418,6 +417,26 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice } } + // ========================================================================= + // 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 메서드 // ========================================================================= @@ -427,8 +446,10 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice */ private function callApi(string $method, array $data): array { - $baseUrl = $this->testMode ? self::API_TEST_URL : self::API_BASE_URL; - $url = $baseUrl.'/TI/'.$method; + $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([ @@ -437,7 +458,7 @@ private function callApi(string $method, array $data): array ->post($url, $data); if ($response->failed()) { - throw new \Exception('API 호출 실패: '.$response->status()); + throw new \RuntimeException('API 호출 실패: '.$response->status()); } return $response->json() ?? []; @@ -448,7 +469,6 @@ private function callApi(string $method, array $data): array */ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $setting): array { - // 품목 데이터 구성 $items = []; foreach ($taxInvoice->items ?? [] as $index => $item) { $items[] = [ @@ -463,7 +483,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se ]; } - // 품목이 없는 경우 기본 품목 추가 if (empty($items)) { $items[] = [ 'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'), @@ -487,8 +506,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se 'TaxType' => '과세', 'PurposeType' => '영수', 'WriteDate' => $taxInvoice->issue_date->format('Ymd'), - - // 공급자 정보 'InvoicerCorpNum' => $taxInvoice->supplier_corp_num, 'InvoicerCorpName' => $taxInvoice->supplier_corp_name, 'InvoicerCEOName' => $taxInvoice->supplier_ceo_name, @@ -496,8 +513,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se '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, @@ -505,16 +520,10 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se '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 ?? '', ], ]; diff --git a/config/services.php b/config/services.php index 6a99ace..1f29e1e 100644 --- a/config/services.php +++ b/config/services.php @@ -58,4 +58,17 @@ 'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'), ], + /* + |-------------------------------------------------------------------------- + | BaroBill (바로빌 전자세금계산서/회계 연동) + |-------------------------------------------------------------------------- + | MNG와 동일한 설정 구조를 사용한다. + */ + 'barobill' => [ + 'cert_key_test' => env('BAROBILL_CERT_KEY_TEST', ''), + 'cert_key_prod' => env('BAROBILL_CERT_KEY_PROD', ''), + 'corp_num' => env('BAROBILL_CORP_NUM', ''), + 'test_mode' => env('BAROBILL_TEST_MODE', true), + ], + ]; diff --git a/database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php b/database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php new file mode 100644 index 0000000..f4d49c8 --- /dev/null +++ b/database/migrations/2026_03_11_100000_add_options_to_barobill_tables.php @@ -0,0 +1,59 @@ +tables as $table) { + if (Schema::hasTable($table) && ! Schema::hasColumn($table, 'options')) { + Schema::table($table, function (Blueprint $table) { + $table->json('options')->nullable()->after('id'); + }); + } + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'options')) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + } + } +};