feat: [barobill] 구독/과금/사용량 관리 API 이관

- BarobillBillingService 생성 (구독 CRUD, 월별 과금 배치, 사용량 과금, 집계)
- BarobillUsageService 생성 (회원별 사용량 조회, 통계, 정책 기반 과금 계산)
- BarobillBillingController 생성 (9개 엔드포인트)
  - 구독 관리, 월별 현황, 과금 배치, 연간 추이, 정책 관리
- BarobillUsageController 생성 (4개 엔드포인트)
  - 사용량 목록/통계, 회원별 상세, 과금 정책 정보
- finance.php 라우트 등록 (barobill/billing/*, barobill/usage/*)
This commit is contained in:
김보곤
2026-03-22 19:59:41 +09:00
parent a1f3de782f
commit ac94602cf1
5 changed files with 759 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Barobill\BarobillBillingService;
use Carbon\Carbon;
use Illuminate\Http\Request;
class BarobillBillingController extends Controller
{
public function __construct(
private BarobillBillingService $billingService,
) {}
/**
* 구독 목록
*/
public function subscriptions(Request $request)
{
$data = $request->validate([
'member_id' => 'nullable|integer',
]);
return ApiResponse::handle(function () use ($data) {
return [
'subscriptions' => $this->billingService->getSubscriptions($data['member_id'] ?? null),
];
}, __('message.fetched'));
}
/**
* 구독 등록/수정
*/
public function saveSubscription(Request $request)
{
$data = $request->validate([
'member_id' => 'required|integer|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',
]);
return ApiResponse::handle(function () use ($data) {
return $this->billingService->saveSubscription(
$data['member_id'],
$data['service_type'],
$data
);
}, __('message.created'));
}
/**
* 구독 해지
*/
public function cancelSubscription(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->billingService->cancelSubscription($id);
return ['cancelled' => true];
}, __('message.deleted'));
}
/**
* 월별 과금 현황 목록
*/
public function billingList(Request $request)
{
$data = $request->validate([
'billing_month' => 'nullable|string|date_format:Y-m',
'tenant_id' => 'nullable|integer',
]);
return ApiResponse::handle(function () use ($data) {
$billingMonth = $data['billing_month'] ?? Carbon::now()->format('Y-m');
$tenantId = $data['tenant_id'] ?? null;
return [
'list' => $this->billingService->getMonthlyBillingList($billingMonth, $tenantId),
'total' => $this->billingService->getMonthlyTotal($billingMonth, $tenantId),
'billing_month' => $billingMonth,
];
}, __('message.fetched'));
}
/**
* 월별 과금 통계
*/
public function billingStats(Request $request)
{
$data = $request->validate([
'billing_month' => 'nullable|string|date_format:Y-m',
'tenant_id' => 'nullable|integer',
]);
return ApiResponse::handle(function () use ($data) {
$billingMonth = $data['billing_month'] ?? Carbon::now()->format('Y-m');
return $this->billingService->getMonthlyTotal($billingMonth, $data['tenant_id'] ?? null);
}, __('message.fetched'));
}
/**
* 월별 과금 배치 처리
*/
public function processBilling(Request $request)
{
$data = $request->validate([
'billing_month' => 'nullable|string|date_format:Y-m',
]);
return ApiResponse::handle(function () use ($data) {
return $this->billingService->processMonthlyBilling($data['billing_month'] ?? null);
}, __('message.created'));
}
/**
* 연간 과금 추이
*/
public function yearlyTrend(Request $request)
{
$data = $request->validate([
'year' => 'nullable|integer|min:2020|max:2030',
'tenant_id' => 'nullable|integer',
]);
return ApiResponse::handle(function () use ($data) {
$year = $data['year'] ?? Carbon::now()->year;
return [
'trend' => $this->billingService->getYearlyTrend($year, $data['tenant_id'] ?? null),
'year' => $year,
];
}, __('message.fetched'));
}
/**
* 과금 정책 목록
*/
public function pricingPolicies()
{
return ApiResponse::handle(function () {
return ['policies' => $this->billingService->getPricingPolicies()];
}, __('message.fetched'));
}
/**
* 과금 정책 수정
*/
public function updatePricingPolicy(Request $request, int $id)
{
$data = $request->validate([
'name' => 'nullable|string|max:100',
'description' => 'nullable|string|max:500',
'free_quota' => 'nullable|integer|min:0',
'free_quota_unit' => 'nullable|string|max:10',
'additional_unit' => 'nullable|integer|min:1',
'additional_unit_label' => 'nullable|string|max:10',
'additional_price' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
]);
return ApiResponse::handle(function () use ($id, $data) {
return $this->billingService->updatePricingPolicy($id, $data);
}, __('message.updated'));
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillMember;
use App\Services\Barobill\BarobillUsageService;
use Carbon\Carbon;
use Illuminate\Http\Request;
class BarobillUsageController extends Controller
{
public function __construct(
private BarobillUsageService $usageService,
) {}
/**
* 사용량 목록
*/
public function index(Request $request)
{
$data = $request->validate([
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d',
'tenant_id' => 'nullable|integer',
]);
return ApiResponse::handle(function () use ($data) {
$startDate = $data['start_date'] ?? Carbon::now()->startOfMonth()->format('Y-m-d');
$endDate = $data['end_date'] ?? Carbon::now()->format('Y-m-d');
$tenantId = $data['tenant_id'] ?? null;
$usageList = $this->usageService->getUsageList($startDate, $endDate, $tenantId);
return [
'data' => $usageList,
'stats' => $this->usageService->aggregateStats($usageList),
'meta' => [
'start_date' => $startDate,
'end_date' => $endDate,
],
];
}, __('message.fetched'));
}
/**
* 사용량 통계
*/
public function stats(Request $request)
{
$data = $request->validate([
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d',
'tenant_id' => 'nullable|integer',
]);
return ApiResponse::handle(function () use ($data) {
$startDate = $data['start_date'] ?? Carbon::now()->startOfMonth()->format('Y-m-d');
$endDate = $data['end_date'] ?? Carbon::now()->format('Y-m-d');
$usageList = $this->usageService->getUsageList($startDate, $endDate, $data['tenant_id'] ?? null);
return $this->usageService->aggregateStats($usageList);
}, __('message.fetched'));
}
/**
* 회원별 사용량 상세
*/
public function show(Request $request, int $memberId)
{
$data = $request->validate([
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d',
]);
return ApiResponse::handle(function () use ($memberId, $data) {
$member = BarobillMember::withoutGlobalScopes()->findOrFail($memberId);
$startDate = $data['start_date'] ?? Carbon::now()->startOfMonth()->format('Y-m-d');
$endDate = $data['end_date'] ?? Carbon::now()->format('Y-m-d');
return $this->usageService->getMemberUsage($member, $startDate, $endDate);
}, __('message.fetched'));
}
/**
* 과금 정책 정보
*/
public function priceInfo()
{
return ApiResponse::handle(function () {
return ['prices' => BarobillUsageService::getPriceInfo()];
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,305 @@
<?php
namespace App\Services\Barobill;
use App\Models\Barobill\BarobillBillingRecord;
use App\Models\Barobill\BarobillMember;
use App\Models\Barobill\BarobillMonthlySummary;
use App\Models\Barobill\BarobillPricingPolicy;
use App\Models\Barobill\BarobillSubscription;
use App\Services\Service;
use Carbon\Carbon;
class BarobillBillingService extends Service
{
/**
* 구독 목록 조회
*/
public function getSubscriptions(?int $memberId = null): array
{
$query = BarobillSubscription::withoutGlobalScopes()
->with('member');
if ($memberId) {
$query->where('member_id', $memberId);
}
return $query->orderBy('created_at', 'desc')->get()->toArray();
}
/**
* 구독 등록/수정
*/
public function saveSubscription(int $memberId, string $serviceType, array $data): BarobillSubscription
{
return BarobillSubscription::withoutGlobalScopes()->updateOrCreate(
[
'member_id' => $memberId,
'service_type' => $serviceType,
],
[
'monthly_fee' => $data['monthly_fee'] ?? BarobillSubscription::DEFAULT_MONTHLY_FEES[$serviceType] ?? 0,
'started_at' => $data['started_at'] ?? now(),
'ended_at' => $data['ended_at'] ?? null,
'is_active' => $data['is_active'] ?? true,
'memo' => $data['memo'] ?? null,
]
);
}
/**
* 구독 해지
*/
public function cancelSubscription(int $subscriptionId): bool
{
$subscription = BarobillSubscription::withoutGlobalScopes()->findOrFail($subscriptionId);
$subscription->update([
'ended_at' => now(),
'is_active' => false,
]);
return true;
}
/**
* 월정액 과금 배치 처리
*/
public function processMonthlyBilling(?string $billingMonth = null): array
{
$billingMonth = $billingMonth ?: Carbon::now()->format('Y-m');
$subscriptions = BarobillSubscription::withoutGlobalScopes()
->where('is_active', true)
->get();
$processed = 0;
$skipped = 0;
$errors = 0;
foreach ($subscriptions as $subscription) {
$exists = BarobillBillingRecord::withoutGlobalScopes()
->where('member_id', $subscription->member_id)
->where('billing_month', $billingMonth)
->where('service_type', $subscription->service_type)
->where('billing_type', 'subscription')
->exists();
if ($exists) {
$skipped++;
continue;
}
try {
BarobillBillingRecord::withoutGlobalScopes()->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' => now(),
'description' => $this->getServiceLabel($subscription->service_type).' 월정액',
]);
$processed++;
} catch (\Throwable $e) {
$errors++;
}
}
$this->updateMonthlySummaries($billingMonth);
return [
'processed' => $processed,
'skipped' => $skipped,
'errors' => $errors,
'billing_month' => $billingMonth,
];
}
/**
* 건별 사용량 과금 기록
*/
public function recordUsage(int $memberId, string $serviceType, int $quantity, ?string $billingMonth = null): BarobillBillingRecord
{
$billingMonth = $billingMonth ?: Carbon::now()->format('Y-m');
$policy = BarobillPricingPolicy::withoutGlobalScopes()
->where('service_type', $serviceType)
->where('is_active', true)
->first();
$totalAmount = 0;
if ($policy) {
$billing = $policy->calculateBilling($quantity);
$totalAmount = $billing['billable_amount'] ?? 0;
}
$record = BarobillBillingRecord::withoutGlobalScopes()->updateOrCreate(
[
'member_id' => $memberId,
'billing_month' => $billingMonth,
'service_type' => $serviceType,
'billing_type' => 'usage',
],
[
'quantity' => $quantity,
'unit_price' => $totalAmount > 0 ? (int) ceil($totalAmount / max($quantity, 1)) : 0,
'total_amount' => $totalAmount,
'billed_at' => now(),
'description' => $this->getServiceLabel($serviceType).' 사용량 과금',
]
);
$this->updateMonthlySummaries($billingMonth);
return $record;
}
/**
* 월별 집계 갱신
*/
public function updateMonthlySummaries(string $billingMonth): void
{
$records = BarobillBillingRecord::withoutGlobalScopes()
->where('billing_month', $billingMonth)
->get()
->groupBy('member_id');
foreach ($records as $memberId => $memberRecords) {
$summary = [
'bank_account_fee' => 0,
'card_fee' => 0,
'hometax_fee' => 0,
'subscription_total' => 0,
'tax_invoice_count' => 0,
'tax_invoice_amount' => 0,
'usage_total' => 0,
];
foreach ($memberRecords as $record) {
if ($record->billing_type === 'subscription') {
match ($record->service_type) {
'bank_account' => $summary['bank_account_fee'] = $record->total_amount,
'card' => $summary['card_fee'] = $record->total_amount,
'hometax' => $summary['hometax_fee'] = $record->total_amount,
default => null,
};
$summary['subscription_total'] += $record->total_amount;
} elseif ($record->billing_type === 'usage') {
if ($record->service_type === 'tax_invoice') {
$summary['tax_invoice_count'] = $record->quantity;
$summary['tax_invoice_amount'] = $record->total_amount;
}
$summary['usage_total'] += $record->total_amount;
}
}
$summary['grand_total'] = $summary['subscription_total'] + $summary['usage_total'];
BarobillMonthlySummary::withoutGlobalScopes()->updateOrCreate(
['member_id' => $memberId, 'billing_month' => $billingMonth],
$summary
);
}
}
/**
* 월별 과금 현황 목록
*/
public function getMonthlyBillingList(string $billingMonth, ?int $tenantId = null): array
{
$query = BarobillMonthlySummary::withoutGlobalScopes()
->with('member')
->where('billing_month', $billingMonth);
if ($tenantId) {
$memberIds = BarobillMember::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->pluck('id');
$query->whereIn('member_id', $memberIds);
}
return $query->get()->toArray();
}
/**
* 월별 합계
*/
public function getMonthlyTotal(string $billingMonth, ?int $tenantId = null): array
{
$query = BarobillMonthlySummary::withoutGlobalScopes()
->where('billing_month', $billingMonth);
if ($tenantId) {
$memberIds = BarobillMember::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->pluck('id');
$query->whereIn('member_id', $memberIds);
}
return [
'member_count' => $query->count(),
'bank_account_fee' => (clone $query)->sum('bank_account_fee'),
'card_fee' => (clone $query)->sum('card_fee'),
'hometax_fee' => (clone $query)->sum('hometax_fee'),
'subscription_total' => (clone $query)->sum('subscription_total'),
'tax_invoice_count' => (clone $query)->sum('tax_invoice_count'),
'tax_invoice_amount' => (clone $query)->sum('tax_invoice_amount'),
'usage_total' => (clone $query)->sum('usage_total'),
'grand_total' => (clone $query)->sum('grand_total'),
];
}
/**
* 연간 과금 추이
*/
public function getYearlyTrend(int $year, ?int $tenantId = null): array
{
$trend = [];
for ($month = 1; $month <= 12; $month++) {
$billingMonth = sprintf('%d-%02d', $year, $month);
$total = $this->getMonthlyTotal($billingMonth, $tenantId);
$trend[] = [
'billing_month' => $billingMonth,
...$total,
];
}
return $trend;
}
/**
* 과금 정책 목록
*/
public function getPricingPolicies(): array
{
return BarobillPricingPolicy::withoutGlobalScopes()
->orderBy('sort_order')
->get()
->toArray();
}
/**
* 과금 정책 수정
*/
public function updatePricingPolicy(int $id, array $data): BarobillPricingPolicy
{
$policy = BarobillPricingPolicy::withoutGlobalScopes()->findOrFail($id);
$policy->update($data);
return $policy->fresh();
}
private function getServiceLabel(string $serviceType): string
{
return match ($serviceType) {
'bank_account' => '계좌조회',
'card' => '법인카드',
'hometax' => '홈택스',
'tax_invoice' => '전자세금계산서',
default => $serviceType,
};
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Services\Barobill;
use App\Models\Barobill\BarobillBankTransaction;
use App\Models\Barobill\BarobillCardTransaction;
use App\Models\Barobill\BarobillMember;
use App\Models\Barobill\BarobillPricingPolicy;
use App\Models\Barobill\HometaxInvoice;
use App\Services\Service;
class BarobillUsageService extends Service
{
/**
* 전체 회원사 사용량 목록
*/
public function getUsageList(string $startDate, string $endDate, ?int $tenantId = null): array
{
$query = BarobillMember::withoutGlobalScopes()
->where('status', 'active');
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
$members = $query->get();
$usageList = [];
foreach ($members as $member) {
$usageList[] = $this->getMemberUsage($member, $startDate, $endDate);
}
return $usageList;
}
/**
* 단일 회원사 사용량 상세
*/
public function getMemberUsage(BarobillMember $member, string $startDate, string $endDate): array
{
$taxInvoiceCount = $this->getTaxInvoiceCount($member, $startDate, $endDate);
$bankAccountCount = $this->getBankAccountCount($member);
$cardCount = $this->getCardCount($member);
$hometaxCount = $this->getHometaxCount($member, $startDate, $endDate);
$taxInvoiceBilling = $this->calculateBillingByPolicy('tax_invoice', $taxInvoiceCount);
return [
'member_id' => $member->id,
'tenant_id' => $member->tenant_id,
'biz_no' => $member->biz_no,
'corp_name' => $member->corp_name,
'barobill_id' => $member->barobill_id,
'server_mode' => $member->server_mode ?? 'test',
'tax_invoice_count' => $taxInvoiceCount,
'bank_account_count' => $bankAccountCount,
'card_count' => $cardCount,
'hometax_count' => $hometaxCount,
'tax_invoice_billing' => $taxInvoiceBilling,
'total_amount' => $taxInvoiceBilling['billable_amount'] ?? 0,
];
}
/**
* 사용량 통계 집계
*/
public function aggregateStats(array $usageList): array
{
$stats = [
'total_members' => count($usageList),
'total_tax_invoice_count' => 0,
'total_bank_account_count' => 0,
'total_card_count' => 0,
'total_hometax_count' => 0,
'total_amount' => 0,
];
foreach ($usageList as $usage) {
$stats['total_tax_invoice_count'] += $usage['tax_invoice_count'];
$stats['total_bank_account_count'] += $usage['bank_account_count'];
$stats['total_card_count'] += $usage['card_count'];
$stats['total_hometax_count'] += $usage['hometax_count'];
$stats['total_amount'] += $usage['total_amount'];
}
return $stats;
}
/**
* 과금 정책 정보
*/
public static function getPriceInfo(): array
{
$policies = BarobillPricingPolicy::withoutGlobalScopes()
->where('is_active', true)
->orderBy('sort_order')
->get();
$info = [];
foreach ($policies as $policy) {
$info[$policy->service_type] = [
'name' => $policy->name,
'free_quota' => $policy->free_quota,
'free_quota_unit' => $policy->free_quota_unit,
'additional_unit' => $policy->additional_unit,
'additional_unit_label' => $policy->additional_unit_label,
'additional_price' => $policy->additional_price,
];
}
return $info;
}
/**
* 정책 기반 과금액 계산
*/
public static function calculateBillingByPolicy(string $serviceType, int $usageCount): array
{
$policy = BarobillPricingPolicy::withoutGlobalScopes()
->where('service_type', $serviceType)
->where('is_active', true)
->first();
if (! $policy) {
return ['free_count' => $usageCount, 'billable_count' => 0, 'billable_amount' => 0];
}
return $policy->calculateBilling($usageCount);
}
protected function getTaxInvoiceCount(BarobillMember $member, string $startDate, string $endDate): int
{
return HometaxInvoice::withoutGlobalScopes()
->where('tenant_id', $member->tenant_id)
->where('invoice_type', 'sales')
->whereBetween('write_date', [$startDate, $endDate])
->count();
}
protected function getBankAccountCount(BarobillMember $member): int
{
return BarobillBankTransaction::withoutGlobalScopes()
->where('tenant_id', $member->tenant_id)
->distinct('bank_account_num')
->count('bank_account_num');
}
protected function getCardCount(BarobillMember $member): int
{
return BarobillCardTransaction::withoutGlobalScopes()
->where('tenant_id', $member->tenant_id)
->distinct('card_num')
->count('card_num');
}
protected function getHometaxCount(BarobillMember $member, string $startDate, string $endDate): int
{
return HometaxInvoice::withoutGlobalScopes()
->where('tenant_id', $member->tenant_id)
->whereBetween('write_date', [$startDate, $endDate])
->count();
}
}

View File

@@ -18,12 +18,14 @@
use App\Http\Controllers\Api\V1\BankAccountController;
use App\Http\Controllers\Api\V1\BankTransactionController;
use App\Http\Controllers\Api\V1\BarobillBankTransactionController;
use App\Http\Controllers\Api\V1\BarobillBillingController;
use App\Http\Controllers\Api\V1\BarobillCardTransactionController;
use App\Http\Controllers\Api\V1\BarobillController;
use App\Http\Controllers\Api\V1\BarobillKakaotalkController;
use App\Http\Controllers\Api\V1\BarobillSettingController;
use App\Http\Controllers\Api\V1\BarobillSmsController;
use App\Http\Controllers\Api\V1\BarobillSyncController;
use App\Http\Controllers\Api\V1\BarobillUsageController;
use App\Http\Controllers\Api\V1\BillController;
use App\Http\Controllers\Api\V1\CalendarController;
use App\Http\Controllers\Api\V1\CardController;
@@ -423,6 +425,27 @@
Route::get('/send-state/{sendKey}', [BarobillSmsController::class, 'sendState'])->name('v1.barobill.sms.send-state');
});
// Barobill Billing API (바로빌 과금 관리)
Route::prefix('barobill/billing')->group(function () {
Route::get('/subscriptions', [BarobillBillingController::class, 'subscriptions'])->name('v1.barobill.billing.subscriptions');
Route::post('/subscriptions', [BarobillBillingController::class, 'saveSubscription'])->name('v1.barobill.billing.subscriptions.store');
Route::delete('/subscriptions/{id}', [BarobillBillingController::class, 'cancelSubscription'])->whereNumber('id')->name('v1.barobill.billing.subscriptions.cancel');
Route::get('/list', [BarobillBillingController::class, 'billingList'])->name('v1.barobill.billing.list');
Route::get('/stats', [BarobillBillingController::class, 'billingStats'])->name('v1.barobill.billing.stats');
Route::post('/process', [BarobillBillingController::class, 'processBilling'])->name('v1.barobill.billing.process');
Route::get('/yearly-trend', [BarobillBillingController::class, 'yearlyTrend'])->name('v1.barobill.billing.yearly-trend');
Route::get('/pricing-policies', [BarobillBillingController::class, 'pricingPolicies'])->name('v1.barobill.billing.pricing-policies');
Route::put('/pricing-policies/{id}', [BarobillBillingController::class, 'updatePricingPolicy'])->whereNumber('id')->name('v1.barobill.billing.pricing-policies.update');
});
// Barobill Usage API (바로빌 사용량 조회)
Route::prefix('barobill/usage')->group(function () {
Route::get('', [BarobillUsageController::class, 'index'])->name('v1.barobill.usage.index');
Route::get('/stats', [BarobillUsageController::class, 'stats'])->name('v1.barobill.usage.stats');
Route::get('/price-info', [BarobillUsageController::class, 'priceInfo'])->name('v1.barobill.usage.price-info');
Route::get('/{memberId}', [BarobillUsageController::class, 'show'])->whereNumber('memberId')->name('v1.barobill.usage.show');
});
// Bad Debt API (악성채권 추심관리)
Route::prefix('bad-debts')->group(function () {
Route::get('', [BadDebtController::class, 'index'])->name('v1.bad-debts.index');