diff --git a/docs/api/discount.md b/docs/api/discount.md new file mode 100644 index 00000000..45a1cac7 --- /dev/null +++ b/docs/api/discount.md @@ -0,0 +1,96 @@ +# Discount API + +Generic per-tenant discount rules and their evaluation for a patient session. +Owner is resolved from the authenticated user (`ROLE_DOCTOR` → doctor, `ROLE_CLINIC` → clinic). All responses use the shared envelope; single/object payloads are double-nested (`data.data`). + +## Rule types (`type`) + +| type | target field(s) | meaning | +|------|-----------------|---------| +| `patient_tag` | `target_tag_id` (TenantTag id) | patient carries the tag | +| `invoice_amount` | `min_amount_rials` | session `final_price_rials` ≥ threshold | +| `specific_patient` | `target_record_id` (PatientRecord id) | a specific patient's record | +| `occasion` | `valid_from`/`valid_to`, optional `occasion_kind: birthday` | date window; `birthday` also requires today == patient birthday (month/day) | +| `service` | `target_service_item_id` (ServiceItem id) | session contains that service (discount base = that service's line total) | +| `visit_count` | `min_visit_count` | patient's session count ≥ threshold | + +Shared fields: `discount_type` (`percent`|`fixed`), `value` (percent 0..100 or rials), `priority` (int, higher first), `combinable` (bool), `active` (bool), `valid_from`/`valid_to` (unix, nullable). + +--- + +## GET `/api/v1/admin/discount-rules` +List the owner's rules (array hydration, priority desc). **Auth:** doctor/clinic. + +### Response `200` +```json +{ "success": true, "data": { "data": [ { "uuid": "…", "name": "بالای ۱۰۰ هزار → ۱۰٪", "type": "invoice_amount", "discount_type": "percent", "value": 10, "priority": 5, "combinable": false, "active": true, "min_amount_rials": 1000000 } ] } } +``` + +## POST `/api/v1/admin/discount-rules` +Create a rule. **Auth:** doctor/clinic. + +### Request Body +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `name` | string | ✅ | | +| `type` | string | ✅ | one of the rule types above | +| `discount_type` | string | ❌ | `percent` (default) or `fixed` | +| `value` | int | ❌ | percent or rials | +| `priority` | int | ❌ | default 0 | +| `combinable` | bool | ❌ | default false | +| `active` | bool | ❌ | default true | +| `valid_from` / `valid_to` | int (unix) | ❌ | validity window | +| `target_tag_id` / `target_record_id` / `target_service_item_id` | int | ❌ | per-type target | +| `min_amount_rials` / `min_visit_count` | int | ❌ | per-type threshold | +| `occasion_kind` | string | ❌ | `birthday` or null | + +### Response `201` +Created rule object (double-nested `data.data`). + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_VALIDATION_001` | 422 | missing name / invalid type | +| `ERR_AUTH_006` | 403 | user is not a doctor/clinic owner | + +## PATCH `/api/v1/admin/discount-rules/{uuid}` +Update a rule (owner-scoped). Same body fields (all optional). **Auth:** doctor/clinic. +`404 ERR_VALIDATION_002` if the rule is not found for this owner. + +## DELETE `/api/v1/admin/discount-rules/{uuid}` +Delete a rule (owner-scoped). **Auth:** doctor/clinic. Response `200` → `{ data: { data: { deleted: true } } }`. + +--- + +## GET `/api/v1/session/{uuid}/discount-suggestions` +Evaluate all active owner rules against a session and return applicable discounts. **Auth:** doctor/clinic; the session's record must belong to the caller's owner. + +### Response `200` +```json +{ + "success": true, + "data": { "data": [ + { + "rule_uuid": "740bcfc4-…", + "rule_name": "بالای ۱۰۰ هزار → ۱۰٪", + "type": "invoice_amount", + "discount_type": "percent", + "value": 10, + "discount_rials": 700000, + "combinable": false, + "priority": 5 + } + ] } +} +``` +Ordered by priority desc. Each `discount_rials` is capped at the session's unpaid remainder. Rules that don't apply (or compute to 0) are omitted. + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_VALIDATION_002` | 404 | session not found | +| `ERR_AUTH_006` | 403 | not the owner / not a doctor-clinic user | + +--- + +> Applying a suggested discount goes through `PATCH /api/v1/session/{uuid}` with `discount_rule_uuid` (see `patient.md`); the applied rule is recorded on the session as `applied_discount_rule_id` / `applied_discount_rule_label` for audit and reporting. diff --git a/src/Discount/Controller/DiscountController.php b/src/Discount/Controller/DiscountController.php new file mode 100644 index 00000000..c3796101 --- /dev/null +++ b/src/Discount/Controller/DiscountController.php @@ -0,0 +1,171 @@ +hasRole('ROLE_DOCTOR')) { + return ['doctor', $this->doctorRepo->findByUser($user)?->getId()]; + } + if ($user->hasRole('ROLE_CLINIC')) { + return ['clinic', $this->clinicRepo->findByUser($user)?->getId()]; + } + return ['unknown', null]; + } + + // ── Admin CRUD ──────────────────────────────────────────────────────────── + + #[Route('/api/v1/admin/discount-rules', methods: ['GET'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function list(#[CurrentUser] User $user): JsonResponse + { + [$ownerType, $ownerId] = $this->resolveOwner($user); + if ($ownerId === null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + return $this->success(['data' => $this->ruleRepo->listForOwner($ownerType, $ownerId)]); + } + + #[Route('/api/v1/admin/discount-rules', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function create(Request $request, #[CurrentUser] User $user): JsonResponse + { + [$ownerType, $ownerId] = $this->resolveOwner($user); + if ($ownerId === null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $name = trim((string) ($data['name'] ?? '')); + $type = (string) ($data['type'] ?? ''); + + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام قانون الزامی است', 422, 'name'); + } + if (!in_array($type, DiscountRule::TYPES, true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع قانون نامعتبر است', 422, 'type'); + } + + $rule = new DiscountRule($ownerType, $ownerId, $name, $type); + $this->applyPayload($rule, $data); + $this->ruleRepo->save($rule); + + return $this->success(['data' => $rule->toArray()], 201); + } + + #[Route('/api/v1/admin/discount-rules/{uuid}', methods: ['PATCH'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + [$ownerType, $ownerId] = $this->resolveOwner($user); + if ($ownerId === null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + $rule = $this->ruleRepo->findByUuidForOwner($uuid, $ownerType, $ownerId); + if ($rule === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (array_key_exists('name', $data)) { + $name = trim((string) $data['name']); + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام قانون الزامی است', 422, 'name'); + } + $rule->setName($name); + } + if (array_key_exists('type', $data)) { + if (!in_array((string) $data['type'], DiscountRule::TYPES, true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع قانون نامعتبر است', 422, 'type'); + } + $rule->setType((string) $data['type']); + } + $this->applyPayload($rule, $data); + $this->ruleRepo->save($rule); + + return $this->success(['data' => $rule->toArray()]); + } + + #[Route('/api/v1/admin/discount-rules/{uuid}', methods: ['DELETE'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$ownerType, $ownerId] = $this->resolveOwner($user); + if ($ownerId === null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + $rule = $this->ruleRepo->findByUuidForOwner($uuid, $ownerType, $ownerId); + if ($rule === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون یافت نشد', 404); + } + $this->ruleRepo->remove($rule); + return $this->success(['data' => ['deleted' => true]]); + } + + // ── Suggestions for a session ───────────────────────────────────────────── + + #[Route('/api/v1/session/{uuid}/discount-suggestions', methods: ['GET'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function suggestions(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$ownerType, $ownerId] = $this->resolveOwner($user); + if ($ownerId === null) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + $session = $this->sessionRepo->findByUuid($uuid); + if ($session === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پرونده یافت نشد', 404); + } + $record = $session->getRecord(); + if ($record->getEntityType() !== $ownerType || $record->getEntityId() !== $ownerId) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + return $this->success(['data' => $this->engine->evaluate($session)]); + } + + private function applyPayload(DiscountRule $rule, array $data): void + { + if (array_key_exists('discount_type', $data)) { + $dt = (string) $data['discount_type']; + $rule->setDiscountType($dt === DiscountRule::DISCOUNT_FIXED ? DiscountRule::DISCOUNT_FIXED : DiscountRule::DISCOUNT_PERCENT); + } + if (array_key_exists('value', $data)) { $rule->setValue((int) $data['value']); } + if (array_key_exists('priority', $data)) { $rule->setPriority((int) $data['priority']); } + if (array_key_exists('combinable', $data)) { $rule->setCombinable((bool) $data['combinable']); } + if (array_key_exists('active', $data)) { $rule->setActive((bool) $data['active']); } + if (array_key_exists('valid_from', $data)) { $rule->setValidFrom($data['valid_from'] !== null ? (int) $data['valid_from'] : null); } + if (array_key_exists('valid_to', $data)) { $rule->setValidTo($data['valid_to'] !== null ? (int) $data['valid_to'] : null); } + if (array_key_exists('target_tag_id', $data)) { $rule->setTargetTagId($data['target_tag_id'] !== null ? (int) $data['target_tag_id'] : null); } + if (array_key_exists('target_record_id', $data)) { $rule->setTargetRecordId($data['target_record_id'] !== null ? (int) $data['target_record_id'] : null); } + if (array_key_exists('target_service_item_id', $data)) { $rule->setTargetServiceItemId($data['target_service_item_id'] !== null ? (int) $data['target_service_item_id'] : null); } + if (array_key_exists('min_amount_rials', $data)) { $rule->setMinAmountRials($data['min_amount_rials'] !== null ? (int) $data['min_amount_rials'] : null); } + if (array_key_exists('min_visit_count', $data)) { $rule->setMinVisitCount($data['min_visit_count'] !== null ? (int) $data['min_visit_count'] : null); } + if (array_key_exists('occasion_kind', $data)) { $rule->setOccasionKind($data['occasion_kind'] !== null ? (string) $data['occasion_kind'] : null); } + } +}