feat(messaging): suite-channel patient email/SMS and entitlement sync
Deploy Ladill Care / deploy (push) Successful in 40s
Deploy Ladill Care / deploy (push) Successful in 40s
Prefer platform suite messaging for patient email/SMS, fall back to product keys; write suite entitlements on Pro wallet, prepaid, and renewal.
This commit is contained in:
+5
-3
@@ -50,12 +50,14 @@ SMS_API_KEY_CARE=
|
||||
SMS_CUSTOMER_RELAY_URL=https://ladill.com/api/sms
|
||||
SMS_DEFAULT_SENDER_ID=Ladill
|
||||
|
||||
# Ladill Bird / SMTP (patient email uses customer keys via Integrations)
|
||||
SMTP_PLATFORM_API_URL=https://bird.ladill.com/api/smtp
|
||||
# Platform suite messaging (zero-config — channel=suite; no Bird/SMS product keys required)
|
||||
# Monolith: MESSAGING_SUITE_PLATFORM_CHANNEL=true, SMTP_API_KEY_CARE + SMS_API_KEY_CARE
|
||||
SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp
|
||||
SMTP_API_KEY_CARE=
|
||||
SMTP_CUSTOMER_RELAY_URL=https://bird.ladill.com/api/smtp
|
||||
SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp
|
||||
CARE_SMTP_FROM=care@ladill.com
|
||||
CARE_SMTP_FROM_NAME="Ladill Care"
|
||||
MESSAGING_SETTINGS_URL=https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite
|
||||
|
||||
# Afia in-app assistant (uses platform relay when AFIA_API_KEY is empty)
|
||||
AFIA_ENABLED=true
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Care;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Models\Patient;
|
||||
use App\Services\Billing\PlatformEmailClient;
|
||||
use App\Services\Billing\PlatformSmsClient;
|
||||
use App\Services\Care\AuditLogger;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use App\Services\Messaging\CustomerEmailClient;
|
||||
@@ -23,6 +25,7 @@ class PatientMessageController extends Controller
|
||||
Patient $patient,
|
||||
MessagingCredentialsService $credentials,
|
||||
CustomerEmailClient $email,
|
||||
PlatformEmailClient $platformEmail,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'patients.manage');
|
||||
$this->authorizePatient($request, $patient);
|
||||
@@ -31,16 +34,6 @@ class PatientMessageController extends Controller
|
||||
return back()->with('error', 'This patient has no valid email on file.');
|
||||
}
|
||||
|
||||
$credential = $credentials->forOrganization($this->organization($request));
|
||||
if (! $credential->hasValidBird()) {
|
||||
return back()->with('error', 'Connect Ladill Bird in Integrations before sending patient email.');
|
||||
}
|
||||
|
||||
$apiKey = $credential->birdApiKey();
|
||||
if (! $apiKey) {
|
||||
return back()->with('error', 'Bird credentials could not be decrypted. Reconnect Bird in Integrations.');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'subject' => ['required', 'string', 'max:200'],
|
||||
'body' => ['required', 'string', 'max:5000'],
|
||||
@@ -49,6 +42,22 @@ class PatientMessageController extends Controller
|
||||
$owner = $this->ownerRef($request);
|
||||
$organization = $this->organization($request);
|
||||
$html = OrganizationBranding::wrapEmailHtml(nl2br(e($data['body'])), $organization);
|
||||
|
||||
$sent = false;
|
||||
$error = null;
|
||||
$via = null;
|
||||
|
||||
if ($platformEmail->isConfigured()) {
|
||||
$sent = $platformEmail->send($owner, (string) $patient->email, $data['subject'], $html, $data['body']);
|
||||
$via = 'suite';
|
||||
if (! $sent) {
|
||||
$error = $platformEmail->lastError();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $sent) {
|
||||
$credential = $credentials->forOrganization($organization);
|
||||
if ($credential->hasValidBird() && ($apiKey = $credential->birdApiKey())) {
|
||||
$sent = $email->send(
|
||||
$apiKey,
|
||||
(string) $credential->bird_from_email,
|
||||
@@ -58,6 +67,14 @@ class PatientMessageController extends Controller
|
||||
$html,
|
||||
$data['body'],
|
||||
);
|
||||
$via = 'bird';
|
||||
if (! $sent) {
|
||||
$error = $email->lastError() ?: $error;
|
||||
}
|
||||
} elseif (! $platformEmail->isConfigured()) {
|
||||
$error = 'Set a Ladill mailbox in Account → Messaging, or connect Ladill Bird in Integrations.';
|
||||
}
|
||||
}
|
||||
|
||||
AuditLogger::record(
|
||||
$owner,
|
||||
@@ -69,12 +86,13 @@ class PatientMessageController extends Controller
|
||||
[
|
||||
'to' => $patient->email,
|
||||
'subject' => $data['subject'],
|
||||
'error' => $sent ? null : $email->lastError(),
|
||||
'via' => $via,
|
||||
'error' => $sent ? null : $error,
|
||||
],
|
||||
);
|
||||
|
||||
if (! $sent) {
|
||||
return back()->with('error', $email->lastError() ?: 'Email could not be sent.');
|
||||
return back()->with('error', $error ?: 'Email could not be sent.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Email sent to '.$patient->email);
|
||||
@@ -85,6 +103,7 @@ class PatientMessageController extends Controller
|
||||
Patient $patient,
|
||||
MessagingCredentialsService $credentials,
|
||||
CustomerSmsClient $sms,
|
||||
PlatformSmsClient $platformSms,
|
||||
): RedirectResponse {
|
||||
$this->authorizeAbility($request, 'patients.manage');
|
||||
$this->authorizePatient($request, $patient);
|
||||
@@ -93,22 +112,34 @@ class PatientMessageController extends Controller
|
||||
return back()->with('error', 'This patient has no phone number on file.');
|
||||
}
|
||||
|
||||
$credential = $credentials->forOrganization($this->organization($request));
|
||||
if (! $credential->hasValidSms()) {
|
||||
return back()->with('error', 'Connect Ladill SMS in Integrations before sending patient SMS.');
|
||||
}
|
||||
|
||||
$apiKey = $credential->smsApiKey();
|
||||
if (! $apiKey) {
|
||||
return back()->with('error', 'SMS credentials could not be decrypted. Reconnect SMS in Integrations.');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'message' => ['required', 'string', 'max:480'],
|
||||
]);
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$organization = $this->organization($request);
|
||||
$credential = $credentials->forOrganization($organization);
|
||||
|
||||
$sent = false;
|
||||
$error = null;
|
||||
|
||||
if ($platformSms->isConfigured()) {
|
||||
$sender = $credential->sms_sender_id ?: null;
|
||||
$sent = $platformSms->send($owner, (string) $patient->phone, $data['message'], $sender ? (string) $sender : null);
|
||||
if (! $sent) {
|
||||
$error = $platformSms->lastError();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $sent && $credential->hasValidSms() && ($apiKey = $credential->smsApiKey())) {
|
||||
$sent = $sms->send($apiKey, (string) $patient->phone, $data['message'], (string) $credential->sms_sender_id);
|
||||
if (! $sent) {
|
||||
$error = $sms->lastError() ?? $error;
|
||||
}
|
||||
} elseif (! $sent && ! $platformSms->isConfigured()) {
|
||||
$error = 'Connect Ladill SMS in Integrations, or ensure platform SMS is configured (SMS_API_KEY_CARE).';
|
||||
}
|
||||
|
||||
|
||||
AuditLogger::record(
|
||||
$owner,
|
||||
@@ -119,12 +150,12 @@ class PatientMessageController extends Controller
|
||||
$patient->id,
|
||||
[
|
||||
'to' => $patient->phone,
|
||||
'error' => $sent ? null : $sms->lastError(),
|
||||
'error' => $sent ? null : $error,
|
||||
],
|
||||
);
|
||||
|
||||
if (! $sent) {
|
||||
return back()->with('error', $sms->lastError() ?: 'SMS could not be sent.');
|
||||
return back()->with('error', $error ?: 'SMS could not be sent.');
|
||||
}
|
||||
|
||||
return back()->with('success', 'SMS sent to '.$patient->phone);
|
||||
|
||||
@@ -258,12 +258,24 @@ class ProController extends Controller
|
||||
$settings['auto_renew'] = true;
|
||||
$settings['billing_method'] = 'wallet_monthly';
|
||||
$settings['billed_branches'] = $billedBranches;
|
||||
$settings['plan_expires_at'] = now()
|
||||
->addDays((int) config('care.pro.period_days', 30))
|
||||
->toIso8601String();
|
||||
$expires = now()->addDays((int) config('care.pro.period_days', 30));
|
||||
$settings['plan_expires_at'] = $expires->toIso8601String();
|
||||
unset($settings['plan_renewal_error'], $settings['enterprise_billed_branches']);
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
$billing->syncSuiteEntitlement(
|
||||
$organization->owner_ref,
|
||||
$plan === 'enterprise' ? 'enterprise' : 'pro',
|
||||
'active',
|
||||
$reference,
|
||||
[
|
||||
'period_starts_at' => now()->toIso8601String(),
|
||||
'period_ends_at' => $expires->toIso8601String(),
|
||||
'source' => 'wallet_debit',
|
||||
'metadata' => ['billed_branches' => $billedBranches],
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('care.pro.index')->with('success', $successMessage);
|
||||
}
|
||||
|
||||
@@ -278,8 +290,23 @@ class ProController extends Controller
|
||||
$settings['auto_renew'] = false;
|
||||
$settings['billing_method'] = 'paystack_prepaid';
|
||||
$settings['billed_branches'] = $billedBranches;
|
||||
$settings['plan_expires_at'] = now()->addMonths($months)->toIso8601String();
|
||||
$expires = now()->addMonths($months);
|
||||
$settings['plan_expires_at'] = $expires->toIso8601String();
|
||||
unset($settings['plan_renewal_error'], $settings['enterprise_billed_branches']);
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
// Prepaid already writes entitlements on platform checkout; sync as repair.
|
||||
app(BillingClient::class)->syncSuiteEntitlement(
|
||||
$organization->owner_ref,
|
||||
$plan === 'enterprise' ? 'enterprise' : 'pro',
|
||||
'active',
|
||||
'care-prepaid-sync-'.$organization->id.'-'.now()->format('YmdHis'),
|
||||
[
|
||||
'period_starts_at' => now()->toIso8601String(),
|
||||
'period_ends_at' => $expires->toIso8601String(),
|
||||
'source' => 'checkout',
|
||||
'metadata' => ['billed_branches' => $billedBranches],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,4 +105,48 @@ class BillingClient
|
||||
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write suite messaging plan entitlement (SoT for included email/SMS allowances).
|
||||
*
|
||||
* @param array{period_starts_at?: string, period_ends_at?: string|null, source?: string, metadata?: array} $options
|
||||
*/
|
||||
public function syncSuiteEntitlement(
|
||||
string $publicId,
|
||||
string $plan,
|
||||
string $status,
|
||||
string $reference,
|
||||
array $options = [],
|
||||
): bool {
|
||||
if ($this->base() === '' || $this->token() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$app = (string) config('billing.service', 'care');
|
||||
$starts = $options['period_starts_at'] ?? now()->toIso8601String();
|
||||
$ends = array_key_exists('period_ends_at', $options)
|
||||
? $options['period_ends_at']
|
||||
: now()->addDays(30)->toIso8601String();
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())
|
||||
->acceptJson()
|
||||
->timeout(15)
|
||||
->post($this->base().'/suite-entitlements', array_filter([
|
||||
'user' => $publicId,
|
||||
'app' => $app,
|
||||
'plan' => $plan,
|
||||
'status' => $status,
|
||||
'period_starts_at' => $starts,
|
||||
'period_ends_at' => $ends,
|
||||
'source' => $options['source'] ?? 'wallet_debit',
|
||||
'reference' => $reference,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
], static fn ($v) => $v !== null));
|
||||
|
||||
return ! $res->failed();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,27 @@ namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Platform suite email (zero-config): channel=suite, no Bird customer API key.
|
||||
*/
|
||||
class PlatformEmailClient
|
||||
{
|
||||
private ?string $lastError = null;
|
||||
|
||||
private ?string $lastErrorCode = null;
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
public function lastErrorCode(): ?string
|
||||
{
|
||||
return $this->lastErrorCode;
|
||||
}
|
||||
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('smtp.platform_api_url', 'https://ladill.com/api/smtp'), '/');
|
||||
@@ -29,9 +40,25 @@ class PlatformEmailClient
|
||||
return $this->token() !== '';
|
||||
}
|
||||
|
||||
public function send(string $ownerPublicId, string $to, string $subject, string $html, ?string $text = null): bool
|
||||
public function messagingSettingsUrl(): string
|
||||
{
|
||||
return (string) config(
|
||||
'smtp.messaging_settings_url',
|
||||
'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite',
|
||||
);
|
||||
}
|
||||
|
||||
public function send(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $html,
|
||||
?string $text = null,
|
||||
?string $fromName = null,
|
||||
?string $idempotencyKey = null,
|
||||
): bool {
|
||||
$this->lastError = null;
|
||||
$this->lastErrorCode = null;
|
||||
|
||||
if (! $this->isConfigured()) {
|
||||
$this->lastError = 'Outbound email is not configured on this Care instance. Set SMTP_API_KEY_CARE.';
|
||||
@@ -39,28 +66,41 @@ class PlatformEmailClient
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = $idempotencyKey ?: ('care-email-'.Str::uuid());
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(30)
|
||||
$res = Http::withToken($this->token())
|
||||
->withHeaders(['Idempotency-Key' => $key])
|
||||
->acceptJson()
|
||||
->timeout(30)
|
||||
->post($this->base().'/messages/send', array_filter([
|
||||
'user' => $ownerPublicId,
|
||||
'from' => config('smtp.from'),
|
||||
'from_name' => config('smtp.from_name'),
|
||||
'channel' => 'suite',
|
||||
'from_name' => $fromName ?? config('smtp.from_name'),
|
||||
'to' => $to,
|
||||
'subject' => $subject,
|
||||
'html' => $html,
|
||||
'text' => $text,
|
||||
], fn ($value) => $value !== null && $value !== ''));
|
||||
'reference' => $key,
|
||||
], static fn ($v) => $v !== null && $v !== ''));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
$this->lastError = (string) ($res->json('error') ?: 'Insufficient Bird email credits. Top up Bird and try again.');
|
||||
Log::warning('Care platform email send: insufficient balance', ['user' => $ownerPublicId]);
|
||||
$this->lastErrorCode = (string) ($res->json('error') ?: 'allowance_exceeded');
|
||||
$this->lastError = (string) ($res->json('error') ?: 'Email allowance exceeded. Upgrade Care Pro or top up your wallet.');
|
||||
Log::warning('Care suite email: allowance/balance', ['user' => $ownerPublicId]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
$code = (string) ($res->json('error') ?: 'send_failed');
|
||||
$this->lastErrorCode = $code;
|
||||
if ($code === 'messaging_mailbox_required') {
|
||||
$this->lastError = 'Set a Ladill mailbox in Account → Messaging: '.$this->messagingSettingsUrl();
|
||||
} else {
|
||||
$this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The email could not be sent.');
|
||||
Log::warning('Care platform email send failed', ['status' => $res->status(), 'body' => $res->body()]);
|
||||
}
|
||||
Log::warning('Care suite email failed', ['status' => $res->status(), 'body' => $res->body()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -68,7 +108,7 @@ class PlatformEmailClient
|
||||
return (bool) ($res->json('success') ?? true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->lastError = 'Could not reach the email service. Please try again.';
|
||||
Log::warning('Care platform email send error', ['error' => $e->getMessage()]);
|
||||
Log::warning('Care suite email error', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Platform suite SMS (zero-config): channel=suite, no customer SMS API key.
|
||||
*/
|
||||
class PlatformSmsClient
|
||||
{
|
||||
private ?string $lastError = null;
|
||||
@@ -29,65 +33,13 @@ class PlatformSmsClient
|
||||
return $this->token() !== '';
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function services(string $ownerPublicId): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)
|
||||
->get($this->base().'/sms/services', ['user' => $ownerPublicId]);
|
||||
|
||||
if ($res->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (array) ($res->json('data') ?? []);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Care platform SMS services lookup failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function ensureServiceId(string $ownerPublicId): ?int
|
||||
{
|
||||
$services = $this->services($ownerPublicId);
|
||||
if ($services !== []) {
|
||||
return (int) ($services[0]['id'] ?? 0) ?: null;
|
||||
}
|
||||
|
||||
if (! $this->isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)
|
||||
->post($this->base().'/sms/services', [
|
||||
'user' => $ownerPublicId,
|
||||
'name' => 'Ladill Care',
|
||||
'brand_name' => 'Care',
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
$this->lastError = (string) ($res->json('error') ?: 'Could not create an SMS service for this account.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) ($res->json('data.id') ?? 0) ?: null;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Care platform SMS service create failed', ['error' => $e->getMessage()]);
|
||||
$this->lastError = 'Could not reach the SMS service.';
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function send(string $ownerPublicId, string $to, string $message, ?string $senderId = null): bool
|
||||
{
|
||||
public function send(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $message,
|
||||
?string $senderId = null,
|
||||
?string $idempotencyKey = null,
|
||||
): bool {
|
||||
$this->lastError = null;
|
||||
|
||||
if (! $this->isConfigured()) {
|
||||
@@ -96,33 +48,32 @@ class PlatformSmsClient
|
||||
return false;
|
||||
}
|
||||
|
||||
$serviceId = $this->ensureServiceId($ownerPublicId);
|
||||
if (! $serviceId) {
|
||||
$this->lastError ??= 'No SMS service is available for this account. Open sms.ladill.com once, then retry.';
|
||||
|
||||
return false;
|
||||
}
|
||||
$key = $idempotencyKey ?: ('care-sms-'.Str::uuid());
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(30)
|
||||
$res = Http::withToken($this->token())
|
||||
->withHeaders(['Idempotency-Key' => $key])
|
||||
->acceptJson()
|
||||
->timeout(30)
|
||||
->post($this->base().'/sms/messages/send', array_filter([
|
||||
'user' => $ownerPublicId,
|
||||
'sms_service_id' => $serviceId,
|
||||
'channel' => 'suite',
|
||||
'to' => $to,
|
||||
'text' => $message,
|
||||
'sender_id' => $senderId ?? config('sms.default_sender_id'),
|
||||
]));
|
||||
'reference' => $key,
|
||||
], static fn ($v) => $v !== null && $v !== ''));
|
||||
|
||||
if ($res->status() === 402) {
|
||||
$this->lastError = (string) ($res->json('error') ?: 'Insufficient SMS credit. Top up your Ladill SMS wallet and try again.');
|
||||
Log::warning('Care platform SMS send: insufficient balance', ['user' => $ownerPublicId]);
|
||||
$this->lastError = (string) ($res->json('error') ?: 'SMS allowance exceeded. Upgrade Care Pro or top up your wallet.');
|
||||
Log::warning('Care suite SMS: allowance/balance', ['user' => $ownerPublicId]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
$this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The SMS could not be sent.');
|
||||
Log::warning('Care platform SMS send failed', ['status' => $res->status(), 'body' => $res->body()]);
|
||||
Log::warning('Care suite SMS failed', ['status' => $res->status(), 'body' => $res->body()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -130,7 +81,7 @@ class PlatformSmsClient
|
||||
return (bool) ($res->json('success') ?? true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->lastError = 'Could not reach the SMS service. Please try again.';
|
||||
Log::warning('Care platform SMS send error', ['error' => $e->getMessage()]);
|
||||
Log::warning('Care suite SMS error', ['error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -73,12 +73,23 @@ class ProRenewalService
|
||||
$settings['plan'] = $plan;
|
||||
$settings['auto_renew'] = true;
|
||||
$settings['billed_branches'] = $branches;
|
||||
$settings['plan_expires_at'] = now()
|
||||
->addDays((int) config('care.pro.period_days', 30))
|
||||
->toIso8601String();
|
||||
$expiresAt = now()->addDays((int) config('care.pro.period_days', 30));
|
||||
$settings['plan_expires_at'] = $expiresAt->toIso8601String();
|
||||
unset($settings['plan_renewal_error'], $settings['enterprise_billed_branches']);
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
$this->billing->syncSuiteEntitlement(
|
||||
$organization->owner_ref,
|
||||
$isEnterprise ? 'enterprise' : 'pro',
|
||||
'active',
|
||||
$reference,
|
||||
[
|
||||
'period_starts_at' => now()->toIso8601String(),
|
||||
'period_ends_at' => $expiresAt->toIso8601String(),
|
||||
'source' => 'wallet_debit',
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,8 +99,30 @@ class ProRenewalService
|
||||
$settings['plan'] = 'free';
|
||||
unset($settings['plan_expires_at'], $settings['billed_branches'], $settings['enterprise_billed_branches']);
|
||||
$settings['plan_renewal_error'] = 'Suspended after failed renewal.';
|
||||
$this->billing->syncSuiteEntitlement(
|
||||
$organization->owner_ref,
|
||||
'free',
|
||||
'cancelled',
|
||||
$reference.'-free',
|
||||
[
|
||||
'period_starts_at' => now()->toIso8601String(),
|
||||
'period_ends_at' => now()->endOfMonth()->toIso8601String(),
|
||||
'source' => 'wallet_debit',
|
||||
],
|
||||
);
|
||||
} else {
|
||||
$settings['plan_renewal_error'] = 'Renewal failed — top up your wallet.';
|
||||
$this->billing->syncSuiteEntitlement(
|
||||
$organization->owner_ref,
|
||||
$isEnterprise ? 'enterprise' : 'pro',
|
||||
'past_due',
|
||||
$reference.'-pastdue',
|
||||
[
|
||||
'period_starts_at' => now()->toIso8601String(),
|
||||
'period_ends_at' => $graceEnd->toIso8601String(),
|
||||
'source' => 'wallet_debit',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
+6
-2
@@ -1,9 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'platform_api_url' => env('SMTP_PLATFORM_API_URL', 'https://bird.ladill.com/api/smtp'),
|
||||
'platform_api_url' => env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'),
|
||||
'platform_api_key' => env('SMTP_API_KEY_CARE'),
|
||||
'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://bird.ladill.com/api/smtp'),
|
||||
'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'),
|
||||
'from' => env('CARE_SMTP_FROM', 'care@ladill.com'),
|
||||
'from_name' => env('CARE_SMTP_FROM_NAME', 'Ladill Care'),
|
||||
'messaging_settings_url' => env(
|
||||
'MESSAGING_SETTINGS_URL',
|
||||
'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite',
|
||||
),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Department;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Patient;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CareSuiteMessagingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Branch $branch;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
$this->user = User::create([
|
||||
'public_id' => 'test-suite-user-001',
|
||||
'name' => 'Suite User',
|
||||
'email' => 'suite@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'name' => 'Suite Clinic',
|
||||
'slug' => 'suite-clinic',
|
||||
'timezone' => 'UTC',
|
||||
'settings' => ['onboarded' => true],
|
||||
]);
|
||||
|
||||
Member::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $this->user->public_id,
|
||||
'role' => 'receptionist',
|
||||
]);
|
||||
|
||||
$this->branch = Branch::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Main Branch',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Department::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'name' => 'General Outpatient',
|
||||
'type' => 'outpatient',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_patient_email_uses_suite_channel_when_platform_configured(): void
|
||||
{
|
||||
config([
|
||||
'smtp.platform_api_key' => 'care-smtp-key',
|
||||
'smtp.platform_api_url' => 'https://platform.test/api/smtp',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'platform.test/api/smtp/messages/send' => Http::response([
|
||||
'success' => true,
|
||||
'path' => 'suite',
|
||||
'message_id' => 'suite-1',
|
||||
]),
|
||||
]);
|
||||
|
||||
$patient = Patient::create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'LC-SUITE-001',
|
||||
'first_name' => 'Ama',
|
||||
'last_name' => 'Suite',
|
||||
'email' => 'ama.suite@example.com',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('care.patients.email', $patient), [
|
||||
'subject' => 'Hello',
|
||||
'body' => 'Suite email body',
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/messages/send')
|
||||
&& ($request['channel'] ?? null) === 'suite'
|
||||
&& ($request['user'] ?? null) === $this->user->public_id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_patient_sms_uses_suite_channel_when_platform_configured(): void
|
||||
{
|
||||
config([
|
||||
'sms.platform_api_key' => 'care-sms-key',
|
||||
'sms.platform_api_url' => 'https://platform.test/api',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'platform.test/api/sms/messages/send' => Http::response([
|
||||
'success' => true,
|
||||
'path' => 'suite',
|
||||
]),
|
||||
]);
|
||||
|
||||
$patient = Patient::create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'LC-SUITE-002',
|
||||
'first_name' => 'Kofi',
|
||||
'last_name' => 'Suite',
|
||||
'phone' => '0244000111',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('care.patients.sms', $patient), [
|
||||
'message' => 'Suite SMS body',
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$data = $request->data();
|
||||
|
||||
return str_contains($request->url(), '/sms/messages/send')
|
||||
&& ($data['channel'] ?? null) === 'suite'
|
||||
&& ! array_key_exists('sms_service_id', $data);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user