Skip to main content

Domain Services

Business logic lives in app/Services/. Controllers and Filament resources are thin — they validate input, call a service, and return a response. No domain logic in controllers.


RateResolutionService

Location: app/Services/Billing/RateResolutionService.php

Resolves the correct billing rate for a time entry. The resolution order follows the schema design: Matter → Client → Lawyer default.

Given: matter_id + lawyer_profile_id + activity_code + date

1. Check MATTER_RATE_PLAN_SELECTIONS for this matter
→ If found and effective, use that rate plan's rate for this activity_code
2. Check CLIENT_RATE_PLAN_SELECTIONS for the matter's client
→ If found and effective, use that rate plan's rate for this activity_code
3. Fall back to FIRM_USER_PROFILES.default_billing_rate
4. Return 0 for non-billable activity codes (BILLING_ACTIVITY_CODES.is_billable = false)

Octane safety: Registered as singleton. Stateless — takes inputs, returns a decimal. Safe to cache in Octane's application memory. Frequently resolved rate plans can be cached in an Octane table keyed by "firm:{id}:matter:{id}".


InvoiceGeneratorService

Location: app/Services/Billing/InvoiceGeneratorService.php

Generates an invoice from all approved, uninvoiced time entries for a matter.

Input: Matter $matter

Steps:
1. Load all APPROVED LawyerTimeEntries for matter where invoiced_at IS NULL
2. Group entries by activity_code
3. For each group: resolve rate via RateResolutionService, calculate line amount
4. Load applicable FirmTaxRate (effective for invoice date)
5. CREATE Invoice (append-only) with subtotal + tax + total
6. CREATE InvoiceLineItems (append-only) for each group
7. UPDATE LawyerTimeEntries: set invoiced_at = now() (this UPDATE is allowed
because invoiced_at is a tracking field, not a financial field)
8. Dispatch GenerateInvoicePdfJob

Throws InvoiceGenerationException if:
- No APPROVED uninvoiced entries exist
- Matter already has a DRAFT invoice pending

Job dispatch: The actual PDF generation is async (queued job). The service creates the database records synchronously for data integrity, then dispatches the PDF job.


RbacCheckerService

Location: app/Services/RBAC/RbacCheckerService.php

Single entry point for all permission checks. Used by every Policy.

check(User $user, string $permissionCode, int $firmId, ?int $matterId = null): bool

Resolution order:
1. Load active UserRoleAssignments for user (respect starts_at / ends_at)
2. For each assignment, load Role → RolePermissions → Permission
3. Check if any role has the requested permissionCode
4. Check ResourceAccessGrants for explicit ALLOW or DENY overrides
5. DENY grants take highest priority, then ALLOW grants, then role check

Octane safety: Registered as scoped. Holds per-request resolved permission data for the current user. Destroyed after each request.


TenantResolverService

Location: app/Services/Auth/TenantResolverService.php

Resolves the current LawFirm from different context sources:

  • Filament panels: from the URL slug (/admin/{firm-slug}/)
  • API requests: from the authenticated user's firm_user_profiles.law_firm_id
  • Sanctum tokens: from the token's associated user → firm_user_profiles

Octane safety: Registered as scoped. Must never be a singleton.


DocumentService

Location: app/Services/Document/DocumentService.php

Handles document lifecycle: presigned URL generation, post-upload processing, and version tracking.

initiateUpload(array $metadata, Matter $matter): array
→ Generates presigned S3 PUT URL via Media Library
→ Creates Document record in PENDING_UPLOAD status
→ Returns { document_id, upload_url, expires_at }

confirmUpload(Document $document): void
→ Called by ProcessDocumentJob after S3 upload detected
→ Transitions document to DRAFT status
→ Triggers Meilisearch index update

getDownloadUrl(Document $document, User $user): string
→ Checks user has access to document's parent matter
→ Generates time-limited presigned S3 GET URL (15 min TTL)