feat(messaging): suite-channel email/SMS and entitlement sync
Deploy Ladill Events / deploy (push) Successful in 48s

Prefer platform suite messaging (channel=suite) for attendee email, fall back
to Bird integrations; sync suite plan entitlements on subscribe/renew.
This commit is contained in:
isaacclad
2026-07-16 11:24:21 +00:00
parent b73b071cb1
commit 3d5ffa593d
8 changed files with 365 additions and 86 deletions
+3 -1
View File
@@ -64,12 +64,14 @@ MAIL_PASSWORD=
MAIL_FROM_ADDRESS=noreply@ladill.com MAIL_FROM_ADDRESS=noreply@ladill.com
MAIL_FROM_NAME=Ladill 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_PLATFORM_API_URL=https://ladill.com/api/smtp
SMTP_API_KEY_EVENTS= SMTP_API_KEY_EVENTS=
SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp
EVENTS_SMTP_FROM=events@ladill.com EVENTS_SMTP_FROM=events@ladill.com
EVENTS_SMTP_FROM_NAME="Ladill Events" 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_ENABLED=true
AFIA_PRODUCT=events AFIA_PRODUCT=events
+56
View File
@@ -176,4 +176,60 @@ class BillingClient
return $res->json(); 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;
}
}
} }
+56 -15
View File
@@ -4,16 +4,28 @@ namespace App\Services\Billing;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; 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 class PlatformEmailClient
{ {
private ?string $lastError = null; private ?string $lastError = null;
private ?string $lastErrorCode = null;
public function lastError(): ?string public function lastError(): ?string
{ {
return $this->lastError; return $this->lastError;
} }
public function lastErrorCode(): ?string
{
return $this->lastErrorCode;
}
private function base(): string private function base(): string
{ {
return rtrim((string) config('smtp.platform_api_url', 'https://ladill.com/api/smtp'), '/'); 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', ''); 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->lastError = null;
$this->lastErrorCode = null;
if (! $this->isConfigured()) { if (! $this->isConfigured()) {
$this->lastError = 'Outbound email is not configured on this Events instance.'; $this->lastError = 'Outbound email is not configured on this Events instance.';
@@ -34,28 +67,41 @@ class PlatformEmailClient
return false; return false;
} }
$key = $idempotencyKey ?: ('events-email-'.Str::uuid());
try { 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([ ->post($this->base().'/messages/send', array_filter([
'user' => $ownerPublicId, 'user' => $ownerPublicId,
'from' => config('smtp.from'), 'channel' => 'suite',
'from_name' => config('smtp.from_name'), 'from_name' => $fromName ?? config('smtp.from_name'),
'to' => $to, 'to' => $to,
'subject' => $subject, 'subject' => $subject,
'html' => $html, 'html' => $html,
'text' => $text, 'text' => $text,
])); 'reference' => $key,
], static fn ($v) => $v !== null && $v !== ''));
if ($res->status() === 402) { if ($res->status() === 402) {
$this->lastError = (string) ($res->json('error') ?: 'Insufficient Bird email credits. Add funds in Billing and try again.'); $this->lastErrorCode = (string) ($res->json('error') ?: 'allowance_exceeded');
Log::warning('Platform email send: insufficient balance', ['user' => $ownerPublicId]); $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; return false;
} }
if ($res->failed()) { if ($res->failed()) {
$this->lastError = (string) ($res->json('error') ?: 'The email could not be sent. Please try again.'); $code = (string) ($res->json('error') ?: 'send_failed');
Log::warning('Platform email send failed', ['status' => $res->status(), 'body' => $res->body()]); $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; return false;
} }
@@ -63,14 +109,9 @@ class PlatformEmailClient
return (bool) ($res->json('success') ?? true); return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->lastError = 'The email could not be sent. Please try again.'; $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; return false;
} }
} }
public function isConfigured(): bool
{
return $this->token() !== '';
}
} }
+38 -62
View File
@@ -4,9 +4,21 @@ namespace App\Services\Billing;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; 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 class PlatformSmsClient
{ {
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string private function base(): string
{ {
return rtrim((string) config('sms.platform_api_url', 'https://ladill.com/api'), '/'); 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 (string) config('sms.platform_api_key', '');
} }
/** @return list<array<string, mixed>> */ public function isConfigured(): bool
public function services(string $ownerPublicId): array
{ {
if ($this->token() === '') { return $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 [];
}
} }
public function ensureServiceId(string $ownerPublicId): ?int public function send(
{ string $ownerPublicId,
$services = $this->services($ownerPublicId); string $to,
if ($services !== []) { string $message,
return (int) ($services[0]['id'] ?? 0) ?: null; ?string $senderId = null,
} ?string $idempotencyKey = null,
): bool {
$this->lastError = null;
if ($this->token() === '') { if (! $this->isConfigured()) {
return null; $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; return false;
} }
$serviceId = $this->ensureServiceId($ownerPublicId); $key = $idempotencyKey ?: ('events-sms-'.Str::uuid());
if (! $serviceId) {
return false;
}
try { 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([ ->post($this->base().'/sms/messages/send', array_filter([
'user' => $ownerPublicId, 'user' => $ownerPublicId,
'sms_service_id' => $serviceId, 'channel' => 'suite',
'to' => $to, 'to' => $to,
'text' => $message, 'text' => $message,
'sender_id' => $senderId ?? config('sms.default_sender_id'), 'sender_id' => $senderId ?? config('sms.default_sender_id'),
])); 'reference' => $key,
], static fn ($v) => $v !== null && $v !== ''));
if ($res->status() === 402) { 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; return false;
} }
if ($res->failed()) { 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 false;
} }
return (bool) ($res->json('success') ?? true); return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) { } 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; return false;
} }
+38 -5
View File
@@ -2,12 +2,13 @@
namespace App\Services\Events; namespace App\Services\Events;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Messaging\CustomerEmailClient; use App\Services\Messaging\CustomerEmailClient;
use App\Services\Messaging\MessagingCredentialsService; use App\Services\Messaging\MessagingCredentialsService;
/** /**
* Sends Ladill Events transactional email to attendees and speakers via the * Attendee/speaker email: prefer platform suite messaging (zero-config mailbox),
* customer's Ladill Bird relay when configured in Integrations. * fall back to customer Bird credentials when suite is unavailable.
*/ */
class EventMailer class EventMailer
{ {
@@ -16,6 +17,7 @@ class EventMailer
public function __construct( public function __construct(
private readonly MessagingCredentialsService $credentials, private readonly MessagingCredentialsService $credentials,
private readonly CustomerEmailClient $customerEmail, private readonly CustomerEmailClient $customerEmail,
private readonly PlatformEmailClient $platformEmail,
) {} ) {}
public function lastError(): ?string public function lastError(): ?string
@@ -25,13 +27,17 @@ class EventMailer
public function isConfiguredForOwner(string $ownerPublicId): bool public function isConfiguredForOwner(string $ownerPublicId): bool
{ {
if ($this->platformEmail->isConfigured()) {
return true;
}
return $this->credentials->forOwner($ownerPublicId)->hasValidBird(); return $this->credentials->forOwner($ownerPublicId)->hasValidBird();
} }
/** @deprecated Use isConfiguredForOwner() — attendee email requires per-account Bird credentials. */ /** @deprecated Use isConfiguredForOwner() */
public function isConfigured(): bool public function isConfigured(): bool
{ {
return false; return $this->platformEmail->isConfigured();
} }
public function send( public function send(
@@ -45,9 +51,36 @@ class EventMailer
): bool { ): bool {
$this->lastError = null; $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); $credential = $this->credentials->forOwner($ownerPublicId);
if (! $credential->hasValidBird()) { 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; return false;
} }
+39 -3
View File
@@ -154,6 +154,9 @@ class SubscriptionService
? $this->enterprisePriceMinor() ? $this->enterprisePriceMinor()
: $this->priceMinor(); : $this->priceMinor();
$periodEnd = Carbon::now()->addMonths($months);
$reference = 'events-prepaid-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis');
ProSubscription::updateOrCreate( ProSubscription::updateOrCreate(
['user_id' => $user->id], ['user_id' => $user->id],
[ [
@@ -163,13 +166,16 @@ class SubscriptionService
'currency' => (string) config('events.pro.currency', 'GHS'), 'currency' => (string) config('events.pro.currency', 'GHS'),
'auto_renew' => false, 'auto_renew' => false,
'started_at' => $existing?->started_at ?? now(), 'started_at' => $existing?->started_at ?? now(),
'current_period_end' => Carbon::now()->addMonths($months), 'current_period_end' => $periodEnd,
'last_charged_at' => now(), 'last_charged_at' => now(),
'canceled_at' => null, 'canceled_at' => null,
'last_reference' => null, 'last_reference' => $reference,
'last_error' => null, '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} */ /** @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"); $result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Events {$planLabel} — renewal");
if ($result['ok']) { if ($result['ok']) {
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
$sub->forceFill([ $sub->forceFill([
'current_period_end' => Carbon::now()->addDays((int) config('events.pro.period_days', 30)), 'current_period_end' => $periodEnd,
'last_charged_at' => now(), 'last_charged_at' => now(),
'last_reference' => $reference, 'last_reference' => $reference,
'last_error' => null, 'last_error' => null,
])->save(); ])->save();
$this->syncMessagingEntitlement($user, $sub->plan, ProSubscription::STATUS_ACTIVE, $reference, $periodEnd);
return; return;
} }
$graceEnd = optional($sub->current_period_end)->addDays((int) config('events.pro.grace_days', 3)); $graceEnd = optional($sub->current_period_end)->addDays((int) config('events.pro.grace_days', 3));
if ($graceEnd && $graceEnd->isPast()) { if ($graceEnd && $graceEnd->isPast()) {
$sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save(); $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 { } else {
$sub->forceFill(['last_error' => $result['error']])->save(); $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.']; 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,
]);
}
} }
+5
View File
@@ -6,4 +6,9 @@ return [
'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'), 'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'),
'from' => env('EVENTS_SMTP_FROM', 'events@ladill.com'), 'from' => env('EVENTS_SMTP_FROM', 'events@ladill.com'),
'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'), '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',
),
]; ];
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
use App\Services\Events\EventMailer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class EventsSuiteMessagingTest extends TestCase
{
use RefreshDatabase;
public function test_platform_email_client_sends_channel_suite(): void
{
config([
'smtp.platform_api_key' => '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',
'<p>Hi</p>',
'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',
'<p>Join us</p>',
'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';
});
}
}