Add phone login support and member import functionality
Features: - Support login via phone number or email (LoginRequest) - Add members:import-roster command for Excel roster import - Merge survey emails with roster data Code Quality (Phase 1-4): - Add database locking for balance calculation - Add self-approval checks for finance workflow - Create service layer (FinanceDocumentApprovalService, PaymentVerificationService) - Add HasAccountingEntries and HasApprovalWorkflow traits - Create FormRequest classes for validation - Add status-badge component - Define authorization gates in AuthServiceProvider - Add accounting config file Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
217
app/Services/PaymentVerificationService.php
Normal file
217
app/Services/PaymentVerificationService.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\MembershipActivatedMail;
|
||||
use App\Mail\PaymentApprovedByAccountantMail;
|
||||
use App\Mail\PaymentApprovedByCashierMail;
|
||||
use App\Mail\PaymentFullyApprovedMail;
|
||||
use App\Mail\PaymentRejectedMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\MembershipPayment;
|
||||
use App\Models\User;
|
||||
use App\Support\AuditLogger;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Service for handling MembershipPayment verification workflow.
|
||||
*
|
||||
* Workflow: Cashier → Accountant → Chair
|
||||
*/
|
||||
class PaymentVerificationService
|
||||
{
|
||||
/**
|
||||
* Approve by Cashier (first tier)
|
||||
*/
|
||||
public function approveByCashier(MembershipPayment $payment, User $user, ?string $notes = null): array
|
||||
{
|
||||
if (! $payment->canBeApprovedByCashier()) {
|
||||
return ['success' => false, 'message' => '此付款無法在此階段由出納審核。'];
|
||||
}
|
||||
|
||||
$payment->update([
|
||||
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
|
||||
'verified_by_cashier_id' => $user->id,
|
||||
'cashier_verified_at' => now(),
|
||||
'notes' => $notes ?? $payment->notes,
|
||||
]);
|
||||
|
||||
AuditLogger::log('payment.approved_by_cashier', $payment, [
|
||||
'member_id' => $payment->member_id,
|
||||
'amount' => $payment->amount,
|
||||
'verified_by' => $user->id,
|
||||
]);
|
||||
|
||||
// Notify member
|
||||
$this->notifyMember($payment, PaymentApprovedByCashierMail::class);
|
||||
|
||||
// Notify accountants
|
||||
$this->notifyRole($payment, 'verify_payments_accountant', PaymentApprovedByCashierMail::class);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '出納已審核。已送交會計審核。',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve by Accountant (second tier)
|
||||
*/
|
||||
public function approveByAccountant(MembershipPayment $payment, User $user, ?string $notes = null): array
|
||||
{
|
||||
if (! $payment->canBeApprovedByAccountant()) {
|
||||
return ['success' => false, 'message' => '此付款無法在此階段由會計審核。'];
|
||||
}
|
||||
|
||||
$payment->update([
|
||||
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
|
||||
'verified_by_accountant_id' => $user->id,
|
||||
'accountant_verified_at' => now(),
|
||||
'notes' => $notes ?? $payment->notes,
|
||||
]);
|
||||
|
||||
AuditLogger::log('payment.approved_by_accountant', $payment, [
|
||||
'member_id' => $payment->member_id,
|
||||
'amount' => $payment->amount,
|
||||
'verified_by' => $user->id,
|
||||
]);
|
||||
|
||||
// Notify member
|
||||
$this->notifyMember($payment, PaymentApprovedByAccountantMail::class);
|
||||
|
||||
// Notify chairs
|
||||
$this->notifyRole($payment, 'verify_payments_chair', PaymentApprovedByAccountantMail::class);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '會計已審核。已送交主席審核。',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve by Chair (final tier)
|
||||
*/
|
||||
public function approveByChair(MembershipPayment $payment, User $user, ?string $notes = null): array
|
||||
{
|
||||
if (! $payment->canBeApprovedByChair()) {
|
||||
return ['success' => false, 'message' => '此付款無法在此階段由主席審核。'];
|
||||
}
|
||||
|
||||
$payment->update([
|
||||
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
|
||||
'verified_by_chair_id' => $user->id,
|
||||
'chair_verified_at' => now(),
|
||||
'notes' => $notes ?? $payment->notes,
|
||||
]);
|
||||
|
||||
AuditLogger::log('payment.approved_by_chair', $payment, [
|
||||
'member_id' => $payment->member_id,
|
||||
'amount' => $payment->amount,
|
||||
'verified_by' => $user->id,
|
||||
]);
|
||||
|
||||
// Activate membership
|
||||
$activationResult = $this->activateMembership($payment);
|
||||
|
||||
// Notify member of full approval
|
||||
$this->notifyMember($payment, PaymentFullyApprovedMail::class);
|
||||
|
||||
if ($activationResult) {
|
||||
$this->notifyMember($payment, MembershipActivatedMail::class);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '主席已審核。付款驗證完成,會員資格已啟用。',
|
||||
'membershipActivated' => $activationResult,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject payment at any stage
|
||||
*/
|
||||
public function reject(MembershipPayment $payment, User $user, string $reason): array
|
||||
{
|
||||
if ($payment->isRejected()) {
|
||||
return ['success' => false, 'message' => '此付款已被拒絕。'];
|
||||
}
|
||||
|
||||
$payment->update([
|
||||
'status' => MembershipPayment::STATUS_REJECTED,
|
||||
'rejected_by_user_id' => $user->id,
|
||||
'rejected_at' => now(),
|
||||
'rejection_reason' => $reason,
|
||||
]);
|
||||
|
||||
AuditLogger::log('payment.rejected', $payment, [
|
||||
'member_id' => $payment->member_id,
|
||||
'amount' => $payment->amount,
|
||||
'rejected_by' => $user->id,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
// Notify member
|
||||
$this->notifyMember($payment, PaymentRejectedMail::class);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '付款已拒絕。',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate membership after payment is fully approved
|
||||
*/
|
||||
protected function activateMembership(MembershipPayment $payment): bool
|
||||
{
|
||||
$member = $payment->member;
|
||||
|
||||
if (! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only activate if member is pending
|
||||
if ($member->membership_status !== Member::STATUS_PENDING) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate membership dates
|
||||
$startDate = now();
|
||||
$expiryDate = now()->addYear();
|
||||
|
||||
$member->update([
|
||||
'membership_status' => Member::STATUS_ACTIVE,
|
||||
'membership_started_at' => $startDate,
|
||||
'membership_expires_at' => $expiryDate,
|
||||
]);
|
||||
|
||||
AuditLogger::log('member.activated_via_payment', $member, [
|
||||
'payment_id' => $payment->id,
|
||||
'started_at' => $startDate,
|
||||
'expires_at' => $expiryDate,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify member with given mail class
|
||||
*/
|
||||
protected function notifyMember(MembershipPayment $payment, string $mailClass): void
|
||||
{
|
||||
if ($payment->member && $payment->member->email) {
|
||||
Mail::to($payment->member->email)->queue(new $mailClass($payment));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify users with given permission
|
||||
*/
|
||||
protected function notifyRole(MembershipPayment $payment, string $permission, string $mailClass): void
|
||||
{
|
||||
$users = User::permission($permission)->get();
|
||||
foreach ($users as $user) {
|
||||
Mail::to($user->email)->queue(new $mailClass($payment));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user