feat:바로빌 과금관리 시스템 구현

- 모델: BarobillSubscription, BarobillBillingRecord, BarobillMonthlySummary
- 서비스: BarobillBillingService (구독/과금 처리 로직)
- API 컨트롤러: BarobillBillingController (구독/과금 CRUD)
- 뷰: 과금 현황 탭, 구독 관리 탭, 통계 카드, 상세 모달
- 라우트: 웹/API 라우트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-27 15:03:44 +09:00
parent aadd6d5e07
commit 39161d1203
14 changed files with 1881 additions and 0 deletions

View File

@@ -0,0 +1,379 @@
<?php
namespace App\Http\Controllers\Api\Admin\Barobill;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillBillingRecord;
use App\Models\Barobill\BarobillMember;
use App\Models\Barobill\BarobillMonthlySummary;
use App\Models\Barobill\BarobillSubscription;
use App\Services\Barobill\BarobillBillingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* 바로빌 과금 관리 API 컨트롤러
*/
class BarobillBillingController extends Controller
{
private const HEADQUARTERS_TENANT_ID = 1;
public function __construct(
protected BarobillBillingService $billingService
) {}
// ========================================
// 구독 관리
// ========================================
/**
* 구독 목록 조회
*/
public function subscriptions(Request $request): JsonResponse|Response
{
$tenantId = session('selected_tenant_id');
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
$allTenants = $request->boolean('all_tenants', false);
$query = BarobillSubscription::with(['member.tenant'])
->orderBy('member_id')
->orderBy('service_type');
// 테넌트 필터링
if (!$isHeadquarters && !$allTenants) {
$query->whereHas('member', function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId);
});
}
$subscriptions = $query->get();
if ($request->header('HX-Request')) {
return response(
view('barobill.billing.partials.subscription-table', [
'subscriptions' => $subscriptions,
'allTenants' => $isHeadquarters || $allTenants,
])->render(),
200,
['Content-Type' => 'text/html']
);
}
return response()->json([
'success' => true,
'data' => $subscriptions,
]);
}
/**
* 구독 등록/수정
*/
public function saveSubscription(Request $request): JsonResponse
{
$validated = $request->validate([
'member_id' => 'required|exists:barobill_members,id',
'service_type' => 'required|in:bank_account,card,hometax',
'monthly_fee' => 'nullable|integer|min:0',
'started_at' => 'nullable|date',
'ended_at' => 'nullable|date|after_or_equal:started_at',
'is_active' => 'nullable|boolean',
'memo' => 'nullable|string|max:500',
]);
$subscription = $this->billingService->saveSubscription(
$validated['member_id'],
$validated['service_type'],
$validated
);
return response()->json([
'success' => true,
'message' => '구독이 저장되었습니다.',
'data' => $subscription,
]);
}
/**
* 구독 해지
*/
public function cancelSubscription(int $id): JsonResponse
{
$result = $this->billingService->cancelSubscription($id);
if (!$result) {
return response()->json([
'success' => false,
'message' => '구독을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '구독이 해지되었습니다.',
]);
}
/**
* 회원사별 구독 현황 조회
*/
public function memberSubscriptions(int $memberId): JsonResponse|Response
{
$member = BarobillMember::with('tenant')->find($memberId);
if (!$member) {
return response()->json([
'success' => false,
'message' => '회원사를 찾을 수 없습니다.',
], 404);
}
$subscriptions = BarobillSubscription::where('member_id', $memberId)
->orderBy('service_type')
->get();
return response()->json([
'success' => true,
'data' => [
'member' => $member,
'subscriptions' => $subscriptions,
],
]);
}
// ========================================
// 과금 현황
// ========================================
/**
* 월별 과금 현황 목록
*/
public function billingList(Request $request): JsonResponse|Response
{
$tenantId = session('selected_tenant_id');
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
$allTenants = $request->boolean('all_tenants', false);
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
// 테넌트 필터링
$filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null;
$summaries = BarobillMonthlySummary::with(['member.tenant'])
->where('billing_month', $billingMonth)
->when($filterTenantId, function ($q) use ($filterTenantId) {
$q->whereHas('member', function ($q2) use ($filterTenantId) {
$q2->where('tenant_id', $filterTenantId);
});
})
->orderBy('grand_total', 'desc')
->get();
$total = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId);
if ($request->header('HX-Request')) {
return response(
view('barobill.billing.partials.billing-table', [
'summaries' => $summaries,
'total' => $total,
'billingMonth' => $billingMonth,
'allTenants' => $isHeadquarters || $allTenants,
])->render(),
200,
['Content-Type' => 'text/html']
);
}
return response()->json([
'success' => true,
'data' => $summaries,
'total' => $total,
]);
}
/**
* 월별 과금 통계
*/
public function billingStats(Request $request): JsonResponse|Response
{
$tenantId = session('selected_tenant_id');
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
$allTenants = $request->boolean('all_tenants', false);
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
$filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null;
$stats = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId);
if ($request->header('HX-Request')) {
return response(
view('barobill.billing.partials.billing-stats', compact('stats'))->render(),
200,
['Content-Type' => 'text/html']
);
}
return response()->json([
'success' => true,
'data' => $stats,
]);
}
/**
* 회원사별 과금 상세
*/
public function memberBilling(Request $request, int $memberId): JsonResponse|Response
{
$member = BarobillMember::with('tenant')->find($memberId);
if (!$member) {
return response()->json([
'success' => false,
'message' => '회원사를 찾을 수 없습니다.',
], 404);
}
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
$records = BarobillBillingRecord::where('member_id', $memberId)
->where('billing_month', $billingMonth)
->orderBy('service_type')
->get();
$summary = BarobillMonthlySummary::where('member_id', $memberId)
->where('billing_month', $billingMonth)
->first();
if ($request->header('HX-Request')) {
return response(
view('barobill.billing.partials.member-billing-detail', [
'member' => $member,
'records' => $records,
'summary' => $summary,
'billingMonth' => $billingMonth,
])->render(),
200,
['Content-Type' => 'text/html']
);
}
return response()->json([
'success' => true,
'data' => [
'member' => $member,
'records' => $records,
'summary' => $summary,
],
]);
}
/**
* 월별 과금 처리 (수동 실행)
*/
public function processBilling(Request $request): JsonResponse
{
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
$result = $this->billingService->processMonthlyBilling($billingMonth);
return response()->json([
'success' => true,
'message' => "과금 처리 완료: {$result['processed']}건 처리, {$result['skipped']}건 스킵",
'data' => $result,
]);
}
/**
* 연간 추이 조회
*/
public function yearlyTrend(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id');
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
$allTenants = $request->boolean('all_tenants', false);
$year = $request->input('year', now()->year);
$filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null;
$trend = $this->billingService->getYearlyTrend($year, $filterTenantId);
return response()->json([
'success' => true,
'data' => $trend,
]);
}
/**
* 엑셀 다운로드
*/
public function export(Request $request)
{
$tenantId = session('selected_tenant_id');
$isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID;
$allTenants = $request->boolean('all_tenants', false);
$billingMonth = $request->input('billing_month', now()->format('Y-m'));
$filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null;
$summaries = BarobillMonthlySummary::with(['member.tenant'])
->where('billing_month', $billingMonth)
->when($filterTenantId, function ($q) use ($filterTenantId) {
$q->whereHas('member', function ($q2) use ($filterTenantId) {
$q2->where('tenant_id', $filterTenantId);
});
})
->orderBy('grand_total', 'desc')
->get();
$total = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId);
$filename = "barobill_billing_{$billingMonth}.csv";
$headers = [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function () use ($summaries, $total, $isHeadquarters, $allTenants) {
$file = fopen('php://output', 'w');
fprintf($file, chr(0xEF) . chr(0xBB) . chr(0xBF));
// 헤더
$headerRow = ['사업자번호', '상호', '계좌조회', '카드내역', '홈텍스', '월정액합계', '세금계산서(건)', '세금계산서(원)', '건별합계', '총합계'];
if ($isHeadquarters || $allTenants) {
array_unshift($headerRow, 'T-ID', '테넌트');
}
fputcsv($file, $headerRow);
// 데이터
foreach ($summaries as $summary) {
$row = [
$summary->member->formatted_biz_no ?? '',
$summary->member->corp_name ?? '',
$summary->bank_account_fee,
$summary->card_fee,
$summary->hometax_fee,
$summary->subscription_total,
$summary->tax_invoice_count,
$summary->tax_invoice_amount,
$summary->usage_total,
$summary->grand_total,
];
if ($isHeadquarters || $allTenants) {
array_unshift($row, $summary->member->tenant_id ?? '', $summary->member->tenant->company_name ?? '');
}
fputcsv($file, $row);
}
// 합계
$totalRow = ['', '합계', $total['bank_account_fee'], $total['card_fee'], $total['hometax_fee'], $total['subscription_total'], $total['tax_invoice_count'], $total['tax_invoice_amount'], $total['usage_total'], $total['grand_total']];
if ($isHeadquarters || $allTenants) {
array_unshift($totalRow, '', '');
}
fputcsv($file, $totalRow);
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
}

View File

@@ -140,4 +140,26 @@ public function usage(Request $request): View|Response
return view('barobill.usage.index', compact('currentTenant', 'barobillMember', 'isHeadquarters'));
}
/**
* 과금관리 페이지 (본사 전용)
*/
public function billing(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('barobill.billing.index'));
}
// 현재 선택된 테넌트 정보
$tenantId = session('selected_tenant_id', 1);
$currentTenant = Tenant::find($tenantId);
// 본사(테넌트 1) 여부
$isHeadquarters = $tenantId == 1;
// 테넌트 목록 (전체 테넌트 모드에서 선택용)
$tenants = Tenant::select('id', 'company_name')->orderBy('company_name')->get();
return view('barobill.billing.index', compact('currentTenant', 'isHeadquarters', 'tenants'));
}
}

View File

@@ -0,0 +1,116 @@
<?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';
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',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 서비스 유형 라벨
*/
public const SERVICE_TYPES = [
'tax_invoice' => '전자세금계산서',
'bank_account' => '계좌조회',
'card' => '카드내역',
'hometax' => '홈텍스 매입/매출',
];
/**
* 과금 유형 라벨
*/
public const BILLING_TYPES = [
'subscription' => '월정액',
'usage' => '건별',
];
/**
* 건별 단가 (원)
*/
public const USAGE_UNIT_PRICES = [
'tax_invoice' => 100, // 전자세금계산서 건당 100원
];
/**
* 바로빌 회원사 관계
*/
public function member(): BelongsTo
{
return $this->belongsTo(BarobillMember::class, 'member_id');
}
/**
* 서비스 유형 라벨
*/
public function getServiceTypeLabelAttribute(): string
{
return self::SERVICE_TYPES[$this->service_type] ?? $this->service_type;
}
/**
* 과금 유형 라벨
*/
public function getBillingTypeLabelAttribute(): string
{
return self::BILLING_TYPES[$this->billing_type] ?? $this->billing_type;
}
/**
* 특정 월 조회
*/
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);
}
}

View File

@@ -0,0 +1,141 @@
<?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',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 바로빌 회원사 관계
*/
public function member(): BelongsTo
{
return $this->belongsTo(BarobillMember::class, 'member_id');
}
/**
* 특정 월 조회
*/
public function scopeOfMonth($query, string $billingMonth)
{
return $query->where('billing_month', $billingMonth);
}
/**
* 집계 데이터 갱신 또는 생성
*/
public static function updateOrCreateSummary(int $memberId, string $billingMonth): self
{
// 해당 월의 과금 기록 조회
$records = BarobillBillingRecord::where('member_id', $memberId)
->where('billing_month', $billingMonth)
->get();
$data = [
'bank_account_fee' => 0,
'card_fee' => 0,
'hometax_fee' => 0,
'subscription_total' => 0,
'tax_invoice_count' => 0,
'tax_invoice_amount' => 0,
'usage_total' => 0,
'grand_total' => 0,
];
foreach ($records as $record) {
if ($record->billing_type === 'subscription') {
switch ($record->service_type) {
case 'bank_account':
$data['bank_account_fee'] = $record->total_amount;
break;
case 'card':
$data['card_fee'] = $record->total_amount;
break;
case 'hometax':
$data['hometax_fee'] = $record->total_amount;
break;
}
$data['subscription_total'] += $record->total_amount;
} else {
// 건별 사용
if ($record->service_type === 'tax_invoice') {
$data['tax_invoice_count'] = $record->quantity;
$data['tax_invoice_amount'] = $record->total_amount;
}
$data['usage_total'] += $record->total_amount;
}
}
$data['grand_total'] = $data['subscription_total'] + $data['usage_total'];
return self::updateOrCreate(
['member_id' => $memberId, 'billing_month' => $billingMonth],
$data
);
}
/**
* 전체 회원사 월별 합계
*/
public static function getMonthlyTotal(string $billingMonth): array
{
$result = self::where('billing_month', $billingMonth)
->selectRaw('
COUNT(*) as member_count,
SUM(bank_account_fee) as bank_account_fee,
SUM(card_fee) as card_fee,
SUM(hometax_fee) as hometax_fee,
SUM(subscription_total) as subscription_total,
SUM(tax_invoice_count) as tax_invoice_count,
SUM(tax_invoice_amount) as tax_invoice_amount,
SUM(usage_total) as usage_total,
SUM(grand_total) as grand_total
')
->first();
return [
'member_count' => (int) ($result->member_count ?? 0),
'bank_account_fee' => (int) ($result->bank_account_fee ?? 0),
'card_fee' => (int) ($result->card_fee ?? 0),
'hometax_fee' => (int) ($result->hometax_fee ?? 0),
'subscription_total' => (int) ($result->subscription_total ?? 0),
'tax_invoice_count' => (int) ($result->tax_invoice_count ?? 0),
'tax_invoice_amount' => (int) ($result->tax_invoice_amount ?? 0),
'usage_total' => (int) ($result->usage_total ?? 0),
'grand_total' => (int) ($result->grand_total ?? 0),
];
}
}

View File

@@ -0,0 +1,119 @@
<?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';
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',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
/**
* 서비스 유형 라벨
*/
public const SERVICE_TYPES = [
'bank_account' => '계좌조회',
'card' => '카드내역',
'hometax' => '홈텍스 매입/매출',
];
/**
* 기본 월정액 (원)
*/
public const DEFAULT_MONTHLY_FEES = [
'bank_account' => 10000,
'card' => 10000,
'hometax' => 10000,
];
/**
* 바로빌 회원사 관계
*/
public function member(): BelongsTo
{
return $this->belongsTo(BarobillMember::class, 'member_id');
}
/**
* 서비스 유형 라벨
*/
public function getServiceTypeLabelAttribute(): string
{
return self::SERVICE_TYPES[$this->service_type] ?? $this->service_type;
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
if (!$this->is_active) {
return '비활성';
}
if ($this->ended_at && $this->ended_at->isPast()) {
return '종료';
}
return '구독중';
}
/**
* 상태 색상 클래스
*/
public function getStatusColorAttribute(): string
{
if (!$this->is_active) {
return 'bg-gray-100 text-gray-800';
}
if ($this->ended_at && $this->ended_at->isPast()) {
return 'bg-red-100 text-red-800';
}
return 'bg-green-100 text-green-800';
}
/**
* 활성 구독만 조회
*/
public function scopeActive($query)
{
return $query->where('is_active', true)
->where(function ($q) {
$q->whereNull('ended_at')
->orWhere('ended_at', '>=', now()->toDateString());
});
}
/**
* 특정 서비스 유형 조회
*/
public function scopeOfService($query, string $serviceType)
{
return $query->where('service_type', $serviceType);
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace App\Services\Barobill;
use App\Models\Barobill\BarobillBillingRecord;
use App\Models\Barobill\BarobillMember;
use App\Models\Barobill\BarobillMonthlySummary;
use App\Models\Barobill\BarobillSubscription;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 바로빌 과금 서비스
*
* 월정액 구독 관리 및 과금 처리
*/
class BarobillBillingService
{
/**
* 회원사의 구독 목록 조회
*/
public function getSubscriptions(int $memberId): array
{
return BarobillSubscription::where('member_id', $memberId)
->orderBy('service_type')
->get()
->toArray();
}
/**
* 구독 등록/수정
*/
public function saveSubscription(int $memberId, string $serviceType, array $data): BarobillSubscription
{
return BarobillSubscription::updateOrCreate(
[
'member_id' => $memberId,
'service_type' => $serviceType,
],
[
'monthly_fee' => $data['monthly_fee'] ?? BarobillSubscription::DEFAULT_MONTHLY_FEES[$serviceType] ?? 0,
'started_at' => $data['started_at'] ?? now()->toDateString(),
'ended_at' => $data['ended_at'] ?? null,
'is_active' => $data['is_active'] ?? true,
'memo' => $data['memo'] ?? null,
]
);
}
/**
* 구독 해지
*/
public function cancelSubscription(int $subscriptionId): bool
{
$subscription = BarobillSubscription::find($subscriptionId);
if (!$subscription) {
return false;
}
$subscription->update([
'ended_at' => now()->toDateString(),
'is_active' => false,
]);
return true;
}
/**
* 월별 과금 처리 (배치용)
*
* 매월 1일에 실행하여 전월 구독료 과금
*/
public function processMonthlyBilling(?string $billingMonth = null): array
{
$billingMonth = $billingMonth ?? now()->format('Y-m');
$billedAt = Carbon::parse($billingMonth . '-01');
$results = [
'billing_month' => $billingMonth,
'processed' => 0,
'skipped' => 0,
'errors' => [],
];
// 활성 구독 조회
$subscriptions = BarobillSubscription::active()
->with('member')
->get();
foreach ($subscriptions as $subscription) {
try {
// 이미 과금된 기록이 있는지 확인
$exists = BarobillBillingRecord::where('member_id', $subscription->member_id)
->where('billing_month', $billingMonth)
->where('service_type', $subscription->service_type)
->where('billing_type', 'subscription')
->exists();
if ($exists) {
$results['skipped']++;
continue;
}
// 과금 기록 생성
BarobillBillingRecord::create([
'member_id' => $subscription->member_id,
'billing_month' => $billingMonth,
'service_type' => $subscription->service_type,
'billing_type' => 'subscription',
'quantity' => 1,
'unit_price' => $subscription->monthly_fee,
'total_amount' => $subscription->monthly_fee,
'billed_at' => $billedAt,
'description' => BarobillSubscription::SERVICE_TYPES[$subscription->service_type] . ' 월정액',
]);
$results['processed']++;
} catch (\Exception $e) {
$results['errors'][] = [
'member_id' => $subscription->member_id,
'service_type' => $subscription->service_type,
'error' => $e->getMessage(),
];
Log::error('월정액 과금 처리 실패', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
// 월별 집계 갱신
$this->updateMonthlySummaries($billingMonth);
return $results;
}
/**
* 건별 사용량 과금 (세금계산서)
*/
public function recordUsage(int $memberId, string $serviceType, int $quantity, ?string $billingMonth = null): BarobillBillingRecord
{
$billingMonth = $billingMonth ?? now()->format('Y-m');
$unitPrice = BarobillBillingRecord::USAGE_UNIT_PRICES[$serviceType] ?? 0;
$record = BarobillBillingRecord::updateOrCreate(
[
'member_id' => $memberId,
'billing_month' => $billingMonth,
'service_type' => $serviceType,
'billing_type' => 'usage',
],
[
'quantity' => $quantity,
'unit_price' => $unitPrice,
'total_amount' => $quantity * $unitPrice,
'billed_at' => now()->toDateString(),
'description' => BarobillBillingRecord::SERVICE_TYPES[$serviceType] . ' ' . $quantity . '건',
]
);
// 집계 갱신
BarobillMonthlySummary::updateOrCreateSummary($memberId, $billingMonth);
return $record;
}
/**
* 월별 집계 갱신
*/
public function updateMonthlySummaries(string $billingMonth): void
{
// 해당 월에 과금 기록이 있는 회원사 조회
$memberIds = BarobillBillingRecord::where('billing_month', $billingMonth)
->distinct()
->pluck('member_id');
foreach ($memberIds as $memberId) {
BarobillMonthlySummary::updateOrCreateSummary($memberId, $billingMonth);
}
}
/**
* 월별 과금 현황 조회
*/
public function getMonthlyBillingList(string $billingMonth, ?int $tenantId = null): array
{
$query = BarobillMonthlySummary::with(['member.tenant'])
->where('billing_month', $billingMonth);
if ($tenantId) {
$query->whereHas('member', function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId);
});
}
return $query->orderBy('grand_total', 'desc')->get()->toArray();
}
/**
* 월별 합계 조회
*/
public function getMonthlyTotal(string $billingMonth, ?int $tenantId = null): array
{
$query = BarobillMonthlySummary::where('billing_month', $billingMonth);
if ($tenantId) {
$query->whereHas('member', function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId);
});
}
$result = $query->selectRaw('
COUNT(*) as member_count,
SUM(bank_account_fee) as bank_account_fee,
SUM(card_fee) as card_fee,
SUM(hometax_fee) as hometax_fee,
SUM(subscription_total) as subscription_total,
SUM(tax_invoice_count) as tax_invoice_count,
SUM(tax_invoice_amount) as tax_invoice_amount,
SUM(usage_total) as usage_total,
SUM(grand_total) as grand_total
')->first();
return [
'billing_month' => $billingMonth,
'member_count' => (int) ($result->member_count ?? 0),
'bank_account_fee' => (int) ($result->bank_account_fee ?? 0),
'card_fee' => (int) ($result->card_fee ?? 0),
'hometax_fee' => (int) ($result->hometax_fee ?? 0),
'subscription_total' => (int) ($result->subscription_total ?? 0),
'tax_invoice_count' => (int) ($result->tax_invoice_count ?? 0),
'tax_invoice_amount' => (int) ($result->tax_invoice_amount ?? 0),
'usage_total' => (int) ($result->usage_total ?? 0),
'grand_total' => (int) ($result->grand_total ?? 0),
];
}
/**
* 연간 과금 추이 조회
*/
public function getYearlyTrend(int $year, ?int $tenantId = null): array
{
$months = [];
for ($m = 1; $m <= 12; $m++) {
$billingMonth = sprintf('%d-%02d', $year, $m);
$months[$billingMonth] = $this->getMonthlyTotal($billingMonth, $tenantId);
}
return $months;
}
}

View File

@@ -0,0 +1,354 @@
@extends('layouts.app')
@section('title', '과금관리')
@section('content')
<!-- 현재 테넌트 정보 카드 -->
@if($currentTenant)
<div class="rounded-xl shadow-lg p-5 mb-6" style="background: linear-gradient(to right, #059669, #10b981); color: white;">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex items-center gap-4">
<div class="p-3 rounded-xl" style="background: rgba(255,255,255,0.2);">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: rgba(255,255,255,0.2);">T-ID: {{ $currentTenant->id }}</span>
@if($isHeadquarters ?? false)
<span class="px-2 py-0.5 rounded-full text-xs font-bold" style="background: #facc15; color: #713f12;">파트너사</span>
@endif
</div>
<h2 class="text-xl font-bold">{{ $currentTenant->company_name }}</h2>
</div>
</div>
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
<p class="text-xs" style="color: rgba(255,255,255,0.6);">조회 기준월</p>
<p class="font-medium" id="displayBillingMonth">{{ now()->format('Y년 m월') }}</p>
</div>
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
<p class="text-xs" style="color: rgba(255,255,255,0.6);">과금 유형</p>
<p class="font-medium">월정액 + 건별</p>
</div>
<div class="rounded-lg p-2" style="background: rgba(255,255,255,0.1);">
<p class="text-xs" style="color: rgba(255,255,255,0.6);">결제일</p>
<p class="font-medium">매월 1</p>
</div>
</div>
</div>
</div>
@endif
<div class="flex flex-col h-full">
<!-- 페이지 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6 flex-shrink-0">
<div>
<h1 class="text-2xl font-bold text-gray-800">과금관리</h1>
<p class="text-sm text-gray-500 mt-1">바로빌 월정액 구독 과금 현황 관리</p>
</div>
<div class="flex gap-2">
<button
type="button"
onclick="processBilling()"
class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
과금 처리
</button>
<button
type="button"
onclick="exportExcel()"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
엑셀
</button>
</div>
</div>
<!-- 네비게이션 -->
<div class="flex border-b border-gray-200 mb-6 flex-shrink-0">
<button type="button" onclick="switchTab('billing')" id="tab-billing" class="px-6 py-3 text-sm font-medium border-b-2 border-blue-600 text-blue-600">
과금 현황
</button>
<button type="button" onclick="switchTab('subscription')" id="tab-subscription" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
구독 관리
</button>
</div>
<!-- 통계 카드 -->
<div id="stats-container"
hx-get="/api/admin/barobill/billing/stats"
hx-trigger="load, billingUpdated from:body"
hx-include="#billingFilterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6 flex-shrink-0">
@include('barobill.billing.partials.stats-skeleton')
</div>
<!-- 필터 영역 -->
<div class="flex-shrink-0">
<x-filter-collapsible id="billingFilter">
<form id="billingFilterForm" class="flex flex-wrap gap-2 sm:gap-4 items-center">
<!-- 전체 테넌트 보기 토글 -->
@if($isHeadquarters ?? false)
<label class="flex items-center gap-2 px-3 py-2 bg-purple-50 border border-purple-200 rounded-lg cursor-pointer hover:bg-purple-100 transition-colors">
<input type="checkbox"
name="all_tenants"
value="1"
id="allTenantsToggle"
checked
class="w-4 h-4 rounded border-purple-300 text-purple-600 focus:ring-purple-500">
<span class="text-sm font-medium text-purple-700">전체 테넌트</span>
</label>
@endif
<!-- 기준월 -->
<div class="flex items-center gap-2">
<label for="billingMonth" class="text-sm font-medium text-gray-700 whitespace-nowrap">기준월</label>
<input type="month"
name="billing_month"
id="billingMonth"
value="{{ now()->format('Y-m') }}"
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
</div>
<!-- 빠른 선택 -->
<div class="flex gap-1">
<button type="button" onclick="setQuickMonth('thisMonth')" class="px-3 py-2 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
이번달
</button>
<button type="button" onclick="setQuickMonth('lastMonth')" class="px-3 py-2 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
지난달
</button>
</div>
<!-- 조회 버튼 -->
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
조회
</button>
</form>
</x-filter-collapsible>
</div>
<!-- 과금 현황 -->
<div id="content-billing" class="flex-1 flex flex-col min-h-0">
<div id="billing-table"
hx-get="/api/admin/barobill/billing/list"
hx-trigger="load, billingUpdated from:body"
hx-include="#billingFilterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden flex-1 flex flex-col min-h-0">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
</div>
<!-- 구독 관리 -->
<div id="content-subscription" class="hidden flex-1 flex flex-col min-h-0">
<div id="subscription-table"
hx-get="/api/admin/barobill/billing/subscriptions"
hx-trigger="subscriptionUpdated from:body"
hx-include="#billingFilterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden flex-1 flex flex-col min-h-0">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
</div>
</div>
<!-- 상세 모달 -->
<div id="detailModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeDetailModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white">
<div>
<h3 class="text-lg font-semibold text-gray-800">과금 상세</h3>
<p class="text-sm text-gray-500" id="detailModalTitle"></p>
</div>
<button type="button" onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div id="detailModalContent" class="p-6"></div>
<div class="px-6 py-4 border-t border-gray-100">
<button type="button" onclick="closeDetailModal()" class="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
닫기
</button>
</div>
</div>
</div>
</div>
<!-- 토스트 -->
<div id="toast" class="fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform translate-y-full opacity-0 transition-all duration-300 z-50"></div>
@endsection
@push('scripts')
<script>
// 현재 활성 탭
let currentTab = 'billing';
// 탭 전환
function switchTab(tab) {
currentTab = tab;
// 탭 버튼 스타일
document.getElementById('tab-billing').classList.toggle('border-blue-600', tab === 'billing');
document.getElementById('tab-billing').classList.toggle('text-blue-600', tab === 'billing');
document.getElementById('tab-billing').classList.toggle('border-transparent', tab !== 'billing');
document.getElementById('tab-billing').classList.toggle('text-gray-500', tab !== 'billing');
document.getElementById('tab-subscription').classList.toggle('border-blue-600', tab === 'subscription');
document.getElementById('tab-subscription').classList.toggle('text-blue-600', tab === 'subscription');
document.getElementById('tab-subscription').classList.toggle('border-transparent', tab !== 'subscription');
document.getElementById('tab-subscription').classList.toggle('text-gray-500', tab !== 'subscription');
// 컨텐츠 표시/숨김
document.getElementById('content-billing').classList.toggle('hidden', tab !== 'billing');
document.getElementById('content-subscription').classList.toggle('hidden', tab !== 'subscription');
// 구독 탭 전환 시 데이터 로드
if (tab === 'subscription') {
htmx.trigger(document.body, 'subscriptionUpdated');
}
}
// 폼 제출
document.getElementById('billingFilterForm').addEventListener('submit', function(e) {
e.preventDefault();
updateDisplayMonth();
htmx.trigger(document.body, 'billingUpdated');
});
// 기준월 표시 업데이트
function updateDisplayMonth() {
const month = document.getElementById('billingMonth').value;
const [year, mon] = month.split('-');
document.getElementById('displayBillingMonth').textContent = `${year}년 ${mon}월`;
}
// 빠른 월 선택
function setQuickMonth(period) {
const today = new Date();
let targetMonth;
if (period === 'thisMonth') {
targetMonth = today;
} else {
targetMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
}
const year = targetMonth.getFullYear();
const month = String(targetMonth.getMonth() + 1).padStart(2, '0');
document.getElementById('billingMonth').value = `${year}-${month}`;
updateDisplayMonth();
htmx.trigger(document.body, 'billingUpdated');
}
// 과금 처리
async function processBilling() {
if (!confirm('선택한 월의 과금을 처리하시겠습니까?')) return;
const billingMonth = document.getElementById('billingMonth').value;
try {
const res = await fetch('/api/admin/barobill/billing/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({ billing_month: billingMonth }),
});
const result = await res.json();
if (result.success) {
showToast(result.message, 'success');
htmx.trigger(document.body, 'billingUpdated');
} else {
showToast(result.message || '처리 실패', 'error');
}
} catch (error) {
showToast('오류가 발생했습니다.', 'error');
}
}
// 엑셀 다운로드
function exportExcel() {
const form = document.getElementById('billingFilterForm');
const formData = new FormData(form);
const params = new URLSearchParams(formData);
window.location.href = `/api/admin/barobill/billing/export?${params.toString()}`;
}
// 상세 모달
async function showDetailModal(memberId, memberName) {
document.getElementById('detailModalTitle').textContent = memberName;
document.getElementById('detailModalContent').innerHTML = `
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
`;
document.getElementById('detailModal').classList.remove('hidden');
try {
const billingMonth = document.getElementById('billingMonth').value;
const res = await fetch(`/api/admin/barobill/billing/member/${memberId}?billing_month=${billingMonth}`, {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'HX-Request': 'true',
},
});
if (res.ok) {
const html = await res.text();
document.getElementById('detailModalContent').innerHTML = html;
}
} catch (error) {
document.getElementById('detailModalContent').innerHTML = `
<div class="text-center text-red-500 py-8">조회 실패</div>
`;
}
}
function closeDetailModal() {
document.getElementById('detailModal').classList.add('hidden');
}
// 토스트
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50';
toast.classList.add(type === 'success' ? 'bg-green-600' : 'bg-red-600', 'text-white');
toast.classList.remove('translate-y-full', 'opacity-0');
setTimeout(() => {
toast.classList.add('translate-y-full', 'opacity-0');
}, 3000);
}
// ESC 키
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDetailModal();
});
</script>
@endpush

View File

@@ -0,0 +1,69 @@
<!-- 조회 회원사 -->
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-400 uppercase">회원사</h3>
<div class="p-1.5 rounded-lg bg-blue-50 text-blue-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
</div>
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['member_count']) }}</div>
<div class="text-xs text-gray-400 mt-1">과금 대상</div>
</div>
<!-- 월정액 합계 -->
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-400 uppercase">월정액</h3>
<div class="p-1.5 rounded-lg bg-green-50 text-green-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['subscription_total']) }}<span class="text-sm font-normal text-gray-500"></span></div>
<div class="text-xs text-gray-400 mt-1">계좌+카드+홈텍스</div>
</div>
<!-- 세금계산서 -->
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-400 uppercase">세금계산서</h3>
<div class="p-1.5 rounded-lg bg-purple-50 text-purple-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['tax_invoice_count']) }}<span class="text-sm font-normal text-gray-500"></span></div>
<div class="text-xs text-gray-400 mt-1">{{ number_format($stats['tax_invoice_amount']) }}</div>
</div>
<!-- 건별 합계 -->
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-400 uppercase">건별 합계</h3>
<div class="p-1.5 rounded-lg bg-indigo-50 text-indigo-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
</div>
</div>
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['usage_total']) }}<span class="text-sm font-normal text-gray-500"></span></div>
<div class="text-xs text-gray-400 mt-1">세금계산서 </div>
</div>
<!-- 총합계 -->
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100">
<div class="flex items-start justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-400 uppercase"> 과금액</h3>
<div class="p-1.5 rounded-lg bg-yellow-50 text-yellow-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['grand_total']) }}<span class="text-sm font-normal text-gray-500"></span></div>
<div class="text-xs text-gray-400 mt-1">월정액 + 건별</div>
</div>

View File

@@ -0,0 +1,156 @@
@if($summaries->isEmpty())
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">과금 내역이 없습니다</h3>
<p class="mt-1 text-sm text-gray-500">{{ $billingMonth }} 기준 과금 내역이 없습니다.</p>
<p class="mt-1 text-sm text-gray-500">"과금 처리" 버튼을 클릭하여 월정액을 처리하세요.</p>
</div>
@else
<div class="flex-1 overflow-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0 z-10">
<tr>
@if($allTenants ?? false)
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
T-ID
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
테넌트
</th>
@endif
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
사업자번호
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
상호명
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
계좌조회
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
카드내역
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
홈텍스
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
월정액
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
세금계산서
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
총합계
</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
상세
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($summaries as $summary)
<tr class="hover:bg-gray-50 transition-colors group">
@if($allTenants ?? false)
<td class="px-4 py-4 whitespace-nowrap text-center">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold bg-indigo-100 text-indigo-700">
{{ $summary->member->tenant_id ?? '-' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{{ $summary->member->tenant->company_name ?? '-' }}
</span>
</td>
@endif
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-mono text-gray-500">{{ $summary->member->formatted_biz_no ?? '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $summary->member->corp_name ?? '-' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm {{ $summary->bank_account_fee > 0 ? 'text-green-600 font-medium' : 'text-gray-400' }}">
{{ number_format($summary->bank_account_fee) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm {{ $summary->card_fee > 0 ? 'text-blue-600 font-medium' : 'text-gray-400' }}">
{{ number_format($summary->card_fee) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm {{ $summary->hometax_fee > 0 ? 'text-orange-600 font-medium' : 'text-gray-400' }}">
{{ number_format($summary->hometax_fee) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm font-medium text-gray-700">
{{ number_format($summary->subscription_total) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
@if($summary->tax_invoice_count > 0)
<div class="text-sm text-purple-600">
<span class="font-medium">{{ number_format($summary->tax_invoice_count) }}</span>
<span class="text-xs text-gray-400">({{ number_format($summary->tax_invoice_amount) }})</span>
</div>
@else
<span class="text-sm text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm font-bold text-gray-900">
{{ number_format($summary->grand_total) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<button
type="button"
onclick="showDetailModal({{ $summary->member_id }}, '{{ addslashes($summary->member->corp_name ?? '') }}')"
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg opacity-30 group-hover:opacity-100 transition"
title="상세보기"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</td>
</tr>
@endforeach
</tbody>
<!-- 합계 -->
<tfoot class="bg-gray-100 border-t-2 border-gray-300">
<tr>
@if($allTenants ?? false)
<td class="px-4 py-4" colspan="2"></td>
@endif
<td class="px-6 py-4" colspan="2">
<span class="text-sm font-bold text-gray-700">합계</span>
</td>
<td class="px-6 py-4 text-right">
<span class="text-sm font-bold text-green-600">{{ number_format($total['bank_account_fee']) }}</span>
</td>
<td class="px-6 py-4 text-right">
<span class="text-sm font-bold text-blue-600">{{ number_format($total['card_fee']) }}</span>
</td>
<td class="px-6 py-4 text-right">
<span class="text-sm font-bold text-orange-600">{{ number_format($total['hometax_fee']) }}</span>
</td>
<td class="px-6 py-4 text-right">
<span class="text-sm font-bold text-gray-700">{{ number_format($total['subscription_total']) }}</span>
</td>
<td class="px-6 py-4 text-right">
<span class="text-sm font-bold text-purple-600">{{ number_format($total['tax_invoice_amount']) }}</span>
</td>
<td class="px-6 py-4 text-right">
<span class="text-lg font-bold text-gray-900">{{ number_format($total['grand_total']) }}</span>
</td>
<td class="px-6 py-4"></td>
</tr>
</tfoot>
</table>
</div>
@endif

View File

@@ -0,0 +1,103 @@
{{-- 회원사 과금 상세 모달 내용 --}}
<div class="space-y-6">
<!-- 회원사 정보 -->
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-700 mb-3">회원사 정보</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">상호명</span>
<p class="font-medium text-gray-900">{{ $member->corp_name }}</p>
</div>
<div>
<span class="text-gray-500">사업자번호</span>
<p class="font-mono text-gray-900">{{ $member->formatted_biz_no }}</p>
</div>
<div>
<span class="text-gray-500">테넌트</span>
<p class="font-medium text-gray-900">{{ $member->tenant->company_name ?? '-' }}</p>
</div>
<div>
<span class="text-gray-500">과금 기준월</span>
<p class="font-medium text-gray-900">{{ $billingMonth }}</p>
</div>
</div>
</div>
<!-- 과금 내역 -->
@if($records->isNotEmpty())
<div class="space-y-3">
<h4 class="text-sm font-semibold text-gray-700">과금 내역</h4>
@foreach($records as $record)
@php
$colors = [
'bank_account' => ['bg' => 'bg-green-50', 'border' => 'border-green-100', 'text' => 'text-green-700', 'icon' => 'text-green-600'],
'card' => ['bg' => 'bg-blue-50', 'border' => 'border-blue-100', 'text' => 'text-blue-700', 'icon' => 'text-blue-600'],
'hometax' => ['bg' => 'bg-orange-50', 'border' => 'border-orange-100', 'text' => 'text-orange-700', 'icon' => 'text-orange-600'],
'tax_invoice' => ['bg' => 'bg-purple-50', 'border' => 'border-purple-100', 'text' => 'text-purple-700', 'icon' => 'text-purple-600'],
];
$color = $colors[$record->service_type] ?? ['bg' => 'bg-gray-50', 'border' => 'border-gray-100', 'text' => 'text-gray-700', 'icon' => 'text-gray-600'];
@endphp
<div class="flex items-center justify-between p-4 {{ $color['bg'] }} rounded-lg border {{ $color['border'] }}">
<div class="flex items-center gap-3">
<div class="p-2 bg-white/50 rounded-lg">
@if($record->service_type === 'tax_invoice')
<svg class="w-5 h-5 {{ $color['icon'] }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
@else
<svg class="w-5 h-5 {{ $color['icon'] }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
@endif
</div>
<div>
<p class="font-medium {{ $color['text'] }}">{{ $record->service_type_label }}</p>
<p class="text-xs {{ $color['icon'] }}">
{{ $record->billing_type_label }}
@if($record->billing_type === 'usage')
({{ number_format($record->quantity) }} x {{ number_format($record->unit_price) }})
@endif
</p>
</div>
</div>
<div class="text-right">
<p class="text-xl font-bold {{ $color['text'] }}">{{ number_format($record->total_amount) }}</p>
<p class="text-xs text-gray-500">{{ $record->billed_at?->format('Y-m-d') }}</p>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500">
<p> 기간에 과금 내역이 없습니다.</p>
</div>
@endif
<!-- 집계 -->
@if($summary)
<div class="bg-yellow-50 rounded-lg p-4 border-2 border-yellow-200">
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="text-center">
<p class="text-xs text-gray-500">월정액 합계</p>
<p class="text-lg font-bold text-gray-700">{{ number_format($summary->subscription_total) }}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500">건별 합계</p>
<p class="text-lg font-bold text-gray-700">{{ number_format($summary->usage_total) }}</p>
</div>
</div>
<div class="flex items-center justify-between pt-4 border-t border-yellow-200">
<div class="flex items-center gap-3">
<div class="p-2 bg-yellow-100 rounded-lg">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-lg font-semibold text-yellow-800"> 과금액</span>
</div>
<span class="text-2xl font-bold text-yellow-700">{{ number_format($summary->grand_total) }}</span>
</div>
</div>
@endif
</div>

View File

@@ -0,0 +1,11 @@
<!-- 통계 카드 스켈레톤 -->
@for($i = 0; $i < 5; $i++)
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-100 animate-pulse">
<div class="flex items-start justify-between mb-2">
<div class="h-3 bg-gray-200 rounded w-20"></div>
<div class="w-8 h-8 bg-gray-200 rounded-lg"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-16 mb-1"></div>
<div class="h-2 bg-gray-100 rounded w-24"></div>
</div>
@endfor

View File

@@ -0,0 +1,137 @@
@if($subscriptions->isEmpty())
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">등록된 구독이 없습니다</h3>
<p class="mt-1 text-sm text-gray-500">회원사에 월정액 서비스 구독을 등록해주세요.</p>
</div>
@else
<div class="flex-1 overflow-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 sticky top-0 z-10">
<tr>
@if($allTenants ?? false)
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
T-ID
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
테넌트
</th>
@endif
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
회원사
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
서비스
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
월정액
</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
시작일
</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
종료일
</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
상태
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
관리
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($subscriptions as $subscription)
<tr class="hover:bg-gray-50 transition-colors group">
@if($allTenants ?? false)
<td class="px-4 py-4 whitespace-nowrap text-center">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold bg-indigo-100 text-indigo-700">
{{ $subscription->member->tenant_id ?? '-' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{{ $subscription->member->tenant->company_name ?? '-' }}
</span>
</td>
@endif
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ $subscription->member->corp_name ?? '-' }}</div>
<div class="text-xs text-gray-400">{{ $subscription->member->formatted_biz_no ?? '' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@php
$serviceColors = [
'bank_account' => 'bg-green-100 text-green-800',
'card' => 'bg-blue-100 text-blue-800',
'hometax' => 'bg-orange-100 text-orange-800',
];
@endphp
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $serviceColors[$subscription->service_type] ?? 'bg-gray-100 text-gray-800' }}">
{{ $subscription->service_type_label }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm font-medium text-gray-900">{{ number_format($subscription->monthly_fee) }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm text-gray-600">{{ $subscription->started_at?->format('Y-m-d') ?? '-' }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm text-gray-600">{{ $subscription->ended_at?->format('Y-m-d') ?? '-' }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ $subscription->status_color }}">
{{ $subscription->status_label }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex justify-end gap-1 opacity-30 group-hover:opacity-100 transition-opacity">
@if($subscription->is_active && !$subscription->ended_at)
<button
type="button"
onclick="cancelSubscription({{ $subscription->id }}, '{{ addslashes($subscription->member->corp_name ?? '') }}', '{{ $subscription->service_type_label }}')"
class="p-2 text-red-600 hover:bg-red-50 rounded-lg"
title="구독 해지"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@endif
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
<script>
async function cancelSubscription(id, memberName, serviceName) {
if (!confirm(`${memberName}의 ${serviceName} 구독을 해지하시겠습니까?`)) return;
try {
const res = await fetch(`/api/admin/barobill/billing/subscriptions/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
});
const result = await res.json();
if (result.success) {
showToast(result.message, 'success');
htmx.trigger(document.body, 'subscriptionUpdated');
} else {
showToast(result.message || '처리 실패', 'error');
}
} catch (error) {
showToast('오류가 발생했습니다.', 'error');
}
}
</script>

View File

@@ -165,6 +165,27 @@
Route::get('/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillUsageController::class, 'show'])->name('show');
});
// 바로빌 과금관리 API
Route::prefix('barobill/billing')->name('barobill.billing.')->group(function () {
// 구독 관리
Route::get('/subscriptions', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'subscriptions'])->name('subscriptions');
Route::post('/subscriptions', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'saveSubscription'])->name('subscriptions.save');
Route::delete('/subscriptions/{id}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'cancelSubscription'])->name('subscriptions.cancel');
Route::get('/subscriptions/member/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'memberSubscriptions'])->name('subscriptions.member');
// 과금 현황
Route::get('/list', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'billingList'])->name('list');
Route::get('/stats', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'billingStats'])->name('stats');
Route::get('/member/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'memberBilling'])->name('member');
Route::get('/export', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'export'])->name('export');
// 과금 처리
Route::post('/process', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'processBilling'])->name('process');
// 연간 추이
Route::get('/yearly-trend', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'yearlyTrend'])->name('yearly-trend');
});
// 테넌트 관리 API
Route::prefix('tenants')->name('tenants.')->group(function () {
// 고정 경로는 먼저 정의

View File

@@ -320,6 +320,7 @@
Route::get('/card-usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'cardUsage'])->name('card-usage.index');
// Route::get('/hometax', ...) - 아래 hometax 그룹으로 이동됨
Route::get('/usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'usage'])->name('usage.index');
Route::get('/billing', [\App\Http\Controllers\Barobill\BarobillController::class, 'billing'])->name('billing.index');
// 기존 config 라우트 (호환성)
Route::get('/config', [\App\Http\Controllers\Barobill\BarobillController::class, 'config'])->name('config.index');