From cfdc8c7c09bd7436d36819d4cdb7d131b5d0e062 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 7 Jul 2026 02:59:45 +0000 Subject: [PATCH] Add Woo Manager email notification milestones with shared mail templates. New order, store connected, Pro expiry, and past-due alerts use the Ladill notification layout with woo branding; expiry reminders dedupe per threshold like hosting. Co-authored-by: Cursor --- .../Commands/NotifyProExpiringCommand.php | 21 +++ .../Api/StoreActivationController.php | 11 +- app/Http/Controllers/WooWebhookController.php | 10 +- app/Models/Woo/ProSubscription.php | 3 +- .../Woo/WooOrderReceivedNotification.php | 74 ++++++++ .../Woo/WooProExpiringNotification.php | 87 +++++++++ .../Woo/WooProPastDueNotification.php | 64 +++++++ .../Woo/WooStoreConnectedNotification.php | 61 +++++++ app/Services/Woo/SubscriptionService.php | 17 +- app/Services/Woo/WooNotificationService.php | 67 +++++++ app/Services/Woo/WooProExpiryAlertService.php | 82 +++++++++ config/mail_brands.php | 10 ++ config/notifications.php | 33 +++- ...etadata_to_woo_pro_subscriptions_table.php | 22 +++ .../woo-order-received.blade.php | 80 +++++++++ .../notifications/woo-pro-expiring.blade.php | 48 +++++ .../notifications/woo-pro-past-due.blade.php | 37 ++++ .../woo-store-connected.blade.php | 39 ++++ resources/views/notifications/_list.blade.php | 1 + resources/views/notifications/index.blade.php | 4 +- routes/console.php | 1 + tests/Feature/WooNotificationTest.php | 166 ++++++++++++++++++ 22 files changed, 926 insertions(+), 12 deletions(-) create mode 100644 app/Console/Commands/NotifyProExpiringCommand.php create mode 100644 app/Notifications/Woo/WooOrderReceivedNotification.php create mode 100644 app/Notifications/Woo/WooProExpiringNotification.php create mode 100644 app/Notifications/Woo/WooProPastDueNotification.php create mode 100644 app/Notifications/Woo/WooStoreConnectedNotification.php create mode 100644 app/Services/Woo/WooNotificationService.php create mode 100644 app/Services/Woo/WooProExpiryAlertService.php create mode 100644 database/migrations/2026_07_07_030000_add_metadata_to_woo_pro_subscriptions_table.php create mode 100644 resources/views/mail/notifications/woo-order-received.blade.php create mode 100644 resources/views/mail/notifications/woo-pro-expiring.blade.php create mode 100644 resources/views/mail/notifications/woo-pro-past-due.blade.php create mode 100644 resources/views/mail/notifications/woo-store-connected.blade.php create mode 100644 tests/Feature/WooNotificationTest.php diff --git a/app/Console/Commands/NotifyProExpiringCommand.php b/app/Console/Commands/NotifyProExpiringCommand.php new file mode 100644 index 0000000..510e082 --- /dev/null +++ b/app/Console/Commands/NotifyProExpiringCommand.php @@ -0,0 +1,21 @@ +checkAll(); + $this->info("Sent {$sent} pro expiry alert(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/StoreActivationController.php b/app/Http/Controllers/Api/StoreActivationController.php index 8b9115c..1ce0105 100644 --- a/app/Http/Controllers/Api/StoreActivationController.php +++ b/app/Http/Controllers/Api/StoreActivationController.php @@ -6,13 +6,17 @@ use App\Http\Controllers\Controller; use App\Jobs\StoreBootstrapSync; use App\Models\WooStore; use App\Services\Woo\InstallTokenService; +use App\Services\Woo\WooNotificationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use RuntimeException; class StoreActivationController extends Controller { - public function __construct(private InstallTokenService $tokens) {} + public function __construct( + private InstallTokenService $tokens, + private WooNotificationService $notifications, + ) {} public function activate(Request $request): JsonResponse { @@ -35,9 +39,14 @@ class StoreActivationController extends Controller return response()->json(['message' => 'Site URL does not match the connection request.'], 422); } + $wasPending = $store->status === WooStore::STATUS_PENDING; $pluginToken = $this->tokens->issuePluginToken($store); $store = $store->fresh(); + if ($wasPending) { + $this->notifications->storeConnected($store); + } + StoreBootstrapSync::dispatch($store->id)->afterResponse(); return response()->json([ diff --git a/app/Http/Controllers/WooWebhookController.php b/app/Http/Controllers/WooWebhookController.php index 0f13492..82bd2b9 100644 --- a/app/Http/Controllers/WooWebhookController.php +++ b/app/Http/Controllers/WooWebhookController.php @@ -6,6 +6,7 @@ use App\Models\WooStore; use App\Services\Woo\CategoryIngestService; use App\Services\Woo\OrderIngestService; use App\Services\Woo\ProductIngestService; +use App\Services\Woo\WooNotificationService; use App\Services\Woo\WooWebhookVerifier; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -17,6 +18,7 @@ class WooWebhookController extends Controller private OrderIngestService $orders, private ProductIngestService $products, private CategoryIngestService $categories, + private WooNotificationService $notifications, ) {} public function __invoke(Request $request, string $storePublicId): JsonResponse @@ -46,7 +48,7 @@ class WooWebhookController extends Controller } return match (true) { - str_starts_with($topic, 'order.') => $this->handleOrder($store, $payload), + str_starts_with($topic, 'order.') => $this->handleOrder($store, $topic, $payload), str_starts_with($topic, 'product_category.') => $this->handleCategory($store, $topic, $payload), str_starts_with($topic, 'product.') => $this->handleProduct($store, $topic, $payload), default => response()->json(['ignored' => true]), @@ -54,10 +56,14 @@ class WooWebhookController extends Controller } /** @param array $payload */ - private function handleOrder(WooStore $store, array $payload): JsonResponse + private function handleOrder(WooStore $store, string $topic, array $payload): JsonResponse { $order = $this->orders->ingest($store, $payload); + if ($topic === 'order.created' && $order->wasRecentlyCreated) { + $this->notifications->orderReceived($order, $store); + } + return response()->json([ 'ok' => true, 'order_id' => $order->id, diff --git a/app/Models/Woo/ProSubscription.php b/app/Models/Woo/ProSubscription.php index b7f55d3..df627d5 100644 --- a/app/Models/Woo/ProSubscription.php +++ b/app/Models/Woo/ProSubscription.php @@ -23,7 +23,7 @@ class ProSubscription extends Model protected $fillable = [ 'user_id', 'status', 'plan', 'price_minor', 'currency', 'auto_renew', 'started_at', 'current_period_end', 'last_charged_at', 'canceled_at', - 'last_reference', 'last_error', + 'last_reference', 'last_error', 'metadata', ]; protected $casts = [ @@ -33,6 +33,7 @@ class ProSubscription extends Model 'current_period_end' => 'datetime', 'last_charged_at' => 'datetime', 'canceled_at' => 'datetime', + 'metadata' => 'array', ]; public function user(): BelongsTo diff --git a/app/Notifications/Woo/WooOrderReceivedNotification.php b/app/Notifications/Woo/WooOrderReceivedNotification.php new file mode 100644 index 0000000..b44fc62 --- /dev/null +++ b/app/Notifications/Woo/WooOrderReceivedNotification.php @@ -0,0 +1,74 @@ +channels('order_received'); + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage()) + ->subject($this->subjectLine()) + ->view('mail.notifications.woo-order-received', [ + 'order' => $this->order, + 'store' => $this->store, + 'manageUrl' => route('woo.orders.index'), + ]); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + $label = $this->store->site_name ?? parse_url($this->store->site_url, PHP_URL_HOST) ?? 'your store'; + + return [ + 'title' => 'New order received', + 'message' => "Order #{$this->order->order_number} for {$this->order->totalFormatted()} from {$label}.", + 'icon' => 'order', + 'milestone' => 'order_received', + 'url' => route('woo.orders.index'), + 'order_id' => $this->order->id, + 'store_id' => $this->store->id, + ]; + } + + private function subjectLine(): string + { + $storeLabel = $this->store->site_name ?? parse_url($this->store->site_url, PHP_URL_HOST) ?? 'WooCommerce store'; + + return "New order #{$this->order->order_number} — {$storeLabel}"; + } + + /** @return list */ + private function channels(string $event): array + { + $config = (array) config("notifications.woo_events.{$event}", ['mail' => true, 'database' => true]); + $channels = []; + if ($config['mail'] ?? false) { + $channels[] = 'mail'; + } + if ($config['database'] ?? false) { + $channels[] = 'database'; + } + + return $channels ?: ['database']; + } +} diff --git a/app/Notifications/Woo/WooProExpiringNotification.php b/app/Notifications/Woo/WooProExpiringNotification.php new file mode 100644 index 0000000..2fef2b9 --- /dev/null +++ b/app/Notifications/Woo/WooProExpiringNotification.php @@ -0,0 +1,87 @@ +channels('pro_expiring'); + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage()) + ->subject($this->subjectLine()) + ->view('mail.notifications.woo-pro-expiring', [ + 'subscription' => $this->subscription, + 'daysRemaining' => $this->daysRemaining, + 'planLabel' => $this->planLabel(), + 'renewUrl' => route('woo.pro.index'), + ]); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'title' => $this->headline(), + 'message' => "Your Woo Manager {$this->planLabel()} plan expires in {$this->daysRemaining} day(s).", + 'icon' => 'billing', + 'milestone' => 'pro_expiring', + 'url' => route('woo.pro.index'), + 'days_remaining' => $this->daysRemaining, + ]; + } + + private function subjectLine(): string + { + return match (true) { + $this->daysRemaining <= 1 => 'Woo Manager plan expires tomorrow', + $this->daysRemaining <= 3 => "Woo Manager plan expires in {$this->daysRemaining} days", + default => 'Woo Manager plan renewal reminder', + }; + } + + private function headline(): string + { + return match (true) { + $this->daysRemaining <= 1 => 'Plan expires soon', + $this->daysRemaining <= 3 => 'Urgent plan renewal', + default => 'Plan renewal reminder', + }; + } + + private function planLabel(): string + { + return $this->subscription->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro'; + } + + /** @return list */ + private function channels(string $event): array + { + $config = (array) config("notifications.woo_events.{$event}", ['mail' => true, 'database' => true]); + $channels = []; + if ($config['mail'] ?? false) { + $channels[] = 'mail'; + } + if ($config['database'] ?? false) { + $channels[] = 'database'; + } + + return $channels ?: ['database']; + } +} diff --git a/app/Notifications/Woo/WooProPastDueNotification.php b/app/Notifications/Woo/WooProPastDueNotification.php new file mode 100644 index 0000000..32d78e7 --- /dev/null +++ b/app/Notifications/Woo/WooProPastDueNotification.php @@ -0,0 +1,64 @@ +channels('pro_past_due'); + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage()) + ->subject('Woo Manager subscription payment failed') + ->view('mail.notifications.woo-pro-past-due', [ + 'subscription' => $this->subscription, + 'planLabel' => $this->planLabel(), + 'renewUrl' => route('woo.pro.index'), + ]); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'title' => 'Subscription payment failed', + 'message' => "We could not renew your Woo Manager {$this->planLabel()} plan. Top up your wallet to restore access.", + 'icon' => 'billing', + 'milestone' => 'pro_past_due', + 'url' => route('woo.pro.index'), + ]; + } + + private function planLabel(): string + { + return $this->subscription->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro'; + } + + /** @return list */ + private function channels(string $event): array + { + $config = (array) config("notifications.woo_events.{$event}", ['mail' => true, 'database' => true]); + $channels = []; + if ($config['mail'] ?? false) { + $channels[] = 'mail'; + } + if ($config['database'] ?? false) { + $channels[] = 'database'; + } + + return $channels ?: ['database']; + } +} diff --git a/app/Notifications/Woo/WooStoreConnectedNotification.php b/app/Notifications/Woo/WooStoreConnectedNotification.php new file mode 100644 index 0000000..3055f45 --- /dev/null +++ b/app/Notifications/Woo/WooStoreConnectedNotification.php @@ -0,0 +1,61 @@ +channels('store_connected'); + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage()) + ->subject('WooCommerce store connected — '.($this->store->site_name ?? parse_url($this->store->site_url, PHP_URL_HOST))) + ->view('mail.notifications.woo-store-connected', [ + 'store' => $this->store, + 'manageUrl' => route('woo.dashboard'), + ]); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + $label = $this->store->site_name ?? parse_url($this->store->site_url, PHP_URL_HOST) ?? 'your store'; + + return [ + 'title' => 'Store connected', + 'message' => "{$label} is now linked to Ladill Woo Manager.", + 'icon' => 'success', + 'milestone' => 'store_connected', + 'url' => route('woo.stores.index'), + 'store_id' => $this->store->id, + ]; + } + + /** @return list */ + private function channels(string $event): array + { + $config = (array) config("notifications.woo_events.{$event}", ['mail' => true, 'database' => true]); + $channels = []; + if ($config['mail'] ?? false) { + $channels[] = 'mail'; + } + if ($config['database'] ?? false) { + $channels[] = 'database'; + } + + return $channels ?: ['database']; + } +} diff --git a/app/Services/Woo/SubscriptionService.php b/app/Services/Woo/SubscriptionService.php index 40751ba..10225d3 100644 --- a/app/Services/Woo/SubscriptionService.php +++ b/app/Services/Woo/SubscriptionService.php @@ -11,7 +11,11 @@ use Illuminate\Support\Str; class SubscriptionService { - public function __construct(private readonly BillingClient $billing) {} + public function __construct( + private readonly BillingClient $billing, + private readonly WooNotificationService $notifications, + private readonly WooProExpiryAlertService $expiryAlerts, + ) {} public function gatingActive(): bool { @@ -238,13 +242,18 @@ class SubscriptionService 'last_reference' => $reference, 'last_error' => null, ])->save(); + $this->expiryAlerts->resetAlerts($sub->fresh()); return; } $graceEnd = optional($sub->current_period_end)->addDays((int) config('woo.pro.grace_days', 3)); if ($graceEnd && $graceEnd->isPast()) { + $wasPastDue = $sub->status === ProSubscription::STATUS_PAST_DUE; $sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save(); + if (! $wasPastDue) { + $this->notifications->proPastDue($sub->fresh()); + } } else { $sub->forceFill(['last_error' => $result['error']])->save(); } @@ -282,9 +291,15 @@ class SubscriptionService 'canceled_at' => null, 'last_reference' => $reference, 'last_error' => null, + 'metadata' => null, ], ); + $subscription = $this->subscriptionFor($user); + if ($subscription) { + $this->expiryAlerts->resetAlerts($subscription); + } + return [true, $successMessage.' Your subscription is active.']; } } diff --git a/app/Services/Woo/WooNotificationService.php b/app/Services/Woo/WooNotificationService.php new file mode 100644 index 0000000..1a54d43 --- /dev/null +++ b/app/Services/Woo/WooNotificationService.php @@ -0,0 +1,67 @@ +loadMissing('store'); + $user = $store->user; + if (! $user) { + return; + } + + $this->send($user, 'order_received', new WooOrderReceivedNotification($order, $store)); + } + + public function storeConnected(WooStore $store): void + { + $user = $store->user; + if (! $user) { + return; + } + + $this->send($user, 'store_connected', new WooStoreConnectedNotification($store)); + } + + public function proExpiring(ProSubscription $subscription, int $daysRemaining): void + { + $user = $subscription->user; + if (! $user) { + return; + } + + $this->send($user, 'pro_expiring', new WooProExpiringNotification($subscription, $daysRemaining)); + } + + public function proPastDue(ProSubscription $subscription): void + { + $user = $subscription->user; + if (! $user) { + return; + } + + $this->send($user, 'pro_past_due', new WooProPastDueNotification($subscription)); + } + + private function send(User $user, string $event, Notification $notification): void + { + $channels = (array) config("notifications.woo_events.{$event}", ['mail' => true, 'database' => true]); + if (! ($channels['mail'] ?? false) && ! ($channels['database'] ?? false)) { + return; + } + + $user->notify($notification); + } +} diff --git a/app/Services/Woo/WooProExpiryAlertService.php b/app/Services/Woo/WooProExpiryAlertService.php new file mode 100644 index 0000000..d7e6eab --- /dev/null +++ b/app/Services/Woo/WooProExpiryAlertService.php @@ -0,0 +1,82 @@ + */ + public function milestones(): array + { + return array_values(array_map( + 'intval', + (array) config('notifications.pro_expiry_milestones', [7, 3, 1]) + )); + } + + public function checkAll(): int + { + $sent = 0; + + ProSubscription::query() + ->whereIn('status', [ProSubscription::STATUS_ACTIVE, ProSubscription::STATUS_CANCELED]) + ->whereNotNull('current_period_end') + ->where('current_period_end', '>', now()) + ->with('user') + ->each(function (ProSubscription $subscription) use (&$sent): void { + if ($this->checkSubscription($subscription)) { + $sent++; + } + }); + + return $sent; + } + + public function checkSubscription(ProSubscription $subscription): bool + { + if (! $subscription->current_period_end || ! $subscription->user || $subscription->current_period_end->isPast()) { + return false; + } + + if ($subscription->auto_renew && $subscription->status === ProSubscription::STATUS_ACTIVE) { + return false; + } + + $daysRemaining = $this->daysUntilExpiry($subscription); + if (! in_array($daysRemaining, $this->milestones(), true)) { + return false; + } + + $metadata = is_array($subscription->metadata) ? $subscription->metadata : []; + $sent = array_map('intval', (array) ($metadata['expiry_alerts_sent'] ?? [])); + if (in_array($daysRemaining, $sent, true)) { + return false; + } + + $this->notifications->proExpiring($subscription, $daysRemaining); + + $metadata['expiry_alerts_sent'] = array_values(array_unique([...$sent, $daysRemaining])); + $subscription->update(['metadata' => $metadata]); + + return true; + } + + public function resetAlerts(ProSubscription $subscription): void + { + $metadata = is_array($subscription->metadata) ? $subscription->metadata : []; + unset($metadata['expiry_alerts_sent']); + $subscription->update(['metadata' => $metadata]); + } + + private function daysUntilExpiry(ProSubscription $subscription): int + { + return (int) now()->startOfDay()->diffInDays( + $subscription->current_period_end->copy()->startOfDay(), + false + ); + } +} diff --git a/config/mail_brands.php b/config/mail_brands.php index c0fa4a7..0b44110 100644 --- a/config/mail_brands.php +++ b/config/mail_brands.php @@ -119,5 +119,15 @@ return [ 'support_path' => '/support', 'home_label' => 'servers.ladill.com', ], + 'woo' => [ + 'name' => 'Ladill Woo Manager', + 'logo' => 'ladillwoomanager-logo.svg', + 'logo_class' => 'email-logo', + 'app_url' => env('APP_URL', 'https://woo.ladill.com'), + 'footer_account' => 'a Ladill Woo Manager account', + 'dashboard_path' => '/dashboard', + 'support_path' => '/support', + 'home_label' => 'woo.ladill.com', + ], ], ]; diff --git a/config/notifications.php b/config/notifications.php index 82c6f9b..0ec97c3 100644 --- a/config/notifications.php +++ b/config/notifications.php @@ -4,14 +4,37 @@ return [ /* |-------------------------------------------------------------------------- - | FCM push for Ladill Mini Android + | FCM push (legacy Mini scaffolding) |-------------------------------------------------------------------------- - | - | Sellers receive instant payment alerts when a device token is registered - | and the account was active in the app within this window. - | */ 'fcm_active_user_within_days' => (int) env('NOTIFICATIONS_FCM_ACTIVE_USER_DAYS', 60), + /* + |-------------------------------------------------------------------------- + | Woo Manager Pro — days before period end to send one-time reminder emails + |-------------------------------------------------------------------------- + */ + + 'pro_expiry_milestones' => array_values(array_map( + 'intval', + explode(',', (string) env('WOO_PRO_EXPIRY_MILESTONES', '7,3,1')) + )), + + /* + |-------------------------------------------------------------------------- + | Woo Manager notification events + |-------------------------------------------------------------------------- + | + | milestone keys are stored on in-app notifications for filtering/history. + | + */ + + 'woo_events' => [ + 'order_received' => ['mail' => true, 'database' => true], + 'store_connected' => ['mail' => true, 'database' => true], + 'pro_expiring' => ['mail' => true, 'database' => true], + 'pro_past_due' => ['mail' => true, 'database' => true], + ], + ]; diff --git a/database/migrations/2026_07_07_030000_add_metadata_to_woo_pro_subscriptions_table.php b/database/migrations/2026_07_07_030000_add_metadata_to_woo_pro_subscriptions_table.php new file mode 100644 index 0000000..6e09ba7 --- /dev/null +++ b/database/migrations/2026_07_07_030000_add_metadata_to_woo_pro_subscriptions_table.php @@ -0,0 +1,22 @@ +json('metadata')->nullable()->after('last_error'); + }); + } + + public function down(): void + { + Schema::table('woo_pro_subscriptions', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; diff --git a/resources/views/mail/notifications/woo-order-received.blade.php b/resources/views/mail/notifications/woo-order-received.blade.php new file mode 100644 index 0000000..94f4749 --- /dev/null +++ b/resources/views/mail/notifications/woo-order-received.blade.php @@ -0,0 +1,80 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'woo']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'woo']) +@endsection + +@section('content') +
+

New order received

+

Order #{{ $order->order_number }}

+
+ +

You have a new WooCommerce order

+ + + + + + @if(is_array($order->line_items) && count($order->line_items) > 0) + + @endif + +
+ +

{{ $order->totalFormatted() }}

+
+ +

+ +

+@endsection diff --git a/resources/views/mail/notifications/woo-pro-expiring.blade.php b/resources/views/mail/notifications/woo-pro-expiring.blade.php new file mode 100644 index 0000000..1d6ec19 --- /dev/null +++ b/resources/views/mail/notifications/woo-pro-expiring.blade.php @@ -0,0 +1,48 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'woo']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'woo']) +@endsection + +@section('content') +
+

Plan expiring soon

+

Woo Manager {{ $planLabel }}

+
+ +

Your Woo Manager {{ $planLabel }} plan is expiring

+ + + + + +

+ +

+ + + + +@endsection diff --git a/resources/views/mail/notifications/woo-pro-past-due.blade.php b/resources/views/mail/notifications/woo-pro-past-due.blade.php new file mode 100644 index 0000000..1f6bdad --- /dev/null +++ b/resources/views/mail/notifications/woo-pro-past-due.blade.php @@ -0,0 +1,37 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'woo']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'woo']) +@endsection + +@section('content') +
+

Payment failed

+

Woo Manager {{ $planLabel }}

+
+ +

We could not renew your subscription

+ + + + @if($subscription->last_error) + + @endif + +

+ +

+@endsection diff --git a/resources/views/mail/notifications/woo-store-connected.blade.php b/resources/views/mail/notifications/woo-store-connected.blade.php new file mode 100644 index 0000000..76fd99f --- /dev/null +++ b/resources/views/mail/notifications/woo-store-connected.blade.php @@ -0,0 +1,39 @@ +@extends('mail.notifications.layout') + +@section('email-header') + @include('mail.partials.brand-header', ['brand' => 'woo']) +@endsection + +@section('email-footer') + @include('mail.partials.brand-footer', ['brand' => 'woo']) +@endsection + +@section('content') +
+

Store connected

+

{{ $store->site_name ?? parse_url($store->site_url, PHP_URL_HOST) }}

+
+ +

Your WooCommerce store is linked

+ + + + + +

+ +

+@endsection diff --git a/resources/views/notifications/_list.blade.php b/resources/views/notifications/_list.blade.php index 09c6ebb..1bbd46f 100644 --- a/resources/views/notifications/_list.blade.php +++ b/resources/views/notifications/_list.blade.php @@ -26,6 +26,7 @@ 'email' => 'bg-pink-50', 'billing' => 'bg-amber-50', 'success' => 'bg-green-50', + 'order' => 'bg-indigo-50', default => 'bg-slate-100', }; $iconColor = match ($icon) { diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php index 9901476..321d488 100644 --- a/resources/views/notifications/index.blade.php +++ b/resources/views/notifications/index.blade.php @@ -4,7 +4,7 @@ @section('content') @include('notifications._list', [ - 'subtitle' => 'Hosting activity for your account.', - 'emptyMessage' => "You're all caught up. We'll notify you when something happens with your hosting.", + 'subtitle' => 'Orders, stores, and subscription activity for Woo Manager.', + 'emptyMessage' => "You're all caught up. We'll notify you when you receive orders or account updates.", ]) @endsection diff --git a/routes/console.php b/routes/console.php index 13b3071..31ed753 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,3 +9,4 @@ Artisan::command('inspire', function () { })->purpose('Display an inspiring quote'); Schedule::command('woo:pro-renew')->dailyAt('02:50')->withoutOverlapping(); +Schedule::command('woo:pro-expiry-alerts')->dailyAt('08:00')->withoutOverlapping(); diff --git a/tests/Feature/WooNotificationTest.php b/tests/Feature/WooNotificationTest.php new file mode 100644 index 0000000..337e132 --- /dev/null +++ b/tests/Feature/WooNotificationTest.php @@ -0,0 +1,166 @@ +create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'site_name' => 'Example Shop', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + 'plugin_token_hash' => hash('sha256', 'test-token'), + ]); + + $payload = [ + 'id' => 99, + 'number' => '1099', + 'status' => 'processing', + 'currency' => 'GHS', + 'total' => '75.00', + 'billing' => ['first_name' => 'Kofi', 'last_name' => 'Annan', 'email' => 'kofi@example.com'], + 'line_items' => [['name' => 'Cap', 'quantity' => 1, 'total' => '75.00']], + 'needs_payment' => false, + 'date_modified_gmt' => now()->utc()->format('Y-m-d H:i:s'), + ]; + + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $signature = base64_encode(hash_hmac('sha256', $body, (string) $store->webhook_secret, true)); + + $this->call( + 'POST', + route('woo.webhooks.woocommerce', $store->public_id), + [], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_WC_WEBHOOK_SIGNATURE' => $signature, + 'HTTP_X_WC_WEBHOOK_TOPIC' => 'order.created', + ], + $body, + )->assertOk(); + + Notification::assertSentTo($user, WooOrderReceivedNotification::class); + } + + public function test_order_updated_webhook_does_not_send_order_received_notification(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + 'plugin_token_hash' => hash('sha256', 'test-token'), + ]); + + WooOrder::create([ + 'woo_store_id' => $store->id, + 'external_order_id' => 42, + 'order_number' => '1042', + 'line_items' => [], + 'currency' => 'GHS', + 'total_minor' => 5000, + ]); + + $payload = [ + 'id' => 42, + 'number' => '1042', + 'status' => 'completed', + 'currency' => 'GHS', + 'total' => '50.00', + 'billing' => [], + 'line_items' => [], + 'needs_payment' => false, + 'date_modified_gmt' => now()->utc()->format('Y-m-d H:i:s'), + ]; + + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $signature = base64_encode(hash_hmac('sha256', $body, (string) $store->webhook_secret, true)); + + $this->call( + 'POST', + route('woo.webhooks.woocommerce', $store->public_id), + [], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_WC_WEBHOOK_SIGNATURE' => $signature, + 'HTTP_X_WC_WEBHOOK_TOPIC' => 'order.updated', + ], + $body, + )->assertOk(); + + Notification::assertNothingSent(); + } + + public function test_store_activation_sends_store_connected_notification(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'site_name' => 'Example Shop', + 'status' => WooStore::STATUS_PENDING, + ]); + + $token = app(InstallTokenService::class)->issue($store)['token']; + + $this->postJson('/api/v1/stores/activate', [ + 'install_token' => $token, + 'site_url' => 'https://shop.example.com', + ])->assertOk(); + + Notification::assertSentTo($user, WooStoreConnectedNotification::class); + } + + public function test_pro_expiry_alert_sent_once_per_milestone(): void + { + Notification::fake(); + + $user = User::factory()->create(); + $subscription = ProSubscription::create([ + 'user_id' => $user->id, + 'status' => ProSubscription::STATUS_CANCELED, + 'plan' => ProSubscription::PLAN_PRO, + 'price_minor' => 4900, + 'currency' => 'GHS', + 'auto_renew' => false, + 'current_period_end' => now()->addDays(7)->startOfDay(), + ]); + + $service = app(WooProExpiryAlertService::class); + $service->checkSubscription($subscription); + $service->checkSubscription($subscription->fresh()); + + Notification::assertSentTo($user, WooProExpiringNotification::class, 1); + $this->assertSame([7], $subscription->fresh()->metadata['expiry_alerts_sent']); + } +}