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)