Files
financial/app/lib/transaction_service.php
T
2026-06-07 00:33:58 +09:00

441 lines
14 KiB
PHP

<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/account_service.php';
require_once __DIR__ . '/installment_service.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/card_billing_service.php';
function build_transaction_fingerprint(
int $userId,
int $accountId,
?int $relatedAccountId,
int $categoryId,
string $transactionType,
float $amount,
string $transactionDate,
?string $merchantName,
?string $description
): string {
$raw = implode('|', [
$userId,
$accountId,
$relatedAccountId ?? 0,
$categoryId,
$transactionType,
number_format($amount, 2, '.', ''),
$transactionDate,
trim((string)$merchantName),
trim((string)$description),
]);
return hash('sha256', $raw);
}
function get_account_for_transaction(int $userId, int $accountId): ?array
{
$pdo = db();
$stmt = $pdo->prepare("
SELECT *
FROM accounts
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$accountId, $userId]);
$row = $stmt->fetch();
return $row ?: null;
}
function create_transaction(array $data, bool $skipIfDuplicate = false): bool
{
$pdo = db();
$pdo->beginTransaction();
try {
$fingerprint = build_transaction_fingerprint(
(int)$data['user_id'],
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
(int)$data['category_id'],
(string)$data['transaction_type'],
(float)$data['amount'],
(string)$data['transaction_date'],
$data['merchant_name'] ?? null,
$data['description'] ?? null
);
if ($skipIfDuplicate) {
$stmt = $pdo->prepare("
SELECT id
FROM transactions
WHERE user_id = ?
AND fingerprint = ?
LIMIT 1
");
$stmt->execute([$data['user_id'], $fingerprint]);
if ($stmt->fetch()) {
$pdo->commit();
return false;
}
}
$account = get_account_for_transaction(
(int)$data['user_id'],
(int)$data['account_id']
);
$billingYearMonth = null;
if ($account) {
$billingYearMonth = get_card_billing_year_month(
$account,
(string)$data['transaction_date']
);
}
$isInstallment = !empty($data['is_installment']) ? 1 : 0;
$installmentMonths = !empty($data['installment_months']) ? (int)$data['installment_months'] : null;
$installmentInterestRate = !empty($data['installment_interest_rate']) ? (float)$data['installment_interest_rate'] : 0.0;
$installmentInterestTotal =
isset($data['installment_interest_total']) &&
$data['installment_interest_total'] !== null &&
$data['installment_interest_total'] !== ''
? (float)$data['installment_interest_total']
: null;
$installmentTotalBilled =
isset($data['installment_total_billed']) &&
$data['installment_total_billed'] !== null &&
$data['installment_total_billed'] !== ''
? (float)$data['installment_total_billed']
: null;
if ($isInstallment && ($installmentMonths === null || $installmentMonths <= 1)) {
throw new RuntimeException('할부 개월 수가 올바르지 않습니다.');
}
if ($isInstallment) {
if ($installmentInterestTotal === null) {
$installmentInterestTotal = calculate_installment_interest_total(
(float)$data['amount'],
$installmentMonths,
$installmentInterestRate
);
}
$expectedTotal = round(
(float)$data['amount'] + (float)$installmentInterestTotal,
2
);
if ($installmentTotalBilled === null) {
$installmentTotalBilled = $expectedTotal;
}
if (round($installmentTotalBilled, 2) !== $expectedTotal) {
throw new RuntimeException('총 청구금액은 원금 + 할부이자와 같아야 합니다.');
}
} else {
$installmentMonths = null;
$installmentInterestRate = 0.0;
$installmentInterestTotal = 0.0;
$installmentTotalBilled = null;
}
$stmt = $pdo->prepare("
INSERT INTO transactions
(
user_id,
account_id,
category_id,
transaction_type,
amount,
is_installment,
installment_months,
installment_interest_rate,
installment_interest_total,
installment_total_billed,
installment_prepay_amount,
transaction_date,
billing_year_month,
merchant_name,
description,
related_account_id,
fingerprint
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$data['user_id'],
$data['account_id'],
$data['category_id'],
$data['transaction_type'],
$data['amount'],
$isInstallment,
$installmentMonths,
$installmentInterestRate,
$installmentInterestTotal,
$installmentTotalBilled,
$data['transaction_date'],
$billingYearMonth,
$data['merchant_name'],
$data['description'],
$data['related_account_id'],
$fingerprint,
]);
$transactionId = (int)$pdo->lastInsertId();
$pdo->commit();
if (
$data['transaction_type'] === 'expense' &&
$isInstallment === 1 &&
$installmentMonths !== null &&
$installmentMonths > 1
) {
create_installment_schedule(
(int)$data['user_id'],
$transactionId,
(int)$data['account_id'],
(float)$data['amount'],
$installmentMonths,
(string)$data['transaction_date'],
$installmentInterestRate,
$installmentInterestTotal,
$installmentTotalBilled,
$installmentInterestRate > 0 ? 'fixed_total' : 'none'
);
}
if (
$data['transaction_type'] === 'card_payment' &&
!empty($data['related_account_id'])
) {
$yearMonth = date('Y-m', strtotime((string)$data['transaction_date']));
$processedCount = mark_installment_month_billed_for_card_payment(
(int)$data['user_id'],
(int)$data['related_account_id'],
$yearMonth
);
if ($processedCount > 0) {
set_flash_message(
'success',
$yearMonth . ' 할부 청구 회차 ' . intvalf($processedCount) . '건 자동 처리'
);
}
}
recalculate_account_balance((int)$data['account_id']);
if (!empty($data['related_account_id'])) {
recalculate_account_balance((int)$data['related_account_id']);
}
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function update_transaction(int $transactionId, int $userId, array $data): void
{
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT account_id, related_account_id
FROM transactions
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$transactionId, $userId]);
$old = $stmt->fetch();
if (!$old) {
throw new RuntimeException('거래를 찾을 수 없습니다.');
}
$fingerprint = build_transaction_fingerprint(
$userId,
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
(int)$data['category_id'],
(string)$data['transaction_type'],
(float)$data['amount'],
(string)$data['transaction_date'],
$data['merchant_name'] ?? null,
$data['description'] ?? null
);
$account = get_account_for_transaction(
$userId,
(int)$data['account_id']
);
$billingYearMonth = null;
if ($account) {
$billingYearMonth = get_card_billing_year_month(
$account,
(string)$data['transaction_date']
);
}
$isInstallment = !empty($data['is_installment']) ? 1 : 0;
$installmentMonths = !empty($data['installment_months']) ? (int)$data['installment_months'] : null;
$installmentInterestRate = !empty($data['installment_interest_rate']) ? (float)$data['installment_interest_rate'] : 0.0;
$stmt = $pdo->prepare("
UPDATE transactions
SET
account_id = ?,
category_id = ?,
transaction_type = ?,
amount = ?,
is_installment = ?,
installment_months = ?,
installment_interest_rate = ?,
installment_interest_total = ?,
installment_total_billed = ?,
transaction_date = ?,
billing_year_month = ?,
merchant_name = ?,
description = ?,
related_account_id = ?,
fingerprint = ?
WHERE id = ?
AND user_id = ?
");
$stmt->execute([
$data['account_id'],
$data['category_id'],
$data['transaction_type'],
$data['amount'],
$isInstallment,
$installmentMonths,
$installmentInterestRate,
$data['installment_interest_total'] ?? 0,
$data['installment_total_billed'] ?? null,
$data['transaction_date'],
$billingYearMonth,
$data['merchant_name'],
$data['description'],
$data['related_account_id'],
$fingerprint,
$transactionId,
$userId
]);
delete_installment_by_transaction_id($transactionId);
if (
$data['transaction_type'] === 'expense' &&
$isInstallment === 1 &&
$installmentMonths !== null &&
$installmentMonths > 1
) {
$interestTotal = calculate_installment_interest_total(
(float)$data['amount'],
$installmentMonths,
$installmentInterestRate
);
$totalBilled = round(
(float)$data['amount'] + (float)$interestTotal,
2
);
create_installment_schedule(
$userId,
$transactionId,
(int)$data['account_id'],
(float)$data['amount'],
$installmentMonths,
(string)$data['transaction_date'],
$installmentInterestRate,
$interestTotal,
$totalBilled,
$installmentInterestRate > 0 ? 'fixed_total' : 'none'
);
}
$pdo->commit();
$recalcIds = array_unique(array_filter([
(int)$old['account_id'],
!empty($old['related_account_id']) ? (int)$old['related_account_id'] : null,
(int)$data['account_id'],
!empty($data['related_account_id']) ? (int)$data['related_account_id'] : null,
]));
foreach ($recalcIds as $id) {
recalculate_account_balance((int)$id);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function delete_transaction(int $transactionId, int $userId): void
{
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("
SELECT account_id, related_account_id
FROM transactions
WHERE id = ?
AND user_id = ?
LIMIT 1
");
$stmt->execute([$transactionId, $userId]);
$row = $stmt->fetch();
if (!$row) {
throw new RuntimeException('거래를 찾을 수 없습니다.');
}
delete_installment_by_transaction_id($transactionId);
$stmt = $pdo->prepare("
DELETE FROM transactions
WHERE id = ?
AND user_id = ?
");
$stmt->execute([$transactionId, $userId]);
$pdo->commit();
recalculate_account_balance((int)$row['account_id']);
if (!empty($row['related_account_id'])) {
recalculate_account_balance((int)$row['related_account_id']);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}