# Plan: FeeCalculationService (Issue #42)

## Summary
Implement the FeeCalculationService PHP class that provides fee calculation logic including family discount DSL, frozen rates for inactive members, and batch calculation. Also create the required FeeRule/FeeRecord entities, mappers, and a FeeController for API access. The service must integrate with existing MemberMapper and FamilyMapper.

## Implementation Steps

### Step 1: Create FeeRule entity (lib/Db/FeeRule.php)
- Entity mapping oc_mv_fee_rules table columns: id, year_from, base_rate, family_rules_json, inactive_rule, created_at, created_by
- jsonSerialize with parsed familyRules from JSON
- addType for numeric fields

### Step 2: Create FeeRuleMapper (lib/Db/FeeRuleMapper.php)  
- findById, findAll, findByYear (find rule where year_from <= $year, ordered DESC, first result)
- Standard QBMapper pattern matching existing mappers

### Step 3: Create FeeRecord entity (lib/Db/FeeRecord.php)
- Entity mapping oc_mv_fee_records columns: id, member_id, year, amount, rule_snapshot_json, manuell_angepasst, paid, payment_date, notes, created_at, updated_at
- jsonSerialize

### Step 4: Create FeeRecordMapper (lib/Db/FeeRecordMapper.php)
- findByMemberId, findByYear, findByMemberAndYear, countByYear
- Standard QBMapper pattern

### Step 5: Create FeeCalculationService (lib/Service/FeeCalculationService.php)
Core methods:
- `calculateFee($member, $family, $ruleForYear)` - pure function:
  1. If rolle == 'erziehungsberechtigter' -> return 0 (no fee)
  2. If status == 'inaktiv' and frozenFeeRate set -> return frozen rate
  3. Get all active children in family sorted by geburtsdatum ASC (oldest first)
  4. Find member's position (1st, 2nd, 3rd+ child)
  5. Apply family_rules_json for that position
  6. Members without family -> use base_rate
- `batchCalculate($year)` - iterate all active+paying members, skip manuell_angepasst
- `getRulesForYear($year)` - find applicable rule
- `getFeeRecordsForYear($year)` - all records for a year
- `getMemberFeeHistory($memberId)` - all records for a member
- `markAsPaid($recordId, $paymentDate)`
- `manualOverride($recordId, $amount, $notes)`
- `createRule($data)` - create a new fee rule
- `updateRule($id, $data)` - update existing rule

### Step 6: Create FeeController (lib/Controller/FeeController.php)
REST endpoints following existing controller patterns:
- GET /api/v1/fees/rules - list fee rules
- POST /api/v1/fees/rules - create rule
- PUT /api/v1/fees/rules/{id} - update rule
- GET /api/v1/fees/records?year=2026 - get records for year
- GET /api/v1/fees/members/{memberId}/records - member history
- POST /api/v1/fees/batch-calculate - trigger batch calculation
- PUT /api/v1/fees/records/{id}/paid - mark as paid
- PUT /api/v1/fees/records/{id}/override - manual override

### Step 7: Register routes in appinfo/routes.php
Add fee-related routes following existing patterns.

## AC Verification Checklist
1. [ ] FeeRule entity exists with correct column mappings
2. [ ] FeeRuleMapper can find rules by year
3. [ ] FeeRecord entity exists with correct column mappings
4. [ ] FeeRecordMapper supports finding by member and year
5. [ ] calculateFee returns 0 for Erziehungsberechtigter
6. [ ] calculateFee uses frozen rate for inactive members
7. [ ] calculateFee determines child position by age and applies family discount
8. [ ] calculateFee uses base_rate for members without family
9. [ ] batchCalculate iterates all active members, skips manuell_angepasst, creates/updates records
10. [ ] markAsPaid updates payment status and date
11. [ ] manualOverride sets amount and notes, marks manuell_angepasst=true
12. [ ] FeeController exposes all required REST endpoints
13. [ ] Routes are registered in routes.php
14. [ ] Code follows existing patterns (namespace, error handling, doc comments)
