diff --git a/.env.example b/.env.example index 1cc8b24..ec8da0f 100644 --- a/.env.example +++ b/.env.example @@ -64,12 +64,14 @@ MAIL_PASSWORD= MAIL_FROM_ADDRESS=noreply@ladill.com MAIL_FROM_NAME=Ladill -# Legacy Bird SMTP API (unused for attendee/speaker mail; kept for future Bird integration) +# Platform suite messaging (zero-config — channel=suite; no Bird API key required) +# Monolith must have MESSAGING_SUITE_PLATFORM_CHANNEL=true and SMTP_API_KEY_EVENTS set. SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp SMTP_API_KEY_EVENTS= SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp EVENTS_SMTP_FROM=events@ladill.com EVENTS_SMTP_FROM_NAME="Ladill Events" +MESSAGING_SETTINGS_URL=https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite AFIA_ENABLED=true AFIA_PRODUCT=events diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index e2ab6a9..efed4a4 100644 --- a/app/Services/Billing/BillingClient.php +++ b/app/Services/Billing/BillingClient.php @@ -176,4 +176,60 @@ 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->configured()) { + return false; + } + + $app = (string) config('billing.service', 'events'); + $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)); + + if ($res->failed()) { + Log::warning('Events suite entitlement sync failed', [ + 'user' => $publicId, + 'status' => $res->status(), + 'body' => $res->body(), + ]); + + return false; + } + + return true; + } catch (\Throwable $e) { + Log::warning('Events suite entitlement sync error', ['error' => $e->getMessage()]); + + return false; + } + } } diff --git a/app/Services/Billing/PlatformEmailClient.php b/app/Services/Billing/PlatformEmailClient.php index 80b9c10..fd0fa52 100644 --- a/app/Services/Billing/PlatformEmailClient.php +++ b/app/Services/Billing/PlatformEmailClient.php @@ -4,16 +4,28 @@ namespace App\Services\Billing; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +/** + * Platform suite email (zero-config): POST /api/smtp/messages/send with channel=suite. + * No Bird API key or smtp_service_id required when platform messaging is enabled. + */ 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'), '/'); @@ -24,9 +36,30 @@ class PlatformEmailClient return (string) config('smtp.platform_api_key', ''); } - public function send(string $ownerPublicId, string $to, string $subject, string $html, ?string $text = null): bool + public function isConfigured(): bool { + return $this->token() !== ''; + } + + 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 Events instance.'; @@ -34,28 +67,41 @@ class PlatformEmailClient return false; } + $key = $idempotencyKey ?: ('events-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, - ])); + 'reference' => $key, + ], static fn ($v) => $v !== null && $v !== '')); if ($res->status() === 402) { - $this->lastError = (string) ($res->json('error') ?: 'Insufficient Bird email credits. Add funds in Billing and try again.'); - Log::warning('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 your plan or top up your wallet.'); + Log::warning('Events suite email: allowance/balance', ['user' => $ownerPublicId, 'body' => $res->body()]); return false; } if ($res->failed()) { - $this->lastError = (string) ($res->json('error') ?: 'The email could not be sent. Please try again.'); - Log::warning('Platform email send failed', ['status' => $res->status(), 'body' => $res->body()]); + $code = (string) ($res->json('error') ?: 'send_failed'); + $this->lastErrorCode = $code; + if ($code === 'messaging_mailbox_required') { + $this->lastError = 'Set a Ladill mailbox for suite email in Account → Messaging settings: '.$this->messagingSettingsUrl(); + } else { + $this->lastError = (string) ($res->json('error') ?: 'The email could not be sent. Please try again.'); + } + Log::warning('Events suite email failed', ['status' => $res->status(), 'body' => $res->body()]); return false; } @@ -63,14 +109,9 @@ class PlatformEmailClient return (bool) ($res->json('success') ?? true); } catch (\Throwable $e) { $this->lastError = 'The email could not be sent. Please try again.'; - Log::warning('Platform email send error', ['error' => $e->getMessage()]); + Log::warning('Events suite email error', ['error' => $e->getMessage()]); return false; } } - - public function isConfigured(): bool - { - return $this->token() !== ''; - } } diff --git a/app/Services/Billing/PlatformSmsClient.php b/app/Services/Billing/PlatformSmsClient.php index 67b0356..aa4389c 100644 --- a/app/Services/Billing/PlatformSmsClient.php +++ b/app/Services/Billing/PlatformSmsClient.php @@ -4,9 +4,21 @@ namespace App\Services\Billing; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +/** + * Platform suite SMS (zero-config): POST /api/sms/messages/send with channel=suite. + * No customer API key or sms_service_id required when platform messaging is enabled. + */ class PlatformSmsClient { + private ?string $lastError = null; + + public function lastError(): ?string + { + return $this->lastError; + } + private function base(): string { return rtrim((string) config('sms.platform_api_url', 'https://ladill.com/api'), '/'); @@ -17,96 +29,60 @@ class PlatformSmsClient return (string) config('sms.platform_api_key', ''); } - /** @return list> */ - public function services(string $ownerPublicId): array + public function isConfigured(): bool { - if ($this->token() === '') { - 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('Platform SMS services lookup failed', ['error' => $e->getMessage()]); - - return []; - } + return $this->token() !== ''; } - public function ensureServiceId(string $ownerPublicId): ?int - { - $services = $this->services($ownerPublicId); - if ($services !== []) { - return (int) ($services[0]['id'] ?? 0) ?: null; - } + public function send( + string $ownerPublicId, + string $to, + string $message, + ?string $senderId = null, + ?string $idempotencyKey = null, + ): bool { + $this->lastError = null; - if ($this->token() === '') { - return null; - } + if (! $this->isConfigured()) { + $this->lastError = 'SMS is not configured on this Events instance.'; - try { - $res = Http::withToken($this->token())->acceptJson()->timeout(15) - ->post($this->base().'/sms/services', [ - 'user' => $ownerPublicId, - 'name' => 'Ladill Events', - 'brand_name' => 'Events', - ]); - - if ($res->failed()) { - return null; - } - - return (int) ($res->json('data.id') ?? 0) ?: null; - } catch (\Throwable $e) { - Log::warning('Platform SMS service create failed', ['error' => $e->getMessage()]); - - return null; - } - } - - public function send(string $ownerPublicId, string $to, string $message, ?string $senderId = null): bool - { - if ($this->token() === '') { return false; } - $serviceId = $this->ensureServiceId($ownerPublicId); - if (! $serviceId) { - return false; - } + $key = $idempotencyKey ?: ('events-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) { - Log::warning('Platform SMS send: insufficient balance', ['user' => $ownerPublicId]); + $this->lastError = (string) ($res->json('error') ?: 'SMS allowance exceeded. Upgrade your plan or top up your wallet.'); + Log::warning('Events suite SMS: allowance/balance', ['user' => $ownerPublicId]); return false; } if ($res->failed()) { - Log::warning('Platform SMS send failed', ['status' => $res->status(), 'body' => $res->body()]); + $this->lastError = (string) ($res->json('error') ?: 'The SMS could not be sent.'); + Log::warning('Events suite SMS failed', ['status' => $res->status(), 'body' => $res->body()]); return false; } return (bool) ($res->json('success') ?? true); } catch (\Throwable $e) { - Log::warning('Platform SMS send error', ['error' => $e->getMessage()]); + $this->lastError = 'Could not reach the SMS service.'; + Log::warning('Events suite SMS error', ['error' => $e->getMessage()]); return false; } diff --git a/app/Services/Events/EventMailer.php b/app/Services/Events/EventMailer.php index 023a7c8..9d3acc5 100644 --- a/app/Services/Events/EventMailer.php +++ b/app/Services/Events/EventMailer.php @@ -2,12 +2,13 @@ namespace App\Services\Events; +use App\Services\Billing\PlatformEmailClient; use App\Services\Messaging\CustomerEmailClient; use App\Services\Messaging\MessagingCredentialsService; /** - * Sends Ladill Events transactional email to attendees and speakers via the - * customer's Ladill Bird relay when configured in Integrations. + * Attendee/speaker email: prefer platform suite messaging (zero-config mailbox), + * fall back to customer Bird credentials when suite is unavailable. */ class EventMailer { @@ -16,6 +17,7 @@ class EventMailer public function __construct( private readonly MessagingCredentialsService $credentials, private readonly CustomerEmailClient $customerEmail, + private readonly PlatformEmailClient $platformEmail, ) {} public function lastError(): ?string @@ -25,13 +27,17 @@ class EventMailer public function isConfiguredForOwner(string $ownerPublicId): bool { + if ($this->platformEmail->isConfigured()) { + return true; + } + return $this->credentials->forOwner($ownerPublicId)->hasValidBird(); } - /** @deprecated Use isConfiguredForOwner() — attendee email requires per-account Bird credentials. */ + /** @deprecated Use isConfiguredForOwner() */ public function isConfigured(): bool { - return false; + return $this->platformEmail->isConfigured(); } public function send( @@ -45,9 +51,36 @@ class EventMailer ): bool { $this->lastError = null; + if ($this->platformEmail->isConfigured()) { + $sent = $this->platformEmail->send($ownerPublicId, $to, $subject, $html, $text); + if ($sent) { + return true; + } + + // Suite unavailable (mailbox, allowance) — try Bird power-user path next. + $suiteError = $this->platformEmail->lastError(); + if ($this->tryCustomerBird($ownerPublicId, $to, $subject, $html, $text)) { + return true; + } + + $this->lastError = $suiteError ?: $this->lastError; + + return false; + } + + return $this->tryCustomerBird($ownerPublicId, $to, $subject, $html, $text); + } + + private function tryCustomerBird( + string $ownerPublicId, + string $to, + string $subject, + string $html, + ?string $text, + ): bool { $credential = $this->credentials->forOwner($ownerPublicId); if (! $credential->hasValidBird()) { - $this->lastError = 'Connect Ladill Bird in Integrations before sending attendee email.'; + $this->lastError ??= 'Connect a Ladill mailbox in Account → Messaging, or connect Ladill Bird in Integrations.'; return false; } diff --git a/app/Services/Events/SubscriptionService.php b/app/Services/Events/SubscriptionService.php index 0b92f44..069cd35 100644 --- a/app/Services/Events/SubscriptionService.php +++ b/app/Services/Events/SubscriptionService.php @@ -154,6 +154,9 @@ class SubscriptionService ? $this->enterprisePriceMinor() : $this->priceMinor(); + $periodEnd = Carbon::now()->addMonths($months); + $reference = 'events-prepaid-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis'); + ProSubscription::updateOrCreate( ['user_id' => $user->id], [ @@ -163,13 +166,16 @@ class SubscriptionService 'currency' => (string) config('events.pro.currency', 'GHS'), 'auto_renew' => false, 'started_at' => $existing?->started_at ?? now(), - 'current_period_end' => Carbon::now()->addMonths($months), + 'current_period_end' => $periodEnd, 'last_charged_at' => now(), 'canceled_at' => null, - 'last_reference' => null, + 'last_reference' => $reference, 'last_error' => null, ], ); + + // Prepaid checkout already writes entitlements on the platform; this is a repair/sync. + $this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd, 'checkout'); } /** @return array{0:bool,1:string} */ @@ -210,19 +216,23 @@ class SubscriptionService $result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Events {$planLabel} — renewal"); if ($result['ok']) { + $periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30)); $sub->forceFill([ - 'current_period_end' => Carbon::now()->addDays((int) config('events.pro.period_days', 30)), + 'current_period_end' => $periodEnd, 'last_charged_at' => now(), 'last_reference' => $reference, 'last_error' => null, ])->save(); + $this->syncMessagingEntitlement($user, $sub->plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd); + return; } $graceEnd = optional($sub->current_period_end)->addDays((int) config('events.pro.grace_days', 3)); if ($graceEnd && $graceEnd->isPast()) { $sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save(); + $this->syncMessagingEntitlement($user, $sub->plan, 'past_due', $reference.'-pastdue', $sub->current_period_end); } else { $sub->forceFill(['last_error' => $result['error']])->save(); } @@ -263,6 +273,32 @@ class SubscriptionService ], ); + $this->syncMessagingEntitlement($user, $plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd); + return [true, $successMessage.' Your subscription is active.']; } + + /** + * Map Events plan → platform suite entitlement plan (enterprise = Business tier). + */ + private function syncMessagingEntitlement( + User $user, + string $plan, + string $status, + string $reference, + ?Carbon $periodEnd, + string $source = 'wallet_debit', + ): void { + $platformPlan = $plan === ProSubscription::PLAN_ENTERPRISE ? 'enterprise' : ($plan === 'free' ? 'free' : 'pro'); + $publicId = (string) ($user->public_id ?? ''); + if ($publicId === '') { + return; + } + + $this->billing->syncSuiteEntitlement($publicId, $platformPlan, $status, $reference, [ + 'period_starts_at' => now()->toIso8601String(), + 'period_ends_at' => $periodEnd?->toIso8601String(), + 'source' => $source, + ]); + } } diff --git a/config/smtp.php b/config/smtp.php index 39333d5..673f91f 100644 --- a/config/smtp.php +++ b/config/smtp.php @@ -6,4 +6,9 @@ return [ 'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'), 'from' => env('EVENTS_SMTP_FROM', 'events@ladill.com'), 'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'), + // Deep link when suite path returns messaging_mailbox_required + 'messaging_settings_url' => env( + 'MESSAGING_SETTINGS_URL', + 'https://account.ladill.com/account/settings/messaging?reason=messaging_mailbox_required&from=suite', + ), ]; diff --git a/tests/Feature/EventsSuiteMessagingTest.php b/tests/Feature/EventsSuiteMessagingTest.php new file mode 100644 index 0000000..d571331 --- /dev/null +++ b/tests/Feature/EventsSuiteMessagingTest.php @@ -0,0 +1,130 @@ + 'events-key', + 'smtp.platform_api_url' => 'https://platform.test/api/smtp', + 'smtp.from_name' => 'Ladill Events', + ]); + + Http::fake([ + 'platform.test/api/smtp/messages/send' => Http::response(['success' => true, 'path' => 'suite']), + ]); + + $ok = app(PlatformEmailClient::class)->send( + 'usr_events_1', + 'guest@example.com', + 'Ticket', + '

Hi

', + 'Hi', + ); + + $this->assertTrue($ok); + Http::assertSent(function ($request) { + $data = $request->data(); + + return str_contains($request->url(), '/messages/send') + && ($data['channel'] ?? null) === 'suite' + && $request->hasHeader('Idempotency-Key'); + }); + } + + public function test_platform_sms_client_sends_channel_suite_without_service_id(): void + { + config([ + 'sms.platform_api_key' => 'events-sms-key', + 'sms.platform_api_url' => 'https://platform.test/api', + ]); + + Http::fake([ + 'platform.test/api/sms/messages/send' => Http::response(['success' => true, 'path' => 'suite']), + ]); + + $ok = app(PlatformSmsClient::class)->send('usr_events_1', '0241234567', 'Hello'); + + $this->assertTrue($ok); + 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); + }); + } + + public function test_event_mailer_uses_suite_when_platform_configured(): void + { + config([ + 'smtp.platform_api_key' => 'events-key', + 'smtp.platform_api_url' => 'https://platform.test/api/smtp', + ]); + + Http::fake([ + 'platform.test/api/smtp/messages/send' => Http::response(['success' => true]), + ]); + + $user = User::factory()->create(['public_id' => 'usr_mailer_suite']); + $ok = app(EventMailer::class)->send( + $user->public_id, + 'speaker@example.com', + 'Invite', + '

Join us

', + 'Join us', + ); + + $this->assertTrue($ok); + $this->assertTrue(app(EventMailer::class)->isConfiguredForOwner($user->public_id)); + } + + public function test_billing_client_syncs_suite_entitlement(): void + { + config([ + 'billing.api_url' => 'https://platform.test/api/billing', + 'billing.api_key' => 'events-billing', + 'billing.service' => 'events', + ]); + + Http::fake([ + 'platform.test/api/billing/suite-entitlements' => Http::response([ + 'data' => ['app' => 'events', 'plan' => 'pro'], + 'idempotent_replay' => false, + ], 201), + ]); + + $ok = app(\App\Services\Billing\BillingClient::class)->syncSuiteEntitlement( + 'usr_events_1', + 'pro', + 'active', + 'events-pro-ref-1', + [ + 'period_starts_at' => now()->toIso8601String(), + 'period_ends_at' => now()->addMonth()->toIso8601String(), + 'source' => 'wallet_debit', + ], + ); + + $this->assertTrue($ok); + Http::assertSent(function ($request) { + $data = $request->data(); + + return str_contains($request->url(), '/suite-entitlements') + && ($data['app'] ?? null) === 'events' + && ($data['plan'] ?? null) === 'pro'; + }); + } +}