refactor: [barobill] 바로빌 연동 코드 전면 개선

- config/services.php에 barobill 설정 등록 (운영/테스트 모드 분기 정상화)
- BarobillSetting 모델에 BelongsToTenant 적용 및 use_* 필드 casts 추가
- BarobillService API URL을 baroservice.com(SOAP)으로 수정
- BarobillService callApi 메서드 경로 하드코딩 제거 (서비스별 분기)
- BarobillService 예외 이중 래핑 문제 수정
- BarobillController URL 메서드 중복 코드 제거
- 누락 모델 16개 생성 (MNG 패턴 준수, BelongsToTenant 적용)
- 바로빌 전 테이블 options JSON 컬럼 추가 마이그레이션
This commit is contained in:
김보곤
2026-03-11 18:16:59 +09:00
parent 0be88f95ca
commit 18a6f3e7aa
21 changed files with 1390 additions and 79 deletions

View File

@@ -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'));
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillBankSyncStatus extends Model
{
use BelongsToTenant;
protected $table = 'barobill_bank_sync_status';
protected $fillable = [
'tenant_id',
'bank_account_num',
'synced_year_month',
'synced_at',
];
protected $casts = [
'synced_at' => 'datetime',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillBankTransaction extends Model
{
use BelongsToTenant;
protected $table = 'barobill_bank_transactions';
protected $fillable = [
'tenant_id',
'bank_account_num',
'bank_code',
'bank_name',
'trans_date',
'trans_time',
'trans_dt',
'deposit',
'withdraw',
'balance',
'summary',
'cast',
'memo',
'trans_office',
'account_code',
'account_name',
'is_manual',
'client_code',
'client_name',
];
protected $casts = [
'deposit' => '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();
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class BarobillBankTransactionOverride extends Model
{
use BelongsToTenant;
protected $table = 'barobill_bank_transaction_overrides';
protected $fillable = [
'tenant_id',
'unique_key',
'modified_summary',
'modified_cast',
];
// =========================================================================
// 스코프
// =========================================================================
public function scopeByUniqueKey($query, string $uniqueKey)
{
return $query->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]
);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillBankTransactionSplit extends Model
{
use BelongsToTenant;
protected $table = 'barobill_bank_transaction_splits';
protected $fillable = [
'tenant_id',
'original_unique_key',
'split_amount',
'account_code',
'account_name',
'description',
'memo',
'sort_order',
'bank_account_num',
'trans_dt',
'trans_date',
'original_deposit',
'original_withdraw',
'summary',
];
protected $casts = [
'split_amount' => '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();
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillBillingRecord extends Model
{
protected $table = 'barobill_billing_records';
public const SERVICE_TYPES = ['tax_invoice', 'bank_account', 'card', 'hometax'];
public const BILLING_TYPES = ['subscription', 'usage'];
protected $fillable = [
'member_id',
'billing_month',
'service_type',
'billing_type',
'quantity',
'unit_price',
'total_amount',
'billed_at',
'description',
];
protected $casts = [
'quantity' => '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,
};
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillCardTransaction extends Model
{
use BelongsToTenant;
protected $table = 'barobill_card_transactions';
protected $fillable = [
'tenant_id',
'card_num',
'card_company',
'card_company_name',
'use_dt',
'use_date',
'use_time',
'approval_num',
'approval_type',
'approval_amount',
'tax',
'service_charge',
'payment_plan',
'currency_code',
'merchant_name',
'merchant_biz_num',
'merchant_addr',
'merchant_ceo',
'merchant_biz_type',
'merchant_tel',
'memo',
'use_key',
'account_code',
'account_name',
'deduction_type',
'evidence_name',
'description',
'modified_supply_amount',
'modified_tax',
'is_manual',
];
protected $casts = [
'approval_amount' => '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();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillCardTransactionAmountLog extends Model
{
protected $table = 'barobill_card_transaction_amount_logs';
public $timestamps = false;
protected $fillable = [
'card_transaction_id',
'original_unique_key',
'before_supply_amount',
'before_tax',
'after_supply_amount',
'after_tax',
'modified_by',
'modified_by_name',
'ip_address',
];
protected $casts = [
'before_supply_amount' => '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');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class BarobillCardTransactionHide extends Model
{
use BelongsToTenant;
protected $table = 'barobill_card_transaction_hides';
protected $fillable = [
'tenant_id',
'original_unique_key',
'card_num',
'use_date',
'approval_num',
'original_amount',
'merchant_name',
'hidden_by',
];
protected $casts = [
'original_amount' => '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;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillCardTransactionSplit extends Model
{
use BelongsToTenant;
protected $table = 'barobill_card_transaction_splits';
protected $fillable = [
'tenant_id',
'original_unique_key',
'split_amount',
'split_supply_amount',
'split_tax',
'account_code',
'account_name',
'deduction_type',
'evidence_name',
'description',
'memo',
'sort_order',
'card_num',
'use_dt',
'use_date',
'approval_num',
'original_amount',
'merchant_name',
];
protected $casts = [
'split_amount' => '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();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class BarobillConfig extends Model
{
use SoftDeletes;
protected $table = 'barobill_configs';
protected $fillable = [
'name',
'environment',
'cert_key',
'corp_num',
'base_url',
'description',
'is_active',
];
protected $casts = [
'is_active' => '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);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class BarobillMember extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'barobill_members';
protected $fillable = [
'tenant_id',
'biz_no',
'corp_name',
'ceo_name',
'addr',
'biz_type',
'biz_class',
'barobill_id',
'barobill_pwd',
'manager_name',
'manager_email',
'manager_hp',
'status',
'server_mode',
'last_sales_fetch_at',
'last_purchases_fetch_at',
];
protected $casts = [
'barobill_pwd' => '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';
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillMonthlySummary extends Model
{
protected $table = 'barobill_monthly_summaries';
protected $fillable = [
'member_id',
'billing_month',
'bank_account_fee',
'card_fee',
'hometax_fee',
'subscription_total',
'tax_invoice_count',
'tax_invoice_amount',
'usage_total',
'grand_total',
];
protected $casts = [
'bank_account_fee' => '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);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
class BarobillPricingPolicy extends Model
{
protected $table = 'barobill_pricing_policies';
public const TYPE_CARD = 'card';
public const TYPE_TAX_INVOICE = 'tax_invoice';
public const TYPE_BANK_ACCOUNT = 'bank_account';
protected $fillable = [
'service_type',
'name',
'description',
'free_quota',
'free_quota_unit',
'additional_unit',
'additional_unit_label',
'additional_price',
'is_active',
'sort_order',
];
protected $casts = [
'free_quota' => '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;
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class BarobillSubscription extends Model
{
use SoftDeletes;
protected $table = 'barobill_subscriptions';
public const SERVICE_TYPES = ['bank_account', 'card', 'hometax'];
public const DEFAULT_MONTHLY_FEES = [
'bank_account' => 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,
};
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class HometaxInvoice extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'hometax_invoices';
// 과세유형
public const TAX_TYPE_TAXABLE = '01'; // 과세
public const TAX_TYPE_ZERO = '02'; // 영세
public const TAX_TYPE_EXEMPT = '03'; // 면세
// 영수/청구
public const PURPOSE_TYPE_RECEIPT = '01'; // 영수
public const PURPOSE_TYPE_CLAIM = '02'; // 청구
// 발급유형
public const ISSUE_TYPE_NORMAL = '01'; // 정발행
public const ISSUE_TYPE_REVERSE = '02'; // 역발행
protected $fillable = [
'tenant_id',
'nts_confirm_num',
'invoice_type',
'write_date',
'issue_date',
'send_date',
'invoicer_corp_num',
'invoicer_tax_reg_id',
'invoicer_corp_name',
'invoicer_ceo_name',
'invoicer_addr',
'invoicer_biz_type',
'invoicer_biz_class',
'invoicer_contact_id',
'invoicee_corp_num',
'invoicee_tax_reg_id',
'invoicee_corp_name',
'invoicee_ceo_name',
'invoicee_addr',
'invoicee_biz_type',
'invoicee_biz_class',
'invoicee_contact_id',
'supply_amount',
'tax_amount',
'total_amount',
'tax_type',
'purpose_type',
'issue_type',
'is_modified',
'original_nts_confirm_num',
'modify_code',
'remark1',
'remark2',
'remark3',
'item_name',
'item_count',
'item_unit_price',
'item_supply_amount',
'item_tax_amount',
'item_remark',
'account_code',
'account_name',
'deduction_type',
];
protected $casts = [
'supply_amount' => '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 ?? '',
};
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HometaxInvoiceJournal extends Model
{
use BelongsToTenant;
protected $table = 'hometax_invoice_journals';
protected $fillable = [
'tenant_id',
'hometax_invoice_id',
'nts_confirm_num',
'dc_type',
'account_code',
'account_name',
'debit_amount',
'credit_amount',
'description',
'sort_order',
'invoice_type',
'write_date',
'supply_amount',
'tax_amount',
'total_amount',
'trading_partner_name',
];
protected $casts = [
'debit_amount' => '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();
}
}

View File

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

View File

@@ -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 ?? '',
],
];

View File

@@ -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),
],
];

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 바로빌 관련 테이블에 options JSON 컬럼 추가
*
* SAM options 컬럼 정책에 따라 모든 비즈니스 테이블에
* 확장 가능한 options JSON 컬럼을 추가한다.
*
* @see docs/standards/options-column-policy.md
*/
return new class extends Migration
{
/**
* options 컬럼을 추가할 테이블 목록
*/
private array $tables = [
'barobill_settings',
'barobill_configs',
'barobill_members',
'barobill_subscriptions',
'barobill_billing_records',
'barobill_monthly_summaries',
'barobill_pricing_policies',
'barobill_bank_transactions',
'barobill_bank_transaction_overrides',
'barobill_bank_transaction_splits',
'barobill_bank_sync_status',
'barobill_card_transactions',
'barobill_card_transaction_splits',
'barobill_card_transaction_amount_logs',
'barobill_card_transaction_hides',
];
public function up(): void
{
foreach ($this->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');
});
}
}
}
};