transactions->run(function () use ($input): array { $challenge = $this->emailVerification->requireVerifiedChallenge($input['email_verification_id']); $email = $this->normalizeEmail($challenge->email); $this->rejectMismatchedEmail($input, $email); $duplicate = $this->duplicateApplicationForEmail($email); if ($duplicate !== null) { if (in_array($duplicate->status, ['APPLICATION_IN_PROGRESS', 'INCOMPLETE'], true)) { $this->refreshApplicantVerificationChallenge($duplicate, $challenge->id); return [ 'application' => $this->payload($this->requireApplication($duplicate->id)), 'existing' => true, ]; } throw new ApiException('APPLICATION_ALREADY_EXISTS', 'An active application already exists for this email address.', [ 'application' => $this->publicApplicationSummary($duplicate), ], 409); } $id = $this->ids->generate(); $attributes = [ 'id' => $id, 'application_reference' => 'APP-'.Carbon::now()->format('YmdHis').'-'.substr(str_replace('-', '', $id), 0, 8), 'user_id' => null, 'status' => 'APPLICATION_IN_PROGRESS', 'pathway' => $this->pathwayFromInput($input), 'first_name' => $input['first_name'] ?? $this->firstNameFromFullName($input['full_name'] ?? ''), 'middle_name' => $input['middle_name'] ?? null, 'last_name' => $input['last_name'] ?? $this->lastNameFromFullName($input['full_name'] ?? ''), 'preferred_name' => $input['preferred_name'] ?? null, 'email' => $email, 'phone_number' => $input['phone_number'] ?? $input['phone'] ?? '', 'alternate_phone_number' => $input['alternate_phone_number'] ?? null, 'country_of_residence' => $input['country_of_residence'] ?? '', 'city_state_region' => $input['city_state_region'] ?? null, 'professional_profile' => $this->professionalProfileFromInput($input)??null, 'kenyan_professional_indicator' => (int) (bool) ($input['kenyan_professional_indicator'] ?? false), 'duplicate_flag' => 0, 'duplicate_reason' => null, 'metadata' => json_encode($this->applicantMetadata($input), JSON_THROW_ON_ERROR), ]; $attributes = array_merge($attributes, $this->structuredApplicantColumns($input)); if (Schema::hasColumn('applications', 'email_verification_challenge_id')) { $attributes['email_verification_challenge_id'] = $challenge->id; } if (Schema::hasColumn('applications', 'email_normalized')) { $attributes['email_normalized'] = $email; } $this->applications->create($attributes); $this->audit->record('APPLICATION_CREATED', null, null, 'applications', $id, 'SUCCESS', [ 'source' => 'applicant_public_intake', 'email_verification_id' => $challenge->id, ]); return [ 'application' => $this->payload($this->requireApplication($id)), 'existing' => false, ]; }); } public function getApplicant(string $id, string $emailVerificationId): array { $application = $this->requireApplicantApplication($id, $emailVerificationId); return ['application' => $this->payload($application)]; } public function updateApplicant(string $id, array $input): array { return $this->transactions->run(function () use ($id, $input): array { $application = $this->requireApplicantApplication($id, $input['email_verification_id']); $this->ensureApplicantEditable($application); $this->rejectMismatchedEmail($input, $this->normalizeEmail($application->email)); $updates = []; foreach (['middle_name', 'preferred_name', 'alternate_phone_number', 'city_state_region'] as $field) { if (array_key_exists($field, $input)) { $updates[$field] = $input[$field]; } } if (array_key_exists('full_name', $input)) { $updates['first_name'] = $this->firstNameFromFullName($input['full_name']); $updates['last_name'] = $this->lastNameFromFullName($input['full_name']); } foreach (['first_name', 'last_name', 'phone_number', 'country_of_residence'] as $field) { if (array_key_exists($field, $input)) { $updates[$field] = $input[$field]; } } if (array_key_exists('phone', $input)) { $updates['phone_number'] = $input['phone']; } if (array_key_exists('pathway', $input) || array_key_exists('isp_member', $input)) { $updates['pathway'] = $this->pathwayFromInput($input); } if ($this->hasProfessionalInput($input)) { $updates['professional_profile'] = $this->professionalProfileFromInput($input); } if (array_key_exists('kenyan_professional_indicator', $input)) { $updates['kenyan_professional_indicator'] = (int) (bool) $input['kenyan_professional_indicator']; } $updates = array_merge($updates, $this->structuredApplicantColumns($input)); $updates['metadata'] = json_encode(array_merge( $this->decodeMetadata($application->metadata ?? null), $this->applicantMetadata($input), ), JSON_THROW_ON_ERROR); $this->applications->update($id, $updates); $this->audit->record('APPLICATION_UPDATED', null, null, 'applications', $id, 'SUCCESS', [ 'source' => 'applicant_public_intake', ]); return ['application' => $this->payload($this->requireApplication($id))]; }); } public function submitApplicant(string $id, string $emailVerificationId): array { return $this->transactions->run(function () use ($id, $emailVerificationId): array { $application = $this->requireApplicantApplication($id, $emailVerificationId); $this->ensureApplicantEditable($application); $this->ensureApplicantComplete($application); if (! $this->applicantFeeRequirementSatisfied($application)) { throw new ApiException('APPLICATION_FEE_REQUIRED', 'Application fee must be paid before submission.', [ 'application_fee' => $this->applicationFeeSummary($application->id, $this->decodeMetadata($application->metadata ?? null)), ], 409); } $this->applications->update($id, ['submitted_at' => Carbon::now(), 'status' => 'APPLICATION_COMPLETED']); $this->audit->record('APPLICATION_SUBMITTED', null, null, 'applications', $id, 'SUCCESS', [ 'source' => 'applicant_public_intake', ]); $this->sendApplicationMessage($this->requireApplication($id), 'APPLICATION_SUBMITTED', 'Application submitted'); return ['application' => $this->payload($this->requireApplication($id))]; }); } public function attachApplicantDocument(string $id, array $input): array { $application = $this->requireApplicantApplication($id, $input['email_verification_id']); $this->ensureApplicantEditable($application); $file = DB::table('stored_files')->where('id', $input['file_id'])->whereNull('deleted_at')->first(); if ($file === null) { throw new ApiException('FILE_NOT_FOUND', 'File was not found.', [], 404); } DB::table('file_links')->insert([ 'id' => $this->ids->generate(), 'file_id' => $file->id, 'application_id' => $id, 'link_purpose' => $input['link_purpose'] ?? 'APPLICATION_DOCUMENT', 'linked_by_user_id' => null, 'created_at' => now(), 'updated_at' => now(), ]); $this->audit->record('APPLICATION_DOCUMENT_LINKED', null, null, 'applications', $id, 'SUCCESS', ['file_id' => $file->id]); $this->recordApplicantDocumentMetadata($application, $file->id, $input['document_type'] ?? null); return [ 'application' => $this->payload($this->requireApplication($id)), 'file_id' => $file->id, 'file' => [ 'id' => $file->id, 'original_filename' => $file->original_filename, 'mime_type' => $file->mime_type, 'file_size_bytes' => (int) $file->file_size_bytes, 'download_url' => $this->applicantFileSignedUrl($file->id), 'stream_url' => $this->applicantFileStreamUrl($file->id), 'secure_link' => $this->applicantFileSignedUrl($file->id), ], ]; } public function verifyApplicantIspMembership(string $id, array $input): array { return $this->transactions->run(function () use ($id, $input): array { $application = $this->requireApplicantApplication($id, $input['email_verification_id']); $this->ensureApplicantEditable($application); $memberNo = trim($input['member_no']); $programEmail = strtolower(trim($input['program_email'])); $response = $this->ispMembership->verify($memberNo, $programEmail); $isFullMember = $this->ispMembership->isFullMember($response); $metadata = $this->decodeMetadata($application->metadata ?? null); $metadata['isp_member'] = $isFullMember; $metadata['isp_member_number'] = $memberNo; $metadata['isp_program_email'] = $programEmail; $metadata['isp_verification_result'] = $response; $updates = [ 'pathway' => $isFullMember ? 'ISP' : 'STANDARD', 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), ]; if ($isFullMember) { if (Schema::hasColumn('applications', 'ISP_email')) { $updates['ISP_email'] = $programEmail; } if (Schema::hasColumn('applications', 'ISP_no')) { $updates['ISP_no'] = $memberNo; } $validationId = $this->applications->createIspValidation([ 'id' => $this->ids->generate(), 'application_id' => $application->id, 'validation_method' => 'API', 'isp_member_identifier' => $memberNo, 'isp_email' => $programEmail, 'status' => 'CONFIRMED', 'validated_at' => Carbon::now(), 'metadata' => json_encode(['response' => $response], JSON_THROW_ON_ERROR), ]); $this->audit->record('ISP_MEMBERSHIP_VERIFIED', null, null, 'isp_validations', $validationId, 'SUCCESS'); } else { $this->audit->record('ISP_MEMBERSHIP_NOT_FOUND', null, null, 'applications', $id, 'SUCCESS', ['member_no' => $memberNo]); } $this->applications->update($id, $updates); return [ 'isp_membership' => $response, 'is_member' => $isFullMember, 'application' => $this->payload($this->requireApplication($id)), ]; }); } public function registerApplicantDocument(string $id, array $input): array { $application = $this->requireApplicantApplication($id, $input['email_verification_id']); $this->ensureApplicantEditable($application); $fileId = $this->ids->generate(); $classification = strtoupper($input['classification'] ?? 'APPLICATION_DOCUMENT'); $uploadedFile = $input['file'] ?? null; $originalFilename = $uploadedFile instanceof UploadedFile ? $uploadedFile->getClientOriginalName() : $input['original_filename']; $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); $storageKey = $input['storage_key'] ?? 'application_documents/'.$id.'/'.$fileId.'/'.Str::slug(pathinfo($originalFilename, PATHINFO_FILENAME)).($extension ? '.'.$extension : ''); $now = Carbon::now(); $mimeType = $uploadedFile instanceof UploadedFile ? ($uploadedFile->getMimeType() ?? 'application/octet-stream') : $input['mime_type']; $fileSize = $uploadedFile instanceof UploadedFile ? ($uploadedFile->getSize() ?? 0) : $input['file_size_bytes']; if ($uploadedFile instanceof UploadedFile) { Storage::disk('local')->put($storageKey, $uploadedFile->getContent()); } DB::table('stored_files')->insert([ 'id' => $fileId, 'classification' => $classification, 'owner_user_id' => null, 'storage_provider' => 'LOCAL', 'storage_bucket' => 'local', 'storage_key' => $storageKey, 'original_filename' => $originalFilename, 'mime_type' => strtolower($mimeType), 'file_size_bytes' => (int) $fileSize, 'checksum_sha256' => $input['checksum_sha256'] ?? ($uploadedFile instanceof UploadedFile ? hash_file('sha256', $uploadedFile->getRealPath()) : null), 'virus_scan_status' => $input['virus_scan_status'] ?? 'PENDING', 'retention_until' => $input['retention_until'] ?? null, 'created_at' => $now, 'updated_at' => $now, ]); return $this->attachApplicantDocument($id, [ 'email_verification_id' => $input['email_verification_id'], 'file_id' => $fileId, 'link_purpose' => $input['link_purpose'] ?? 'APPLICATION_DOCUMENT', 'document_type' => $input['document_type'] ?? null, ]); } public function applicantApplicationFeeStatus(string $id, string $emailVerificationId): array { $application = $this->requireApplicantApplication($id, $emailVerificationId); return [ 'application' => $this->payload($application), 'application_fee' => $this->applicationFeeSummary($id, $this->decodeMetadata($application->metadata ?? null)), ]; } public function applicantApplicationFeeIntent(string $id, array $input): array { return $this->transactions->run(function () use ($id, $input): array { $application = $this->requireApplicantApplication($id, $input['email_verification_id']); $this->ensureApplicantEditable($application); $metadata = $this->decodeMetadata($application->metadata ?? null); if ($this->applicantIspVerified($metadata)) { return [ 'application' => $this->payload($application), 'application_fee' => $this->applicationFeeSummary($id, $metadata), 'exempt' => true, 'status' => 'EXEMPT', ]; } $existing = $this->latestApplicationFeePayment($id); if ($existing !== null && in_array($existing->payment_status, ['PAID', 'WAIVED'], true)) { return [ 'application' => $this->payload($application), 'application_fee' => $this->applicationFeeSummary($id, $metadata), 'payment' => $this->paymentPayloadFromJoinedRow($existing), 'checkout_url' => $existing->checkout_url ?? null, 'status' => $existing->payment_status, 'idempotent' => true, ]; } if ($existing !== null && $existing->payment_status === 'PENDING' && ! empty($existing->checkout_url)) { return [ 'application' => $this->payload($application), 'application_fee' => $this->applicationFeeSummary($id, $metadata), 'payment' => $this->paymentPayloadFromJoinedRow($existing), 'checkout_url' => $existing->checkout_url, 'status' => 'PENDING', 'idempotent' => true, ]; } $paymentMethod = strtolower((string) ($input['payment_method'] ?? 'card')); if (! in_array($paymentMethod, ['card', 'bank_transfer'], true)) { throw new ApiException('UNSUPPORTED_PAYMENT_METHOD', 'Application fee payment method is not supported.', [], 422); } $invoice = $this->applicationFeeInvoice($id); $idempotencyKey = $input['idempotency_key'] ?? 'application-fee:'.$id.':'.$paymentMethod; $intent = $this->payments->createIntent([ 'invoice_id' => $invoice->id, 'application_id' => $id, 'fee_type_code' => 'APPLICATION_FEE', 'gateway_code' => strtoupper((string) ($input['gateway_code'] ?? 'STRIPE')), 'payment_method' => $paymentMethod, 'customer_email' => $application->email, 'return_url' => $input['return_url'] ?? config('payments.stripe.success_url'), 'cancel_url' => $input['cancel_url'] ?? config('payments.stripe.cancel_url'), 'idempotency_key' => $idempotencyKey, 'test_run_id' => $input['test_run_id'] ?? null, ], null); $payment = isset($intent['payment']['id']) ? DB::table('payments')->where('id', $intent['payment']['id'])->first() : null; return [ 'application' => $this->payload($this->requireApplication($id)), 'application_fee' => $this->applicationFeeSummary($id, $metadata), 'payment' => $intent['payment'] ?? null, 'checkout_url' => $payment->checkout_url ?? ($intent['payment']['checkout_url'] ?? null), 'status' => $payment->status ?? ($intent['payment']['status'] ?? 'PENDING'), 'idempotent' => (bool) ($intent['idempotent'] ?? false), ]; }); } public function create(array $input, string $actorUserId): array { $actor = DB::table('users')->where('id', $actorUserId)->first(); if ($actor === null || $actor->email_verified_at === null) { throw new ApiException('EMAIL_VERIFICATION_REQUIRED', 'Email verification is required before creating an application.', [], 403); } $id = $this->ids->generate(); $email = strtolower(trim($input['email'] ?? $actor->email)); $duplicate = DB::table('applications') ->where('email', $email) ->whereNull('deleted_at') ->exists(); $this->applications->create([ 'id' => $id, 'application_reference' => 'APP-'.Carbon::now()->format('YmdHis').'-'.substr(str_replace('-', '', $id), 0, 8), 'user_id' => $actorUserId, 'status' => 'APPLICATION_IN_PROGRESS', 'pathway' => strtoupper($input['pathway'] ?? 'STANDARD'), 'first_name' => $input['first_name'], 'middle_name' => $input['middle_name'] ?? null, 'last_name' => $input['last_name'], 'preferred_name' => $input['preferred_name'] ?? null, 'email' => $email, 'phone_number' => $input['phone_number'], 'alternate_phone_number' => $input['alternate_phone_number'] ?? null, 'country_of_residence' => $input['country_of_residence'], 'city_state_region' => $input['city_state_region'] ?? null, 'professional_profile' => $input['professional_profile'], 'kenyan_professional_indicator' => (int) (bool) ($input['kenyan_professional_indicator'] ?? false), 'duplicate_flag' => $duplicate ? 1 : 0, 'duplicate_reason' => $duplicate ? 'Existing application uses the same email.' : null, 'metadata' => json_encode(['test_run_id' => $input['test_run_id'] ?? null], JSON_THROW_ON_ERROR), ]); $this->audit->record('APPLICATION_CREATED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['duplicate_flag' => $duplicate]); return ['application' => $this->payload($this->requireApplication($id))]; } public function get(string $id, string $actorUserId): array { $application = $this->requireApplication($id); $this->authorizeAccess($application, $actorUserId); return ['application' => $this->payload($application)]; } public function update(string $id, array $input, string $actorUserId): array { $application = $this->requireApplication($id); $this->authorizeAccess($application, $actorUserId); if ($application->status !== 'APPLICATION_IN_PROGRESS' && ! $this->policy->canManage($actorUserId)) { throw new ApiException('APPLICATION_NOT_EDITABLE', 'Application cannot be edited in its current state.', [], 409); } $updates = []; foreach (['first_name', 'middle_name', 'last_name', 'preferred_name', 'phone_number', 'alternate_phone_number', 'country_of_residence', 'city_state_region', 'professional_profile'] as $field) { if (array_key_exists($field, $input)) { $updates[$field] = $input[$field]; } } if (array_key_exists('kenyan_professional_indicator', $input)) { $updates['kenyan_professional_indicator'] = (int) (bool) $input['kenyan_professional_indicator']; } if (array_key_exists('pathway', $input)) { $updates['pathway'] = strtoupper($input['pathway']); } if ($updates !== []) { $this->applications->update($id, $updates); $this->audit->record('APPLICATION_UPDATED', $actorUserId, null, 'applications', $id, 'SUCCESS'); } return ['application' => $this->payload($this->requireApplication($id))]; } public function acceptConsents(string $id, array $input, Request $request): array { $actorUserId = $request->attributes->get('actor_user_id'); $application = $this->requireApplication($id); $this->authorizeAccess($application, $actorUserId); $types = array_map(fn ($type): string => strtoupper((string) $type), $input['consent_types'] ?? self::REQUIRED_CONSENTS); $created = []; foreach (array_unique($types) as $type) { $created[] = $this->applications->createConsent([ 'id' => $this->ids->generate(), 'application_id' => $id, 'user_id' => $actorUserId, 'consent_type' => $type, 'consent_version' => $input['consent_version'] ?? 'v1', 'accepted_by_name' => $input['accepted_by_name'] ?? trim($application->first_name.' '.$application->last_name), 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'evidence_metadata' => json_encode(['request_id' => $request->attributes->get('request_id')], JSON_THROW_ON_ERROR), ]); } $this->audit->record('APPLICATION_CONSENT_ACCEPTED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['consent_types' => $types]); return ['application' => $this->payload($this->requireApplication($id)), 'consent_ids' => $created]; } public function submit(string $id, string $actorUserId): array { return $this->transactions->run(function () use ($id, $actorUserId): array { $application = $this->requireApplication($id); $this->authorizeAccess($application, $actorUserId); $this->ensureComplete($application); $this->ensureRequiredConsents($id); $status = 'APPLICATION_COMPLETED'; $updates = ['status' => $status, 'submitted_at' => Carbon::now()]; $this->applications->update($id, $updates); $this->audit->record('APPLICATION_SUBMITTED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['status' => $status]); $this->sendApplicationMessage($this->requireApplication($id), 'APPLICATION_SUBMITTED', 'Application submitted'); return ['application' => $this->payload($this->requireApplication($id))]; }); } public function status(string $id, string $actorUserId): array { $application = $this->refreshReadiness($this->requireApplication($id), $actorUserId); $this->authorizeAccess($application, $actorUserId); return [ 'application' => $this->payload($application), 'readiness' => [ 'required_consents_accepted' => count(array_intersect(self::REQUIRED_CONSENTS, $this->applications->consentsFor($id))) === count(self::REQUIRED_CONSENTS), 'application_fee_paid' => $this->applicationFeePaid($id), 'isp_exemption_confirmed' => $this->applications->confirmedIspValidation($id) !== null, 'review_completed' => $this->reviewCompleted($id), 'vetting_passed' => $this->vettingPassed($id), ], ]; } public function attachDocument(string $id, array $input, string $actorUserId): array { $application = $this->requireApplication($id); $this->authorizeAccess($application, $actorUserId); $file = DB::table('stored_files')->where('id', $input['file_id'])->whereNull('deleted_at')->first(); if ($file === null) { throw new ApiException('FILE_NOT_FOUND', 'File was not found.', [], 404); } DB::table('file_links')->insert([ 'id' => $this->ids->generate(), 'file_id' => $file->id, 'application_id' => $id, 'link_purpose' => $input['link_purpose'] ?? 'APPLICATION_DOCUMENT', 'linked_by_user_id' => $actorUserId, 'created_at' => now(), 'updated_at' => now(), ]); $this->audit->record('APPLICATION_DOCUMENT_LINKED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['file_id' => $file->id]); return [ 'application' => $this->payload($application), 'file_id' => $file->id, 'file' => [ 'id' => $file->id, 'original_filename' => $file->original_filename, 'mime_type' => $file->mime_type, 'file_size_bytes' => (int) $file->file_size_bytes, 'download_url' => $this->applicantFileSignedUrl($file->id), 'stream_url' => $this->applicantFileStreamUrl($file->id), 'secure_link' => $this->applicantFileSignedUrl($file->id), ], ]; } public function verifyIspMembership(string $id, array $input, string $actorUserId): array { return $this->transactions->run(function () use ($id, $input, $actorUserId): array { $application = $this->requireApplication($id); $this->authorizeAccess($application, $actorUserId); if ($application->status !== 'APPLICATION_IN_PROGRESS') { throw new ApiException('APPLICATION_NOT_EDITABLE', 'Application cannot be edited in its current state.', [], 409); } $memberNo = trim($input['member_no']); $programEmail = strtolower(trim($input['program_email'])); $response = $this->ispMembership->verify($memberNo, $programEmail); $isFullMember = $this->ispMembership->isFullMember($response); if ($isFullMember) { $validationId = $this->applications->createIspValidation([ 'id' => $this->ids->generate(), 'application_id' => $application->id, 'validation_method' => 'API', 'isp_member_identifier' => $memberNo, 'isp_email' => $programEmail, 'status' => 'CONFIRMED', 'validated_at' => Carbon::now(), 'metadata' => json_encode(['response' => $response], JSON_THROW_ON_ERROR), ]); $this->applications->update($id, [ 'pathway' => 'ISP', 'ISP_email' => $programEmail, 'ISP_no' => $memberNo, ]); $this->audit->record('ISP_MEMBERSHIP_VERIFIED', $actorUserId, null, 'isp_validations', $validationId, 'SUCCESS'); } else { $this->audit->record('ISP_MEMBERSHIP_NOT_FOUND', $actorUserId, null, 'applications', $id, 'SUCCESS', ['member_no' => $memberNo]); } return [ 'isp_membership' => $response, 'is_member' => $isFullMember, 'application' => $this->payload($this->requireApplication($id)), ]; }); } public function requestIspValidation(array $input, string $actorUserId): array { $application = $this->requireApplication($input['application_id']); $this->authorizeAccess($application, $actorUserId); $id = $this->applications->createIspValidation([ 'id' => $this->ids->generate(), 'application_id' => $application->id, 'validation_method' => 'MANUAL', 'isp_member_identifier' => $input['isp_member_identifier'] ?? null, 'isp_email' => $input['isp_email'] ?? null, 'referral_token' => $input['referral_token'] ?? null, 'status' => 'PENDING', 'evidence_file_id' => $input['evidence_file_id'] ?? null, 'metadata' => json_encode(['test_run_id' => $input['test_run_id'] ?? null], JSON_THROW_ON_ERROR), ]); $this->applications->update($application->id, ['pathway' => 'ISP']); $this->audit->record('ISP_VALIDATION_REQUESTED', $actorUserId, null, 'isp_validations', $id, 'SUCCESS'); return ['isp_validation' => (array) $this->applications->findIspValidation($id), 'application' => $this->payload($this->requireApplication($application->id))]; } public function manualConfirmIsp(string $validationId, array $input, string $actorUserId): array { return $this->manualCompleteIsp($validationId, 'CONFIRMED', $input['reason'], $actorUserId); } public function manualRejectIsp(string $validationId, array $input, string $actorUserId): array { return $this->manualCompleteIsp($validationId, 'REJECTED', $input['reason'], $actorUserId); } public function reviewQueue(string $actorUserId): array { return ['applications' => array_map(fn (object $app): array => $this->payload($app), $this->applications->readyQueue())]; } public function reviewApplications(array $filters, string $actorUserId): array { $result = $this->applications->searchForReview($filters); return [ 'applications' => array_map(function (object $application): array { $payload = $this->payload($application); $payload['blockers'] = $this->reviewBlockers($application->id); $payload['open_tasks_count'] = count($this->openBlockingTasks($application->id)); return $payload; }, $result['applications']), 'pagination' => $result['pagination'], ]; } public function reviewApplicationDetail(string $id, string $actorUserId): array { $application = $this->requireApplication($id); if (! $this->policy->canManage($actorUserId)) { throw new ApiException('FORBIDDEN', 'You are not authorized to review applications.', [], 403); } return [ 'application' => $this->payload($application), 'review' => $this->rowPayload($this->applications->latestReview($id)), 'consents' => DB::table('application_consents')->where('application_id', $id)->whereNull('deleted_at')->get()->all(), 'documents' => $this->applicationFilesPayload($id), 'tasks' => $this->reviewTasks($id), 'vetting_records' => DB::table('vetting_records')->where('application_id', $id)->whereNull('deleted_at')->orderByDesc('created_at')->get()->all(), 'document_requests' => $this->documentRequestsPayload($id), 'decisions' => DB::table('application_decisions')->where('application_id', $id)->whereNull('deleted_at')->orderByDesc('decided_at')->get()->all(), 'blockers' => $this->reviewBlockers($id), ]; } public function assignReview(string $id, array $input, string $actorUserId): array { $application = $this->requireApplication($id); $review = $this->applications->latestReview($id); $reviewerId = $input['assigned_to_user_id'] ?? $actorUserId; if ($review === null) { $reviewId = $this->applications->createReview([ 'id' => $this->ids->generate(), 'application_id' => $id, 'assigned_to_user_id' => $reviewerId, 'review_status' => 'ASSIGNED', ]); } else { $reviewId = $review->id; $this->applications->updateReview($review->id, ['assigned_to_user_id' => $reviewerId, 'review_status' => 'ASSIGNED']); } $this->audit->record('APPLICATION_REVIEW_ASSIGNED', $actorUserId, null, 'applications', $id, 'SUCCESS'); return ['application' => $this->payload($application), 'review' => (array) DB::table('application_reviews')->where('id', $reviewId)->first()]; } public function startReview(string $id, string $actorUserId): array { $application = $this->refreshReadiness($this->requireApplication($id), $actorUserId); if (! in_array($application->status, ['APPLICATION_COMPLETED', 'CLARIFICATION_SUBMITTED', 'NEW'], true) || ($application->status === 'NEW' && empty($application->submitted_at))) { throw new ApiException('APPLICATION_NOT_READY_FOR_REVIEW', 'Application cannot enter review before fee or ISP readiness is complete.', [], 409); } $review = $this->applications->latestReview($id); if ($review === null) { $reviewId = $this->applications->createReview([ 'id' => $this->ids->generate(), 'application_id' => $id, 'assigned_to_user_id' => $actorUserId, 'review_status' => 'IN_PROGRESS', 'started_at' => Carbon::now(), ]); } else { $reviewId = $review->id; $this->applications->updateReview($review->id, ['review_status' => 'IN_PROGRESS', 'started_at' => $review->started_at ?? Carbon::now()]); } $this->transitionApplication($application, 'IN_REVIEW', $actorUserId, 'APPLICATION_REVIEW_STARTED'); $this->audit->record('APPLICATION_REVIEW_STARTED', $actorUserId, null, 'applications', $id, 'SUCCESS'); return ['application' => $this->payload($this->requireApplication($id)), 'review' => (array) DB::table('application_reviews')->where('id', $reviewId)->first()]; } public function addReviewNote(string $id, array $input, string $actorUserId): array { $review = $this->applications->latestReview($id); if ($review === null) { throw new ApiException('REVIEW_NOT_STARTED', 'Review must be started before notes are added.', [], 409); } $this->applications->updateReview($review->id, [ 'review_notes' => trim(($review->review_notes ? $review->review_notes.PHP_EOL : '').$input['note']), 'review_status' => $input['complete'] ?? false ? 'COMPLETED' : $review->review_status, 'completed_at' => $input['complete'] ?? false ? Carbon::now() : $review->completed_at, 'completeness_passed' => $input['complete'] ?? false ? 1 : $review->completeness_passed, 'fee_or_exemption_confirmed' => $input['complete'] ?? false ? 1 : $review->fee_or_exemption_confirmed, 'document_review_status' => $input['complete'] ?? false ? 'PASSED' : $review->document_review_status, ]); $this->audit->record('APPLICATION_REVIEW_NOTE_ADDED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['complete' => (bool) ($input['complete'] ?? false)]); return ['review' => (array) DB::table('application_reviews')->where('id', $review->id)->first()]; } public function startVetting(string $id, string $actorUserId): array { if (! $this->reviewCompleted($id)) { throw new ApiException('REVIEW_NOT_COMPLETED', 'Review must be completed before vetting starts.', [], 409); } $taskId = $this->createReviewTask($id, 'VETTING', true, $actorUserId, null, ['source' => 'legacy_start_vetting']); $vettingId = $this->applications->createVetting([ 'id' => $this->ids->generate(), 'application_id' => $id, 'review_task_id' => $taskId, 'vetting_officer_user_id' => $actorUserId, 'vetting_status' => 'IN_PROGRESS', 'meeting_status' => 'REQUESTED', 'started_at' => Carbon::now(), ]); $this->transitionApplication($this->requireApplication($id), 'AWAITING_APPLICANT_ACTION', $actorUserId, 'APPLICATION_VETTING_STARTED'); $this->audit->record('APPLICATION_VETTING_STARTED', $actorUserId, null, 'applications', $id, 'SUCCESS'); return ['application' => $this->payload($this->requireApplication($id)), 'vetting' => (array) DB::table('vetting_records')->where('id', $vettingId)->first()]; } public function vettingOutcome(string $id, array $input, string $actorUserId): array { $vetting = $this->applications->latestVetting($id); if ($vetting === null) { throw new ApiException('VETTING_NOT_STARTED', 'Vetting must be started before outcome is recorded.', [], 409); } $outcome = strtoupper($input['outcome']); $this->applications->updateVetting($vetting->id, [ 'vetting_status' => $outcome, 'completed_at' => Carbon::now(), 'outcome_reason' => $input['reason'] ?? null, 'internal_notes' => $input['internal_notes'] ?? null, ]); if (! empty($vetting->review_task_id)) { $this->closeReviewTask($vetting->review_task_id, in_array($outcome, ['PASS', 'PASSED', 'APPROVED'], true) ? 'COMPLETED' : 'FAILED', $actorUserId, [ 'outcome' => $outcome, ]); } $this->audit->record('APPLICATION_VETTING_COMPLETED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['outcome' => $outcome]); return ['vetting' => (array) DB::table('vetting_records')->where('id', $vetting->id)->first()]; } public function requestClarification(string $id, array $input, string $actorUserId): array { $application = $this->requireApplication($id); $taskId = $this->createReviewTask($id, 'CLARIFICATION', true, $actorUserId, $input['due_at'] ?? Carbon::now()->addDays(7), [ 'request_text' => $input['request_text'], ]); $clarificationId = $this->applications->createClarification([ 'id' => $this->ids->generate(), 'application_id' => $id, 'requested_by_user_id' => $actorUserId, 'request_text' => $input['request_text'], 'due_at' => $input['due_at'] ?? Carbon::now()->addDays(7), 'status' => 'OPEN', ]); $this->transitionApplication($application, 'AWAITING_APPLICANT_ACTION', $actorUserId, 'APPLICATION_CLARIFICATION_REQUESTED'); $this->sendApplicationMessage($application, 'APPLICATION_CLARIFICATION_REQUESTED', $input['request_text']); $this->audit->record('APPLICATION_CLARIFICATION_REQUESTED', $actorUserId, null, 'applications', $id, 'SUCCESS'); return ['application' => $this->payload($this->requireApplication($id)), 'clarification' => (array) DB::table('application_clarifications')->where('id', $clarificationId)->first()]; } public function respondClarification(string $id, array $input, string $actorUserId): array { $application = $this->requireApplication($id); $this->authorizeAccess($application, $actorUserId); $clarification = $this->applications->latestOpenClarification($id); if ($clarification === null) { throw new ApiException('CLARIFICATION_NOT_OPEN', 'There is no open clarification request for this application.', [], 409); } $this->applications->updateClarification($clarification->id, [ 'response_text' => $input['response_text'], 'responded_at' => Carbon::now(), 'status' => 'RESPONDED', ]); $this->closeLatestOpenTask($id, 'CLARIFICATION', 'COMPLETED', $actorUserId); $this->transitionApplication($application, 'CLARIFICATION_SUBMITTED', $actorUserId, 'APPLICATION_CLARIFICATION_RESPONDED'); $this->audit->record('APPLICATION_CLARIFICATION_RESPONDED', $actorUserId, null, 'applications', $id, 'SUCCESS'); return ['application' => $this->payload($this->requireApplication($id)), 'clarification' => (array) DB::table('application_clarifications')->where('id', $clarification->id)->first()]; } public function decision(string $id, array $input, string $actorUserId): array { $application = $this->requireApplication($id); $decision = strtoupper($input['decision']); if (in_array($decision, ['REJECTED', 'DEFERRED'], true) && trim((string) ($input['reason'] ?? '')) === '') { throw new ApiException('DECISION_REASON_REQUIRED', 'Rejection and deferral decisions require a reason.', [], 422); } if ($decision === 'APPROVED') { $blockers = $this->reviewBlockers($id); if ($blockers !== []) { throw new ApiException('APPLICATION_APPROVAL_BLOCKED', 'Application cannot be approved while review blockers remain open.', ['blockers' => $blockers], 409); } } $decisionId = $this->applications->createDecision([ 'id' => $this->ids->generate(), 'application_id' => $id, 'decision' => $decision, 'decision_reason' => $input['reason'] ?? null, 'decided_by_user_id' => $actorUserId, ]); if ($decision === 'REJECTED') { $this->cancelOpenReviewTasks($id, $actorUserId, 'application_rejected'); $this->transitionApplication($application, 'REJECTED', $actorUserId, 'APPLICATION_REJECTED', $input['reason'] ?? null); $this->sendApplicationMessage($application, 'APPLICATION_REJECTED', $input['reason'] ?? 'Your application was not approved.'); } elseif ($decision === 'APPROVED') { $token = $this->createApprovalToken($application); $paymentUrl = $this->approvalPaymentUrl($token); $this->transitionApplication($application, 'APPROVED', $actorUserId, 'APPLICATION_APPROVED'); $this->sendApplicationMessage($application, 'APPLICATION_APPROVED', $paymentUrl); } else { $this->transitionApplication($application, $decision, $actorUserId, 'APPLICATION_DECISION_'.$decision, $input['reason'] ?? null); } $this->audit->record('APPLICATION_DECISION_RECORDED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['decision' => $decision]); return ['application' => $this->payload($this->requireApplication($id)), 'decision' => (array) DB::table('application_decisions')->where('id', $decisionId)->first()]; } public function requestVettingMeeting(string $id, array $input, string $actorUserId): array { return $this->transactions->run(function () use ($id, $input, $actorUserId): array { $application = $this->requireApplication($id); if (! $this->policy->canManage($actorUserId)) { throw new ApiException('FORBIDDEN', 'You are not authorized to request vetting.', [], 403); } $vettingId = $this->ids->generate(); $bookingUrl = $this->calendlyBookingUrl('vetting', $input['calendly_url'] ?? null); if (empty($bookingUrl)) { throw new ApiException('CALENDLY_URL_REQUIRED', 'Calendly booking URL is not configured.', [], 422); } $bookingUrl = $this->calendlyTrackedBookingUrl($bookingUrl, $id, $vettingId); $taskId = $this->createReviewTask($id, 'VETTING', true, $actorUserId, $input['due_at'] ?? null, [ 'calendly_event_type' => 'VETTING', 'calendly_url' => $bookingUrl, 'application_id' => $id, 'vetting_id' => $vettingId, 'message' => $input['message'] ?? null, ]); $this->applications->createVetting([ 'id' => $vettingId, 'application_id' => $id, 'review_task_id' => $taskId, 'vetting_officer_user_id' => $actorUserId, 'vetting_status' => 'REQUESTED', 'meeting_status' => 'REQUESTED', 'meeting_url' => $bookingUrl, 'started_at' => Carbon::now(), ]); $this->transitionApplication($application, 'AWAITING_APPLICANT_ACTION', $actorUserId, 'APPLICATION_VETTING_REQUESTED'); $this->sendApplicationMessage($application, 'APPLICATION_VETTING_REQUESTED', $bookingUrl); $this->audit->record('APPLICATION_VETTING_REQUESTED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['vetting_id' => $vettingId]); return $this->reviewApplicationDetail($id, $actorUserId); }); } public function markVettingAttended(string $id, string $vettingId, array $input, string $actorUserId): array { return $this->completeVettingMeeting($id, $vettingId, 'ATTENDED', 'PASSED', $input, $actorUserId); } public function markVettingMissed(string $id, string $vettingId, array $input, string $actorUserId): array { return $this->completeVettingMeeting($id, $vettingId, 'MISSED', 'FAILED', $input, $actorUserId); } public function handleCalendlyWebhook(string $payload, array $headers): array { $secret = (string) config('app.calendly_webhook_secret'); if ($secret === '') { throw new ApiException('CALENDLY_WEBHOOK_SECRET_REQUIRED', 'Calendly webhook secret is not configured.', [], 503); } $signature = $headers['calendly-webhook-signature'][0] ?? $headers['Calendly-Webhook-Signature'][0] ?? $headers['x-calendly-webhook-signature'][0] ?? $headers['X-Calendly-Webhook-Signature'][0] ?? null; if ($signature === null || ! $this->validCalendlySignature($payload, $signature, $secret)) { throw new ApiException('INVALID_CALENDLY_SIGNATURE', 'Calendly webhook signature is invalid.', [], 401); } $event = json_decode($payload, true); if (! is_array($event)) { throw new ApiException('INVALID_CALENDLY_PAYLOAD', 'Calendly webhook payload is invalid.', [], 422); } $payloadNode = $event['payload'] ?? $event; $eventId = (string) ( $payloadNode['uri'] ?? $payloadNode['uuid'] ?? $payloadNode['event'] ?? $payloadNode['scheduled_event']['uri'] ?? hash('sha256', $payload) ); $eventUri = $payloadNode['event'] ?? $payloadNode['scheduled_event']['uri'] ?? $payloadNode['event_uri'] ?? null; $inviteeUri = $payloadNode['uri'] ?? $payloadNode['invitee'] ?? $payloadNode['invitee_uri'] ?? null; $vettingId = $this->calendlyTrackingValue($payloadNode, 'utm_term'); $applicationId = $this->calendlyTrackingValue($payloadNode, 'utm_content') ?? $payloadNode['questions_and_answers'][0]['answer'] ?? null; Log::info('Calendly webhook matching values', [ 'event_type' => $event['event'] ?? $event['type'] ?? null, 'event_id' => $eventId, 'event_uri' => $eventUri, 'invitee_uri' => $inviteeUri, 'application_id' => $applicationId, 'vetting_id' => $vettingId, 'tracking_keys' => array_keys($payloadNode['tracking'] ?? []), ]); return $this->transactions->run(function () use ($event, $payloadNode, $eventId, $eventUri, $inviteeUri, $vettingId, $applicationId): array { if (DB::table('vetting_records') ->where('webhook_event_id', $eventId) ->whereNull('deleted_at') ->exists() ) { return [ 'duplicate' => true, 'event_id' => $eventId, ]; } $vetting = null; if ($eventUri !== null) { $vetting = DB::table('vetting_records') ->where('calendly_event_uri', $eventUri) ->whereNull('deleted_at') ->orderByDesc('created_at') ->first(); } if ($vetting === null && $vettingId !== null) { $vetting = DB::table('vetting_records') ->where('id', $vettingId) ->whereIn('vetting_status', ['REQUESTED', 'IN_PROGRESS']) ->whereNull('deleted_at') ->first(); } if ($vetting === null && $applicationId !== null) { $vetting = DB::table('vetting_records') ->where('application_id', $applicationId) ->whereIn('vetting_status', ['REQUESTED', 'IN_PROGRESS']) ->whereNull('deleted_at') ->orderByDesc('created_at') ->first(); } if ($vetting === null) { throw new ApiException( 'VETTING_RECORD_NOT_FOUND', 'No vetting record matched the Calendly webhook.', [ 'event_uri' => $eventUri, 'invitee_uri' => $inviteeUri, 'application_id' => $applicationId, 'vetting_id' => $vettingId, ], 404 ); } $scheduledAt = $this->calendlyDateTimeForDatabase( $payloadNode['scheduled_event']['start_time'] ?? $payloadNode['start_time'] ?? null ); $meetingUrl = $payloadNode['scheduled_event']['location']['join_url'] ?? $payloadNode['location']['join_url'] ?? $payloadNode['meeting_url'] ?? null; $eventName = strtolower((string) ($event['event'] ?? $event['type'] ?? '')); $meetingStatus = str_contains($eventName, 'canceled') ? 'CANCELED' : 'SCHEDULED'; $this->applications->updateVetting($vetting->id, [ 'calendly_event_uri' => $eventUri ?? $vetting->calendly_event_uri ?? null, 'calendly_invitee_uri' => $inviteeUri, 'calendly_scheduled_at' => $scheduledAt, 'meeting_status' => $meetingStatus, 'meeting_url' => $meetingUrl ?? $vetting->meeting_url ?? null, 'webhook_event_id' => $eventId, ]); if (! empty($vetting->review_task_id) && $meetingStatus === 'SCHEDULED') { $taskMetadata = $this->decodeMetadata( DB::table('application_review_tasks') ->where('id', $vetting->review_task_id) ->value('metadata') ); DB::table('application_review_tasks') ->where('id', $vetting->review_task_id) ->whereNull('deleted_at') ->update([ 'status' => 'PENDING', 'metadata' => json_encode(array_merge($taskMetadata, [ 'calendly_event_id' => $eventId, 'calendly_event_uri' => $eventUri, 'calendly_invitee_uri' => $inviteeUri, 'calendly_scheduled_at' => $scheduledAt, ]), JSON_THROW_ON_ERROR), 'updated_at' => now(), ]); } if ($meetingStatus === 'SCHEDULED') { $this->transitionApplication( $this->requireApplication($vetting->application_id), 'VETTING_SCHEDULED', null, 'CALENDLY_MEETING_SCHEDULED' ); } return [ 'event_id' => $eventId, 'meeting_status' => $meetingStatus, 'vetting' => (array) DB::table('vetting_records') ->where('id', $vetting->id) ->first(), ]; }); } public function requestDocuments(string $id, array $input, string $actorUserId): array { return $this->transactions->run(function () use ($id, $input, $actorUserId): array { $application = $this->requireApplication($id); if (! $this->policy->canManage($actorUserId)) { throw new ApiException('FORBIDDEN', 'You are not authorized to request documents.', [], 403); } $items = $input['documents'] ?? []; if ($items === []) { throw new ApiException('DOCUMENT_ITEMS_REQUIRED', 'At least one document item is required.', [], 422); } $plainToken = Str::random(48); $tokenHash = hash('sha256', $plainToken); $taskId = $this->createReviewTask($id, 'DOCUMENT_REQUEST', true, $actorUserId, $input['due_at'] ?? null, [ 'title' => $input['title'] ?? null, ]); $requestId = $this->ids->generate(); DB::table('application_document_requests')->insert([ 'id' => $requestId, 'application_id' => $id, 'review_task_id' => $taskId, 'requested_by_user_id' => $actorUserId, 'applicant_email' => $application->email, 'token_hash' => $tokenHash, 'title' => $input['title'] ?? 'Additional documents required', 'message' => $input['message'] ?? null, 'status' => 'OPEN', 'requested_at' => now(), 'expires_at' => $input['expires_at'] ?? Carbon::now()->addDays(14), 'metadata' => json_encode(['document_count' => count($items)], JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(), ]); foreach ($items as $item) { DB::table('application_document_request_items')->insert([ 'id' => $this->ids->generate(), 'document_request_id' => $requestId, 'document_type' => strtoupper((string) $item['document_type']), 'label' => $item['label'] ?? $item['document_type'], 'instructions' => $item['instructions'] ?? null, 'status' => 'PENDING', 'created_at' => now(), 'updated_at' => now(), ]); } $uploadUrl = $this->documentUploadUrl($plainToken); $this->transitionApplication($application, 'AWAITING_APPLICANT_ACTION', $actorUserId, 'APPLICATION_DOCUMENTS_REQUESTED'); $this->sendApplicationMessage($application, 'APPLICATION_DOCUMENTS_REQUESTED', $uploadUrl); $this->audit->record('APPLICATION_DOCUMENTS_REQUESTED', $actorUserId, null, 'applications', $id, 'SUCCESS', ['document_request_id' => $requestId]); return array_merge($this->reviewApplicationDetail($id, $actorUserId), [ 'document_request_token' => $plainToken, 'document_upload_url' => $uploadUrl, ]); }); } public function getDocumentRequestByToken(string $token): array { $request = $this->requireDocumentRequestToken($token); return [ 'document_request' => $this->publicDocumentRequestPayload($request), 'application' => $this->publicApplicationSummary($this->requireApplication($request->application_id)), ]; } public function submitRequestedDocuments(string $token, array $input): array { return $this->transactions->run(function () use ($token, $input): array { $request = $this->requireDocumentRequestToken($token); $items = DB::table('application_document_request_items') ->where('document_request_id', $request->id) ->whereNull('deleted_at') ->get(); $uploads = $input['documents'] ?? []; if ($uploads === []) { $uploads[] = $input; } foreach ($uploads as $upload) { $item = null; if (! empty($upload['item_id'])) { $item = $items->first(fn (object $candidate): bool => $candidate->id === $upload['item_id']); } if ($item === null && ! empty($upload['document_type'])) { $type = strtoupper((string) $upload['document_type']); $item = $items->first(fn (object $candidate): bool => $candidate->document_type === $type && $candidate->status !== 'SUBMITTED'); } if ($item === null) { throw new ApiException('DOCUMENT_REQUEST_ITEM_NOT_FOUND', 'Document request item was not found.', [], 404); } $filePayload = $this->registerRequestedDocumentFile($request, $upload, $item); DB::table('application_document_request_items')->where('id', $item->id)->whereNull('deleted_at')->update([ 'file_id' => $filePayload['file_id'], 'status' => 'SUBMITTED', 'submitted_at' => now(), 'updated_at' => now(), ]); } $remaining = DB::table('application_document_request_items') ->where('document_request_id', $request->id) ->whereIn('status', ['PENDING', 'REJECTED']) ->whereNull('deleted_at') ->count(); if ($remaining === 0) { DB::table('application_document_requests')->where('id', $request->id)->update([ 'status' => 'SUBMITTED', 'completed_at' => now(), 'updated_at' => now(), ]); if (! empty($request->review_task_id)) { $this->closeReviewTask($request->review_task_id, 'COMPLETED', null); } $this->transitionApplication($this->requireApplication($request->application_id), 'CLARIFICATION_SUBMITTED', null, 'APPLICATION_DOCUMENTS_SUBMITTED'); } return $this->getDocumentRequestByToken($token); }); } public function validateApprovalToken(string $token): array { $application = $this->requireApprovedApplicationByToken($token); return [ 'application' => $this->payload($application), 'approval' => [ 'valid' => true, 'payment_type' => 'membership_fee', 'payment_form_url' => $this->approvalPaymentUrl($token), ], 'annual_membership_fee' => $this->feeTypePayload('ANNUAL_MEMBERSHIP_FEE'), ]; } public function createApprovalPaymentIntent(string $token, array $input): array { $application = $this->requireApprovedApplicationByToken($token); $latestPayment = $this->latestAnnualMembershipPayment($application->id); $invoice = $this->annualMembershipInvoice($application->id); if (($latestPayment !== null && in_array($latestPayment->payment_status, ['PAID', 'WAIVED'], true)) || ($invoice !== null && in_array($invoice->status, ['PAID', 'WAIVED'], true))) { return [ 'application' => $this->publicApplicationSummary($application), 'annual_membership_fee' => $this->feeTypePayload('ANNUAL_MEMBERSHIP_FEE'), 'invoice' => $this->rowPayload($invoice), 'payment' => $latestPayment === null ? null : (array) $latestPayment, 'already_paid' => true, 'idempotent' => true, ]; } $paymentMethod = strtolower((string) ($input['payment_method'] ?? 'card')); $result = $this->payments->createIntent([ 'invoice_id' => $invoice?->id, 'application_id' => $application->id, 'fee_type_code' => 'ANNUAL_MEMBERSHIP_FEE', 'payment_method' => $paymentMethod, 'gateway_code' => strtoupper((string) ($input['gateway_code'] ?? 'STRIPE')), 'return_url' => $input['return_url'] ?? null, 'cancel_url' => $input['cancel_url'] ?? null, 'customer_email' => $application->email, 'idempotency_key' => $input['idempotency_key'] ?? 'membership-fee:'.$application->id.':'.$paymentMethod, ], null); return [ 'application' => $this->publicApplicationSummary($application), 'annual_membership_fee' => $this->feeTypePayload('ANNUAL_MEMBERSHIP_FEE'), 'invoice' => $this->rowPayload($this->annualMembershipInvoice($application->id)), 'payment' => $result['payment'], 'checkout_url' => $result['payment']['checkout_url'] ?? null, 'already_paid' => false, 'idempotent' => (bool) ($result['idempotent'] ?? false), ]; } public function approvalPaymentStatus(string $token): array { $application = $this->requireApprovedApplicationByToken($token); $payment = $this->latestAnnualMembershipPayment($application->id); $invoice = $this->annualMembershipInvoice($application->id); $status = $payment?->payment_status ?? $invoice?->status ?? 'NOT_STARTED'; $paid = in_array($status, ['PAID', 'WAIVED'], true); return [ 'application' => $this->publicApplicationSummary($application), 'annual_membership_fee' => $this->feeTypePayload('ANNUAL_MEMBERSHIP_FEE'), 'invoice' => $this->rowPayload($invoice), 'payment' => $payment === null ? null : (array) $payment, 'status' => $status, 'paid' => $paid, 'already_paid' => $paid, ]; } private function completeVettingMeeting(string $applicationId, string $vettingId, string $meetingStatus, string $taskStatus, array $input, string $actorUserId): array { $vetting = DB::table('vetting_records')->where('id', $vettingId)->where('application_id', $applicationId)->whereNull('deleted_at')->first(); if ($vetting === null) { throw new ApiException('VETTING_NOT_FOUND', 'Vetting record was not found.', [], 404); } $this->applications->updateVetting($vettingId, [ 'meeting_status' => $meetingStatus, 'vetting_status' => $taskStatus, 'completed_at' => Carbon::now(), 'outcome_reason' => $input['reason'] ?? null, 'internal_notes' => $input['internal_notes'] ?? null, ]); if (! empty($vetting->review_task_id)) { $this->closeReviewTask($vetting->review_task_id, $taskStatus === 'PASSED' ? 'COMPLETED' : 'FAILED', $actorUserId, [ 'meeting_status' => $meetingStatus, 'reason' => $input['reason'] ?? null, ]); } $this->audit->record('APPLICATION_VETTING_'.$meetingStatus, $actorUserId, null, 'vetting_records', $vettingId, 'SUCCESS'); return $this->reviewApplicationDetail($applicationId, $actorUserId); } private function createReviewTask(string $applicationId, string $type, bool $blocking, ?string $actorUserId, mixed $dueAt = null, array $metadata = []): string { $id = $this->ids->generate(); DB::table('application_review_tasks')->insert([ 'id' => $id, 'application_id' => $applicationId, 'task_type' => strtoupper($type), 'status' => 'OPEN', 'blocking' => $blocking ? 1 : 0, 'opened_by_user_id' => $actorUserId, 'opened_at' => now(), 'due_at' => $dueAt, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(), ]); return $id; } private function closeReviewTask(string $taskId, string $status, ?string $actorUserId, array $metadata = []): void { $task = DB::table('application_review_tasks')->where('id', $taskId)->whereNull('deleted_at')->first(); if ($task === null) { return; } DB::table('application_review_tasks')->where('id', $taskId)->whereNull('deleted_at')->update([ 'status' => strtoupper($status), 'closed_by_user_id' => $actorUserId, 'closed_at' => now(), 'metadata' => json_encode(array_merge($this->decodeMetadata($task->metadata ?? null), $metadata), JSON_THROW_ON_ERROR), 'updated_at' => now(), ]); } private function closeLatestOpenTask(string $applicationId, string $type, string $status, ?string $actorUserId): void { $task = DB::table('application_review_tasks') ->where('application_id', $applicationId) ->where('task_type', strtoupper($type)) ->whereIn('status', ['OPEN', 'PENDING', 'FAILED']) ->whereNull('deleted_at') ->orderByDesc('created_at') ->first(); if ($task !== null) { $this->closeReviewTask($task->id, $status, $actorUserId); } } private function cancelOpenReviewTasks(string $applicationId, ?string $actorUserId, string $reason): void { foreach ($this->openBlockingTasks($applicationId) as $task) { $this->closeReviewTask($task->id, 'CANCELED', $actorUserId, ['reason' => $reason]); } } private function openBlockingTasks(string $applicationId): array { return DB::table('application_review_tasks') ->where('application_id', $applicationId) ->where('blocking', 1) ->whereIn('status', ['OPEN', 'PENDING', 'FAILED']) ->whereNull('deleted_at') ->orderBy('opened_at') ->get() ->all(); } private function reviewTasks(string $applicationId): array { return DB::table('application_review_tasks') ->where('application_id', $applicationId) ->whereNull('deleted_at') ->orderByDesc('opened_at') ->get() ->all(); } private function reviewBlockers(string $applicationId): array { $blockers = []; if (! $this->reviewCompleted($applicationId)) { $blockers[] = ['code' => 'REVIEW_NOT_COMPLETED', 'message' => 'Application review has not been completed.']; } foreach ($this->openBlockingTasks($applicationId) as $task) { $blockers[] = [ 'code' => 'OPEN_'.$task->task_type, 'message' => 'Blocking review task is not resolved.', 'task_id' => $task->id, 'task_type' => $task->task_type, 'status' => $task->status, ]; } $failedVetting = DB::table('vetting_records') ->where('application_id', $applicationId) ->whereIn('vetting_status', ['FAIL', 'FAILED']) ->whereNull('deleted_at') ->orderByDesc('created_at') ->first(); if ($failedVetting !== null) { $blockers[] = ['code' => 'VETTING_FAILED', 'message' => 'Requested vetting did not pass.', 'vetting_id' => $failedVetting->id]; } return $blockers; } private function transitionApplication(object $application, string $status, ?string $actorUserId, string $event, ?string $reason = null): void { if ($application->status === $status) { return; } $updates = ['status' => $status]; if ($status === 'APPROVED') { $updates['approved_at'] = now(); } elseif ($status === 'REJECTED') { $updates['rejected_at'] = now(); } $this->applications->update($application->id, $updates); DB::table('application_status_history')->insert([ 'id' => $this->ids->generate(), 'application_id' => $application->id, 'previous_status' => $application->status, 'new_status' => $status, 'changed_by_user_id' => $actorUserId, 'changed_at' => now(), 'workflow_event' => $event, 'change_reason' => $reason, 'metadata' => json_encode([], JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(), ]); } private function applicationFilesPayload(string $applicationId): array { return DB::table('file_links') ->join('stored_files', 'stored_files.id', '=', 'file_links.file_id') ->where('file_links.application_id', $applicationId) ->whereNull('file_links.deleted_at') ->whereNull('stored_files.deleted_at') ->select('stored_files.*', 'file_links.link_purpose', 'file_links.id as file_link_id') ->get() ->map(fn (object $file): array => $this->filePayload($file)) ->all(); } private function filePayload(object $file): array { $expiresAt = Carbon::now()->addMinutes(10); return [ 'file_id' => $file->id, 'file_link_id' => $file->file_link_id ?? null, 'document_type' => $file->link_purpose ?? null, 'original_filename' => $file->original_filename, 'mime_type' => $file->mime_type, 'file_size_bytes' => (int) $file->file_size_bytes, 'uploaded_at' => $file->created_at, 'download_url' => $this->applicantFileSignedUrl($file->id, $expiresAt), 'stream_url' => $this->applicantFileStreamUrl($file->id, $expiresAt), 'secure_link' => $this->applicantFileSignedUrl($file->id, $expiresAt), 'expires_at' => $expiresAt->toIso8601String(), ]; } private function documentRequestsPayload(string $applicationId): array { return DB::table('application_document_requests') ->where('application_id', $applicationId) ->whereNull('deleted_at') ->orderByDesc('requested_at') ->get() ->map(fn (object $request): array => $this->documentRequestPayload($request)) ->all(); } private function documentRequestPayload(object $request): array { return [ 'id' => $request->id, 'application_id' => $request->application_id, 'review_task_id' => $request->review_task_id, 'title' => $request->title, 'message' => $request->message, 'status' => $request->status, 'requested_at' => $request->requested_at, 'expires_at' => $request->expires_at, 'completed_at' => $request->completed_at, 'items' => DB::table('application_document_request_items') ->where('document_request_id', $request->id) ->whereNull('deleted_at') ->orderBy('created_at') ->get() ->all(), ]; } private function publicDocumentRequestPayload(object $request): array { $payload = $this->documentRequestPayload($request); unset($payload['application_id'], $payload['review_task_id']); return $payload; } private function requireDocumentRequestToken(string $token): object { $request = DB::table('application_document_requests') ->where('token_hash', hash('sha256', $token)) ->whereNull('deleted_at') ->first(); if ($request === null || ! in_array($request->status, ['OPEN', 'SUBMITTED'], true)) { throw new ApiException('DOCUMENT_REQUEST_NOT_FOUND', 'Document request token is invalid.', [], 404); } if ($request->expires_at !== null && Carbon::parse($request->expires_at)->isPast()) { throw new ApiException('DOCUMENT_REQUEST_EXPIRED', 'Document request token has expired.', [], 410); } return $request; } private function registerRequestedDocumentFile(object $request, array $input, object $item): array { $fileId = $this->ids->generate(); $uploadedFile = $input['file'] ?? null; $originalFilename = $uploadedFile instanceof UploadedFile ? $uploadedFile->getClientOriginalName() : ($input['original_filename'] ?? $item->document_type.'.pdf'); $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); $storageKey = 'application_documents/'.$request->application_id.'/'.$fileId.'/'.Str::slug(pathinfo($originalFilename, PATHINFO_FILENAME)).($extension ? '.'.$extension : ''); $mimeType = $uploadedFile instanceof UploadedFile ? ($uploadedFile->getMimeType() ?? 'application/octet-stream') : ($input['mime_type'] ?? 'application/octet-stream'); $fileSize = $uploadedFile instanceof UploadedFile ? ($uploadedFile->getSize() ?? 0) : (int) ($input['file_size_bytes'] ?? 1); if ($uploadedFile instanceof UploadedFile) { Storage::disk('local')->put($storageKey, $uploadedFile->getContent()); } DB::table('stored_files')->insert([ 'id' => $fileId, 'classification' => 'APPLICATION_DOCUMENT', 'owner_user_id' => null, 'storage_provider' => 'LOCAL', 'storage_bucket' => 'local', 'storage_key' => $storageKey, 'original_filename' => $originalFilename, 'mime_type' => strtolower($mimeType), 'file_size_bytes' => (int) $fileSize, 'checksum_sha256' => $input['checksum_sha256'] ?? null, 'virus_scan_status' => $input['virus_scan_status'] ?? 'PENDING', 'retention_until' => $input['retention_until'] ?? null, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('file_links')->insert([ 'id' => $this->ids->generate(), 'file_id' => $fileId, 'application_id' => $request->application_id, 'link_purpose' => $item->document_type, 'linked_by_user_id' => null, 'created_at' => now(), 'updated_at' => now(), ]); return ['file_id' => $fileId]; } private function createApprovalToken(object $application): string { $token = Str::random(48); $this->applications->update($application->id, ['approval_token_hash' => hash('sha256', $token)]); return $token; } private function approvalPaymentUrl(string $token): string { $base = rtrim((string) (config('app.approval_payment_form_url') ?: PlatformUrl::website('/payment') ?: config('payments.stripe.success_url') ?: config('app.url')), '/'); $separator = str_contains($base, '?') ? '&' : '?'; return $base.$separator.http_build_query([ 'payment_type' => 'membership_fee', 'approval_token' => $token, ]); } private function documentUploadUrl(string $token): string { $base = rtrim((string) (config('app.applicant_document_upload_url') ?: config('app.url')), '/'); $separator = str_contains($base, '?') ? '&' : '?'; return $base.$separator.'document_request_token='.$token; } private function feeTypePayload(string $code): ?array { $fee = DB::table('payment_fee_types')->where('code', $code)->whereNull('deleted_at')->first(); return $fee === null ? null : (array) $fee; } private function requireApprovedApplicationByToken(string $token): object { $application = DB::table('applications') ->where('approval_token_hash', hash('sha256', $token)) ->where('status', 'APPROVED') ->whereNull('deleted_at') ->first(); if ($application === null) { throw new ApiException('APPROVAL_NOT_FOUND', 'Approval token was not found or is no longer valid.', [], 404); } return $application; } private function latestAnnualMembershipPayment(string $applicationId): ?object { return DB::table('payments') ->join('invoices', 'invoices.id', '=', 'payments.invoice_id') ->where('invoices.application_id', $applicationId) ->where('invoices.fee_type_code', 'ANNUAL_MEMBERSHIP_FEE') ->whereNull('payments.deleted_at') ->whereNull('invoices.deleted_at') ->orderByRaw("FIELD(payments.status, 'PAID', 'WAIVED', 'PENDING', 'FAILED', 'REFUNDED') ASC") ->orderByDesc('payments.created_at') ->select( 'payments.*', 'payments.status as payment_status', 'invoices.id as invoice_id', 'invoices.status as invoice_status', 'invoices.amount_due', 'invoices.amount_paid', 'invoices.invoice_number', ) ->first(); } private function annualMembershipInvoice(string $applicationId): ?object { return DB::table('invoices') ->where('application_id', $applicationId) ->where('fee_type_code', 'ANNUAL_MEMBERSHIP_FEE') ->whereNull('deleted_at') ->orderByRaw("FIELD(status, 'PAID', 'WAIVED', 'ISSUED', 'DRAFT', 'CANCELED') ASC") ->orderByDesc('created_at') ->first(); } private function rowPayload(?object $row): ?array { return $row === null ? null : (array) $row; } private function validCalendlySignature(string $payload, string $signature, string $secret): bool { $provided = $signature; $timestamp = null; if (str_contains($signature, '=')) { $parts = []; foreach (explode(',', $signature) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, null); if ($key !== null && $value !== null) { $parts[$key] = $value; } } $provided = $parts['v1'] ?? $parts['signature'] ?? $signature; $timestamp = $parts['t'] ?? null; } $rawExpected = hash_hmac('sha256', $payload, $secret); $timestampedExpected = $timestamp === null ? null : hash_hmac('sha256', $timestamp.'.'.$payload, $secret); return hash_equals($rawExpected, $provided) || ($timestampedExpected !== null && hash_equals($timestampedExpected, $provided)); } private function manualCompleteIsp(string $validationId, string $status, string $reason, string $actorUserId): array { $validation = $this->applications->findIspValidation($validationId); if ($validation === null) { throw new ApiException('ISP_VALIDATION_NOT_FOUND', 'ISP validation was not found.', [], 404); } $application = $this->requireApplication($validation->application_id); $applicationStatus = $status === 'CONFIRMED' ? 'APPLICATION_COMPLETED' : 'APPLICATION_IN_PROGRESS'; $this->applications->updateIspValidation($validationId, [ 'status' => $status, 'validated_at' => Carbon::now(), 'manual_reviewed_by_user_id' => $actorUserId, 'manual_review_reason' => $reason, ]); $this->applications->update($application->id, [ 'status' => $applicationStatus, 'ready_for_verification_at' => $status === 'CONFIRMED' ? Carbon::now() : null, ]); $this->audit->record('ISP_VALIDATION_'.$status, $actorUserId, null, 'isp_validations', $validationId, 'SUCCESS'); return ['isp_validation' => (array) $this->applications->findIspValidation($validationId), 'application' => $this->payload($this->requireApplication($application->id))]; } private function refreshReadiness(object $application, string $actorUserId): object { if ($application->status === 'APPLICATION_IN_PROGRESS' && $this->applicationFeePaid($application->id)) { $this->applications->update($application->id, [ 'status' => 'APPLICATION_COMPLETED', 'ready_for_verification_at' => Carbon::now(), 'submitted_at' => DB::raw('COALESCE(submitted_at, CURRENT_TIMESTAMP)'), ]); $this->audit->record('APPLICATION_FEE_READINESS_CONFIRMED', $actorUserId, null, 'applications', $application->id, 'SUCCESS'); return $this->requireApplication($application->id); } return $application; } private function nextSubmittedStatus(object $application): string { return 'APPLICATION_COMPLETED'; } private function applicationFeePaid(string $applicationId): bool { return DB::table('payments') ->join('invoices', 'invoices.id', '=', 'payments.invoice_id') ->where('invoices.application_id', $applicationId) ->where('invoices.fee_type_code', 'APPLICATION_FEE') ->whereIn('payments.status', ['PAID', 'WAIVED']) ->whereNull('payments.deleted_at') ->exists(); } private function reviewCompleted(string $applicationId): bool { $review = $this->applications->latestReview($applicationId); return $review !== null && $review->review_status === 'COMPLETED'; } private function vettingPassed(string $applicationId): bool { $vetting = $this->applications->latestVetting($applicationId); return $vetting !== null && in_array($vetting->vetting_status, ['PASS', 'PASSED', 'APPROVED'], true); } private function ensureComplete(object $application): void { foreach (['first_name', 'last_name', 'email', 'phone_number', 'country_of_residence', 'professional_profile'] as $field) { if (trim((string) $application->{$field}) === '') { throw new ApiException('APPLICATION_INCOMPLETE', 'Application is missing required fields.', ['field' => $field], 422); } } } private function ensureApplicantComplete(object $application): void { $this->ensureComplete($application); $metadata = $this->applicationMetadataPayload($application); foreach (['nationality', 'professional_title', 'professional_category', 'employer_or_business', 'years_of_experience', 'selected_products', 'diaspora_member', 'isp_member', 'passport_file_id'] as $field) { if (! array_key_exists($field, $metadata) || $metadata[$field] === '' || $metadata[$field] === []) { throw new ApiException('APPLICATION_INCOMPLETE', 'Application is missing required fields.', ['field' => $field], 422); } } if (($metadata['diaspora_member'] ?? null) === true && empty($metadata['diaspora_proof_file_id'])) { throw new ApiException('APPLICATION_INCOMPLETE', 'Application is missing required fields.', ['field' => 'diaspora_proof_file_id'], 422); } } private function ensureRequiredConsents(string $applicationId): void { $accepted = $this->applications->consentsFor($applicationId); $missing = array_values(array_diff(self::REQUIRED_CONSENTS, $accepted)); if ($missing !== []) { throw new ApiException('APPLICATION_CONSENT_REQUIRED', 'Application requires terms and privacy consent before submission.', ['missing' => $missing], 422); } } private function authorizeAccess(object $application, string $actorUserId): void { if (! $this->policy->canAccess($application, $actorUserId)) { throw new ApiException('FORBIDDEN', 'You are not authorized to access this application.', [], 403); } } private function requireApplication(string $id): object { $application = $this->applications->find($id); if ($application === null) { throw new ApiException('APPLICATION_NOT_FOUND', 'Application was not found.', [], 404); } return $application; } private function requireApplicantApplication(string $id, string $emailVerificationId): object { $challenge = $this->emailVerification->requireVerifiedChallenge($emailVerificationId); $application = $this->requireApplication($id); if ($this->normalizeEmail($application->email) !== $this->normalizeEmail($challenge->email)) { throw new ApiException('FORBIDDEN', 'You are not authorized to access this application.', [], 403); } if (Schema::hasColumn('applications', 'email_verification_challenge_id') && $application->email_verification_challenge_id !== $challenge->id) { $this->refreshApplicantVerificationChallenge($application, $challenge->id); $application = $this->requireApplication($id); } return $application; } private function ensureApplicantEditable(object $application): void { if (! in_array($application->status, ['APPLICATION_IN_PROGRESS', 'INCOMPLETE'], true)) { throw new ApiException('APPLICATION_NOT_EDITABLE', 'Application cannot be edited in its current state.', [], 409); } } private function duplicateApplicationForEmail(string $email): ?object { $query = DB::table('applications')->whereNull('deleted_at')->orderByDesc('created_at'); if (Schema::hasColumn('applications', 'email_normalized')) { $query->where('email_normalized', $this->normalizeEmail($email)); } else { $query->where('email', $this->normalizeEmail($email)); } $applications = $query->get(); $incomplete = $applications->first(fn (object $application): bool => in_array($application->status, ['APPLICATION_IN_PROGRESS', 'INCOMPLETE'], true)); if ($incomplete !== null) { return $incomplete; } return $applications->first(fn (object $application): bool => $application->status !== 'REJECTED'); } private function publicApplicationSummary(object $application): array { return [ 'id' => $application->id, 'application_reference' => $application->application_reference ?? null, 'status' => $application->status, 'submitted_at' => $application->submitted_at ?? null, ]; } private function rejectMismatchedEmail(array $input, string $verifiedEmail): void { if (array_key_exists('email', $input) && $this->normalizeEmail((string) $input['email']) !== $verifiedEmail) { throw new ApiException('VERIFIED_EMAIL_MISMATCH', 'Application email must match the verified email address.', [], 422); } } private function applicantMetadata(array $input): array { $metadata = []; $map = [ 'nationality', 'professional_title', 'professional_category', 'employer_or_business', 'years_of_experience', 'passport_file_id', 'diaspora_proof_file_id', 'diaspora_proof_type', 'isp_member_number', 'isp_program_email', 'isp_verification_result', 'test_run_id', ]; foreach ($map as $field) { if (array_key_exists($field, $input)) { $metadata[$field] = $input[$field]; } } if (array_key_exists('products', $input)) { $metadata['selected_products'] = $this->normalizeProductCodes((array) $input['products']); } if (array_key_exists('selected_products', $input)) { $metadata['selected_products'] = $this->normalizeProductCodes((array) $input['selected_products']); } foreach (['diaspora_member', 'isp_member'] as $field) { if (array_key_exists($field, $input)) { $metadata[$field] = filter_var($input[$field], FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? (bool) $input[$field]; } } return $metadata; } private function structuredApplicantColumns(array $input): array { $updates = []; foreach (['professional_title', 'professional_category', 'employer_or_business', 'years_of_experience'] as $field) { if (array_key_exists($field, $input) && Schema::hasColumn('applications', $field)) { $updates[$field] = $input[$field]; } } if (Schema::hasColumn('applications', 'selected_product_codes')) { if (array_key_exists('selected_products', $input)) { $updates['selected_product_codes'] = json_encode($this->normalizeProductCodes((array) $input['selected_products']), JSON_THROW_ON_ERROR); } elseif (array_key_exists('products', $input)) { $updates['selected_product_codes'] = json_encode($this->normalizeProductCodes((array) $input['products']), JSON_THROW_ON_ERROR); } } if (array_key_exists('isp_program_email', $input) && Schema::hasColumn('applications', 'ISP_email')) { $updates['ISP_email'] = $input['isp_program_email']; } if (array_key_exists('isp_member_number', $input) && Schema::hasColumn('applications', 'ISP_no')) { $updates['ISP_no'] = $input['isp_member_number']; } return $updates; } private function refreshApplicantVerificationChallenge(object $application, string $challengeId): void { if (! Schema::hasColumn('applications', 'email_verification_challenge_id')) { return; } if (($application->email_verification_challenge_id ?? null) === $challengeId) { return; } $this->applications->update($application->id, [ 'email_verification_challenge_id' => $challengeId, ]); } private function applicantFeeRequirementSatisfied(object $application): bool { $metadata = $this->applicationMetadataPayload($application); return $this->applicantIspVerified($metadata) || $this->applicationFeePaid($application->id); } private function applicantIspVerified(array $metadata): bool { $isIspMember = filter_var($metadata['isp_member'] ?? false, FILTER_VALIDATE_BOOL); if (! $isIspMember) { return false; } $result = $metadata['isp_verification_result'] ?? null; if (is_string($result)) { $decoded = json_decode($result, true); $result = is_array($decoded) ? $decoded : null; } return is_array($result) && ( (($result['verified'] ?? false) === true) || ( ($result['data']['is_member'] ?? false) === true && ($result['data']['is_full_member'] ?? false) === true && ($result['data']['status_check']['track_application_status'] ?? '') === 'FULL_MEMBER' ) ); } private function latestApplicationFeePayment(string $applicationId): ?object { return DB::table('payments') ->join('invoices', 'invoices.id', '=', 'payments.invoice_id') ->where('invoices.application_id', $applicationId) ->where('invoices.fee_type_code', 'APPLICATION_FEE') ->whereNull('payments.deleted_at') ->whereNull('invoices.deleted_at') ->orderByRaw("FIELD(payments.status, 'PAID', 'WAIVED', 'PENDING', 'FAILED', 'REFUNDED') ASC") ->orderByDesc('payments.created_at') ->select( 'payments.*', 'payments.status as payment_status', 'invoices.id as invoice_id', 'invoices.status as invoice_status', 'invoices.amount_due', 'invoices.amount_paid', 'invoices.invoice_number', ) ->first(); } private function applicationFeeInvoice(string $applicationId): object { $invoice = DB::table('invoices') ->where('application_id', $applicationId) ->where('fee_type_code', 'APPLICATION_FEE') ->whereNull('deleted_at') ->orderByDesc('created_at') ->first(); if ($invoice !== null) { return $invoice; } $feeType = DB::table('payment_fee_types') ->where('code', 'APPLICATION_FEE') ->whereNull('deleted_at') ->first(); if ($feeType === null) { throw new ApiException('FEE_TYPE_NOT_FOUND', 'Application fee type was not found.', [], 404); } $invoiceId = $this->ids->generate(); $amount = (float) ($feeType->default_amount ?? 20); $currency = $feeType->default_currency ?? 'USD'; DB::table('invoices')->insert([ 'id' => $invoiceId, 'invoice_number' => 'INV-'.Carbon::now()->format('YmdHis').'-'.substr(str_replace('-', '', $invoiceId), 0, 8), 'member_id' => null, 'application_id' => $applicationId, 'product_request_id' => null, 'fee_type_code' => 'APPLICATION_FEE', 'status' => 'ISSUED', 'amount_due' => $amount, 'amount_paid' => 0, 'currency' => $currency, 'issued_at' => now(), 'due_at' => Carbon::now()->addDays(14), 'created_at' => now(), 'updated_at' => now(), ]); DB::table('invoice_line_items')->insert([ 'id' => $this->ids->generate(), 'invoice_id' => $invoiceId, 'fee_type_code' => 'APPLICATION_FEE', 'description' => $feeType->name ?? 'Application Fee', 'quantity' => 1, 'unit_amount' => $amount, 'currency' => $currency, 'created_at' => now(), 'updated_at' => now(), ]); return DB::table('invoices')->where('id', $invoiceId)->first(); } private function applicationFeeSummary(string $applicationId, array $metadata = []): array { $exempt = $this->applicantIspVerified($metadata); $payment = $this->latestApplicationFeePayment($applicationId); $paid = $exempt || ($payment !== null && in_array($payment->payment_status, ['PAID', 'WAIVED'], true)); return [ 'required' => ! $exempt, 'paid' => $paid, 'status' => $exempt ? 'EXEMPT' : ($payment->payment_status ?? 'UNPAID'), 'invoice_id' => $payment->invoice_id ?? null, 'invoice_number' => $payment->invoice_number ?? null, 'payment_id' => $payment->id ?? null, 'payment_reference' => $payment->payment_reference ?? null, 'amount' => $payment->amount ?? $payment->amount_due ?? null, 'currency' => $payment->currency ?? null, ]; } private function paymentPayloadFromJoinedRow(object $payment): array { return [ 'id' => $payment->id, 'payment_reference' => $payment->payment_reference, 'invoice_id' => $payment->invoice_id, 'fee_type_code' => $payment->fee_type_code, 'status' => $payment->payment_status ?? $payment->status, 'amount' => $payment->amount, 'currency' => $payment->currency, 'gateway_reference' => $payment->gateway_reference ?? null, 'initiated_at' => $payment->initiated_at ?? null, ]; } private function recordApplicantDocumentMetadata(object $application, string $fileId, ?string $documentType): void { $documentType = strtolower(trim((string) $documentType)); $metadataKey = match ($documentType) { 'passport', 'passport_document' => 'passport_file_id', 'diaspora_proof', 'diaspora_evidence', 'diaspora_evidence_document' => 'diaspora_proof_file_id', default => null, }; if ($metadataKey === null) { return; } $metadata = $this->decodeMetadata($application->metadata ?? null); $metadata[$metadataKey] = $fileId; $this->applications->update($application->id, [ 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), ]); } private function normalizeProductCodes(array $products): array { $map = [ 'care' => 'CARE', 'investment' => 'INVESTMENT', 'invest' => 'INVESTMENT', 'real-estate' => 'REAL_ESTATE', 'real_estate' => 'REAL_ESTATE', 'homes' => 'REAL_ESTATE', 'connect' => 'CONNECT', 'concierge' => 'CONCIERGE', 'marketplace' => 'MARKETPLACE', 'essentials' => 'ESSENTIALS', ]; return array_values(array_unique(array_filter(array_map( function ($product) use ($map): string { $key = strtolower(trim((string) $product)); return $map[$key] ?? strtoupper(str_replace('-', '_', $key)); }, $products, )))); } private function decodeMetadata(?string $metadata): array { if ($metadata === null || trim($metadata) === '') { return []; } $decoded = json_decode($metadata, true); return is_array($decoded) ? $decoded : []; } private function applicationMetadataPayload(object $application): array { $metadata = $this->decodeMetadata($application->metadata ?? null); foreach (['professional_title', 'professional_category', 'employer_or_business', 'years_of_experience'] as $field) { if (Schema::hasColumn('applications', $field) && ! empty($application->{$field})) { $metadata[$field] = $application->{$field}; } } $selected = $this->selectedProductCodes($application, $metadata); if ($selected !== []) { $metadata['selected_products'] = $selected; } return $metadata; } private function selectedProductCodes(object $application, array $metadata): array { if (Schema::hasColumn('applications', 'selected_product_codes') && ! empty($application->selected_product_codes)) { $decoded = json_decode((string) $application->selected_product_codes, true); if (is_array($decoded)) { return $this->normalizeProductCodes($decoded); } } return $this->normalizeProductCodes((array) ($metadata['selected_products'] ?? $metadata['products'] ?? [])); } private function applicantDocumentsPayload(object $application, array $metadata): array { $documents = [ 'passport' => null, 'diaspora_proof' => null, ]; $map = [ 'passport' => $metadata['passport_file_id'] ?? null, 'diaspora_proof' => $metadata['diaspora_proof_file_id'] ?? null, ]; foreach ($map as $type => $fileId) { if (empty($fileId)) { continue; } $file = DB::table('stored_files') ->where('id', $fileId) ->whereNull('deleted_at') ->first(); if ($file === null) { continue; } $documents[$type] = [ 'file_id' => $file->id, 'document_type' => $type, 'original_filename' => $file->original_filename, 'mime_type' => $file->mime_type, 'file_size_bytes' => (int) $file->file_size_bytes, 'uploaded_at' => $file->created_at, 'download_url' => $this->applicantFileSignedUrl($file->id), 'stream_url' => $this->applicantFileStreamUrl($file->id), 'secure_link' => $this->applicantFileSignedUrl($file->id), 'expires_at' => Carbon::now()->addMinutes(10)->toIso8601String(), ]; } return $documents; } private function applicantFileSignedUrl(string $fileId, ?Carbon $expiresAt = null): string { return $this->fileUrls->temporaryFileDownloadUrl('applicant.files.download', $fileId, $expiresAt)['url']; } private function applicantFileStreamUrl(string $fileId, ?Carbon $expiresAt = null): string { return $this->fileUrls->temporaryFileStreamUrl('applicant.files.stream', $fileId, $expiresAt)['url']; } private function calendlyBookingUrl(string $eventType, ?string $override = null): ?string { if ($override !== null && trim($override) !== '') { return $override; } $eventType = strtolower($eventType); return config("app.calendly_booking_urls.{$eventType}") ?: config('app.calendly_booking_url'); } private function calendlyTrackedBookingUrl(string $bookingUrl, string $applicationId, string $vettingId): string { $separator = str_contains($bookingUrl, '?') ? '&' : '?'; return $bookingUrl.$separator.http_build_query([ 'utm_source' => 'mdn_backend', 'utm_medium' => 'email', 'utm_campaign' => 'application_vetting', 'utm_content' => $applicationId, 'utm_term' => $vettingId, ], '', '&', PHP_QUERY_RFC3986); } private function calendlyTrackingValue(array $payloadNode, string $key): ?string { $value = $payloadNode['tracking'][$key] ?? $payloadNode['scheduled_event']['tracking'][$key] ?? null; if ($value === null || trim((string) $value) === '') { return null; } return trim((string) $value); } private function calendlyDateTimeForDatabase(mixed $value): ?string { if ($value === null || trim((string) $value) === '') { return null; } try { return Carbon::parse((string) $value)->utc()->toDateTimeString(); } catch (\Throwable) { return null; } } private function professionalProfileFromInput(array $input): string { if (array_key_exists('professional_profile', $input)) { return (string) $input['professional_profile']; } $parts = array_filter([ $input['professional_title'] ?? null, $input['professional_category'] ?? null, $input['employer_or_business'] ?? null, array_key_exists('years_of_experience', $input) ? ((string) $input['years_of_experience']).' years experience' : null, ], fn ($part): bool => trim((string) $part) !== ''); return implode(' | ', $parts); } private function hasProfessionalInput(array $input): bool { return array_intersect(array_keys($input), ['professional_profile', 'professional_title', 'professional_category', 'employer_or_business', 'years_of_experience']) !== []; } private function pathwayFromInput(array $input): string { if (array_key_exists('pathway', $input)) { return strtoupper((string) $input['pathway']); } if (array_key_exists('isp_member', $input) && (filter_var($input['isp_member'], FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? (bool) $input['isp_member'])) { return 'ISP'; } return 'STANDARD'; } private function firstNameFromFullName(string $fullName): string { $parts = preg_split('/\s+/', trim($fullName)) ?: []; return $parts[0] ?? ''; } private function lastNameFromFullName(string $fullName): string { $parts = preg_split('/\s+/', trim($fullName)) ?: []; if (count($parts) <= 1) { return $parts[0] ?? ''; } return implode(' ', array_slice($parts, 1)); } private function normalizeEmail(string $email): string { return strtolower(trim($email)); } private function sendApplicationMessage(object $application, string $eventType, string $body): void { [$subject, $messageBody] = $this->applicationEmailCopy($eventType, $body); $this->communications->send([ 'channel' => 'EMAIL', 'event_type' => $eventType, 'recipient' => ['user_id' => $application->user_id, 'email' => $application->email], 'subject' => $subject, 'body' => $messageBody, 'context' => ['application_id' => $application->id], ], $application->user_id); } private function applicationEmailCopy(string $eventType, string $body): array { return match ($eventType) { 'APPLICATION_SUBMITTED' => [ 'Application received', "Thank you for submitting your My Diaspora Nexus application.\n\nOur team will review your details and contact you if we need anything else. You do not need to submit a new application while this review is in progress.", ], 'APPLICATION_CLARIFICATION_REQUESTED' => [ 'We need a little more information', "Thank you for your interest in My Diaspora Nexus. Before we can continue reviewing your application, we need the following information:\n\n{$body}\n\nPlease reply or follow the instructions provided by our team so we can continue the review.", ], 'APPLICATION_REJECTED' => [ 'Update on your My Diaspora Nexus application', "Thank you for taking the time to apply to My Diaspora Nexus. After reviewing your submission, we are unable to approve the application at this time.\n\nReason: {$body}\n\nIf you believe any information was missed or has changed, please contact our team for guidance.", ], 'APPLICATION_APPROVED' => [ 'Your My Diaspora Nexus application has been approved', "Congratulations, your application has been approved.\n\nThe next step is to complete your annual membership payment using this secure link:\n{$body}\n\nOnce payment and the remaining onboarding steps are complete, our team will continue setting up your membership.", ], 'APPLICATION_VETTING_REQUESTED' => [ 'Please book your application vetting meeting', "As part of your application review, we would like to meet with you virtually.\n\nPlease use the secure booking link below to select a convenient time:\n{$body}\n\nIf none of the available times work for you, contact our team and we will help you coordinate an alternative.", ], 'APPLICATION_DOCUMENTS_REQUESTED' => [ 'Please submit additional application documents', "We need a few additional documents to continue reviewing your application.\n\nPlease use the secure upload link below to submit the requested document(s):\n{$body}\n\nThe link is intended only for your application, so please do not forward it.", ], default => [ Str::headline(strtolower($eventType)), $body, ], }; } private function payload(object $application): array { $payload = (array) $application; $metadata = $this->applicationMetadataPayload($application); unset($payload['metadata']); $payload['metadata'] = $metadata; $payload['full_name'] = trim((string) ($application->first_name ?? '').' '.(string) ($application->last_name ?? '')); $payload['professional_title'] = $metadata['professional_title'] ?? null; $payload['professional_category'] = $metadata['professional_category'] ?? null; $payload['employer_or_business'] = $metadata['employer_or_business'] ?? null; $payload['years_of_experience'] = $metadata['years_of_experience'] ?? null; $payload['selected_product_codes'] = $this->selectedProductCodes($application, $metadata); $payload['documents'] = $this->applicantDocumentsPayload($application, $metadata); $payload['application_fee'] = $this->applicationFeeSummary($application->id, $metadata); return $payload; } }