Files
usher-manage-stack/app/Models/Member.php
Gbanyan 642b879dd4 Add membership fee system with disability discount and fix document permissions
Features:
- Implement two fee types: entrance fee and annual fee (both NT$1,000)
- Add 50% discount for disability certificate holders
- Add disability certificate upload in member profile
- Integrate disability verification into cashier approval workflow
- Add membership fee settings in system admin

Document permissions:
- Fix hard-coded role logic in Document model
- Use permission-based authorization instead of role checks

Additional features:
- Add announcements, general ledger, and trial balance modules
- Add income management and accounting entries
- Add comprehensive test suite with factories
- Update UI translations to Traditional Chinese

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 09:56:01 +08:00

365 lines
10 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class Member extends Model
{
use HasFactory;
// Membership status constants
const STATUS_PENDING = 'pending';
const STATUS_ACTIVE = 'active';
const STATUS_EXPIRED = 'expired';
const STATUS_SUSPENDED = 'suspended';
// Membership type constants
const TYPE_REGULAR = 'regular';
const TYPE_HONORARY = 'honorary';
const TYPE_LIFETIME = 'lifetime';
const TYPE_STUDENT = 'student';
// Disability certificate status constants
const DISABILITY_STATUS_PENDING = 'pending';
const DISABILITY_STATUS_APPROVED = 'approved';
const DISABILITY_STATUS_REJECTED = 'rejected';
protected $fillable = [
'user_id',
'full_name',
'email',
'phone',
'address_line_1',
'address_line_2',
'city',
'postal_code',
'emergency_contact_name',
'emergency_contact_phone',
'national_id_encrypted',
'national_id_hash',
'membership_started_at',
'membership_expires_at',
'membership_status',
'membership_type',
'disability_certificate_path',
'disability_certificate_status',
'disability_verified_by',
'disability_verified_at',
'disability_rejection_reason',
];
protected $casts = [
'membership_started_at' => 'date',
'membership_expires_at' => 'date',
'disability_verified_at' => 'datetime',
];
protected $appends = ['national_id'];
public function user()
{
return $this->belongsTo(User::class);
}
public function payments()
{
return $this->hasMany(MembershipPayment::class);
}
/**
* 關聯的收入記錄
*/
public function incomes()
{
return $this->hasMany(Income::class);
}
/**
* 取得會員的會費收入記錄
*/
public function getMembershipFeeIncomes()
{
return $this->incomes()
->whereIn('income_type', [
Income::TYPE_MEMBERSHIP_FEE,
Income::TYPE_ENTRANCE_FEE
])
->get();
}
/**
* 取得會員的總收入金額
*/
public function getTotalIncomeAttribute(): float
{
return $this->incomes()
->where('status', Income::STATUS_CONFIRMED)
->sum('amount');
}
/**
* Get the decrypted national ID
*/
public function getNationalIdAttribute(): ?string
{
if (empty($this->national_id_encrypted)) {
return null;
}
try {
return Crypt::decryptString($this->national_id_encrypted);
} catch (\Exception $e) {
\Log::error('Failed to decrypt national_id', [
'member_id' => $this->id,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Set the national ID (encrypt and hash)
*/
public function setNationalIdAttribute(?string $value): void
{
if (empty($value)) {
$this->attributes['national_id_encrypted'] = null;
$this->attributes['national_id_hash'] = null;
return;
}
$this->attributes['national_id_encrypted'] = Crypt::encryptString($value);
$this->attributes['national_id_hash'] = hash('sha256', $value);
}
/**
* Check if membership status is pending (not yet paid/verified)
*/
public function isPending(): bool
{
return $this->membership_status === self::STATUS_PENDING;
}
/**
* Check if membership is active (paid & activated)
*/
public function isActive(): bool
{
return $this->membership_status === self::STATUS_ACTIVE;
}
/**
* Check if membership is expired
*/
public function isExpired(): bool
{
return $this->membership_status === self::STATUS_EXPIRED;
}
/**
* Check if membership is suspended
*/
public function isSuspended(): bool
{
return $this->membership_status === self::STATUS_SUSPENDED;
}
/**
* Check if member has paid membership (active status with valid dates)
*/
public function hasPaidMembership(): bool
{
return $this->isActive()
&& $this->membership_started_at
&& $this->membership_expires_at
&& $this->membership_expires_at->isFuture();
}
/**
* Get the membership status badge class for display
*/
public function getMembershipStatusBadgeAttribute(): string
{
$label = $this->membership_status_label;
$class = match($this->membership_status) {
self::STATUS_PENDING => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
self::STATUS_ACTIVE => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
self::STATUS_EXPIRED => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
self::STATUS_SUSPENDED => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
};
return trim("{$label} {$class}");
}
/**
* Get the membership status label in Chinese
*/
public function getMembershipStatusLabelAttribute(): string
{
return match($this->membership_status) {
self::STATUS_PENDING => '待繳費',
self::STATUS_ACTIVE => '已啟用',
self::STATUS_EXPIRED => '已過期',
self::STATUS_SUSPENDED => '已暫停',
default => $this->membership_status,
};
}
/**
* Get the membership type label in Chinese
*/
public function getMembershipTypeLabelAttribute(): string
{
return match($this->membership_type) {
self::TYPE_REGULAR => '一般會員',
self::TYPE_HONORARY => '榮譽會員',
self::TYPE_LIFETIME => '終身會員',
self::TYPE_STUDENT => '學生會員',
default => $this->membership_type,
};
}
/**
* Get pending payment (if any)
*/
public function getPendingPayment()
{
return $this->payments()
->where('status', MembershipPayment::STATUS_PENDING)
->orWhere('status', MembershipPayment::STATUS_APPROVED_CASHIER)
->orWhere('status', MembershipPayment::STATUS_APPROVED_ACCOUNTANT)
->latest()
->first();
}
/**
* Check if member can submit payment
*/
public function canSubmitPayment(): bool
{
// Can submit if pending status and no pending payment
return $this->isPending() && !$this->getPendingPayment();
}
// ========== 身心障礙相關 ==========
/**
* 身心障礙手冊審核人
*/
public function disabilityVerifiedBy()
{
return $this->belongsTo(User::class, 'disability_verified_by');
}
/**
* 是否有上傳身心障礙手冊
*/
public function hasDisabilityCertificate(): bool
{
return !empty($this->disability_certificate_path);
}
/**
* 身心障礙手冊是否待審核
*/
public function isDisabilityPending(): bool
{
return $this->disability_certificate_status === self::DISABILITY_STATUS_PENDING;
}
/**
* 身心障礙手冊是否已通過審核
*/
public function hasApprovedDisability(): bool
{
return $this->disability_certificate_status === self::DISABILITY_STATUS_APPROVED;
}
/**
* 身心障礙手冊是否被駁回
*/
public function isDisabilityRejected(): bool
{
return $this->disability_certificate_status === self::DISABILITY_STATUS_REJECTED;
}
/**
* 取得身心障礙狀態標籤
*/
public function getDisabilityStatusLabelAttribute(): string
{
if (!$this->hasDisabilityCertificate()) {
return '未上傳';
}
return match ($this->disability_certificate_status) {
self::DISABILITY_STATUS_PENDING => '審核中',
self::DISABILITY_STATUS_APPROVED => '已通過',
self::DISABILITY_STATUS_REJECTED => '已駁回',
default => '未知',
};
}
/**
* 取得身心障礙狀態的 Badge 樣式
*/
public function getDisabilityStatusBadgeAttribute(): string
{
if (!$this->hasDisabilityCertificate()) {
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
return match ($this->disability_certificate_status) {
self::DISABILITY_STATUS_PENDING => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
self::DISABILITY_STATUS_APPROVED => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
self::DISABILITY_STATUS_REJECTED => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
};
}
/**
* 審核通過身心障礙手冊
*/
public function approveDisabilityCertificate(User $verifier): void
{
$this->update([
'disability_certificate_status' => self::DISABILITY_STATUS_APPROVED,
'disability_verified_by' => $verifier->id,
'disability_verified_at' => now(),
'disability_rejection_reason' => null,
]);
}
/**
* 駁回身心障礙手冊
*/
public function rejectDisabilityCertificate(User $verifier, string $reason): void
{
$this->update([
'disability_certificate_status' => self::DISABILITY_STATUS_REJECTED,
'disability_verified_by' => $verifier->id,
'disability_verified_at' => now(),
'disability_rejection_reason' => $reason,
]);
}
/**
* 判斷下一次應繳哪種會費
*/
public function getNextFeeType(): string
{
// 新會員(從未啟用過)= 入會會費
if ($this->membership_started_at === null) {
return MembershipPayment::FEE_TYPE_ENTRANCE;
}
// 已有會籍 = 常年會費
return MembershipPayment::FEE_TYPE_ANNUAL;
}
}