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