MiknasTrading/docs/superpowers/plans/2026-05-19-ultra-message-package.md

77 KiB
Raw Blame History

Ultra Message Package — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build promoseven/ultra-message, a reusable Laravel package that wraps the UltraMSG WhatsApp API, then integrate it into OperationModule with a database-backed Settings UI and per-event notification classes.

Architecture: Three phases. Phase 1 builds the standalone Composer package (separate directory/repo). Phase 2 installs it into OperationModule and wires up the Settings UI + dynamic config. Phase 3 wires individual Laravel Notification classes to ERP events.

Tech Stack: PHP 8.2, Laravel 12, UltraMSG REST API, Orchestra Testbench 10 (package tests), PHPUnit 11, SQLite (OperationModule DB), Tailwind CSS + Alpine.js (Settings UI)


File Map

Phase 1 — Package (new directory: ../ultra-message/ — sibling to OperationModule)

Action Path Responsibility
Create composer.json Package metadata, autoloading, Laravel discovery
Create config/ultra-message.php Default config values
Create src/UltraMessageServiceProvider.php Register client singleton, load config/routes
Create src/UltraMessageClient.php All HTTP calls to UltraMSG API
Create src/UltraMessageMessage.php Fluent DTO for outbound messages
Create src/UltraMessageChannel.php Laravel Notification channel
Create src/UltraMessageFake.php Test double — records sends, no HTTP
Create src/UltraMessageException.php Single exception type for all API errors
Create src/Facades/UltraMessage.php Laravel Facade → UltraMessageClient
Create src/Events/UltraMessageWebhookReceived.php Event fired on incoming webhook
Create src/Http/Controllers/WebhookController.php Handles POST to webhook route
Create routes/webhook.php Registers webhook POST route
Create tests/UltraMessageClientTest.php Unit tests for the client
Create tests/UltraMessageChannelTest.php Unit tests for the channel
Create tests/TestCase.php Orchestra Testbench base test case

Phase 2 — OperationModule Integration

Action Path Responsibility
Modify composer.json Add VCS repository + require package
Create database/migrations/xxxx_create_settings_table.php settings key/value table
Create database/migrations/xxxx_add_whatsapp_number_to_suppliers.php whatsapp_number column
Create database/migrations/xxxx_add_whatsapp_number_to_customers.php whatsapp_number column
Create database/migrations/xxxx_add_whatsapp_number_to_users.php whatsapp_number column
Create app/Models/Setting.php Key/value model with static get/set helpers
Modify app/Models/Supplier.php Add routeNotificationFor('ultra_message')
Modify app/Models/Customer.php Add routeNotificationFor('ultra_message')
Modify app/Models/User.php Add routeNotificationFor('ultra_message')
Modify app/Providers/AppServiceProvider.php Boot dynamic config resolver
Modify bootstrap/app.php Exclude webhook path from CSRF
Create app/Http/Controllers/SettingsController.php Show/update integration settings
Create resources/views/settings/integrations.blade.php WhatsApp settings form
Modify routes/web.php Add settings routes
Modify resources/views/layouts/navigation.blade.php Add Settings sidebar link (Admin only)
Modify resources/views/purchase/suppliers/create.blade.php Add whatsapp_number field
Modify resources/views/purchase/suppliers/edit.blade.php Add whatsapp_number field
Modify resources/views/sales/customers/create.blade.php Add whatsapp_number field
Modify resources/views/sales/customers/edit.blade.php Add whatsapp_number field
Modify app/Http/Controllers/Purchase/SupplierController.php Store/update whatsapp_number
Modify app/Http/Controllers/Sales/CustomerController.php Store/update whatsapp_number

Phase 3 — Notifications

Action Path Responsibility
Create app/Notifications/Purchase/PurchaseOrderConfirmedNotification.php Notify supplier on PO confirm
Create app/Notifications/Purchase/GoodsReceiptConfirmedNotification.php Notify store manager on GRN confirm
Create app/Notifications/Sales/SalesOrderConfirmedNotification.php Notify customer on SO confirm
Create app/Notifications/Sales/InvoiceCreatedNotification.php Notify customer on invoice creation
Create app/Notifications/Sales/DeliveryDispatchedNotification.php Notify customer on dispatch
Create app/Notifications/Inventory/LowStockAlertNotification.php Notify store manager on low stock
Create app/Notifications/Production/ProductionOrderCompletedNotification.php Notify production manager on completion
Modify app/Http/Controllers/Purchase/PurchaseOrderController.php Trigger PO confirmed notification
Modify app/Http/Controllers/Purchase/GoodsReceiptNoteController.php Trigger GRN confirmed notification
Modify app/Http/Controllers/Sales/SalesOrderController.php Trigger SO confirmed notification
Modify app/Http/Controllers/Sales/SalesInvoiceController.php Trigger invoice created notification
Modify app/Http/Controllers/Sales/DeliveryNoteController.php Trigger dispatch notification
Modify app/Http/Controllers/Inventory/StockMovementController.php Trigger low stock check + notification
Modify app/Http/Controllers/Production/ProductionOrderController.php Trigger production complete notification

PHASE 1 — The Package

Work in a new directory: create ultra-message/ as a sibling to OperationModule/ (e.g. C:\Users\IT Department\Desktop\ultra-message\).


Task 1: Package scaffold — composer.json and directory structure

Files:

  • Create: ultra-message/composer.json

  • Create: ultra-message/src/ (empty dir placeholder)

  • Create: ultra-message/config/ultra-message.php

  • Create: ultra-message/routes/webhook.php

  • Create: ultra-message/tests/TestCase.php

  • Step 1: Create the package directory and composer.json

mkdir -p ultra-message/src/Facades ultra-message/src/Events ultra-message/src/Http/Controllers ultra-message/config ultra-message/routes ultra-message/tests

Create ultra-message/composer.json:

{
    "name": "promoseven/ultra-message",
    "description": "Laravel WhatsApp integration via UltraMSG API",
    "type": "library",
    "license": "MIT",
    "require": {
        "php": "^8.2",
        "illuminate/support": "^11.0|^12.0",
        "illuminate/http": "^11.0|^12.0",
        "illuminate/notifications": "^11.0|^12.0",
        "illuminate/routing": "^11.0|^12.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^11.0",
        "orchestra/testbench": "^10.0"
    },
    "autoload": {
        "psr-4": {
            "PromoSeven\\UltraMessage\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "PromoSeven\\UltraMessage\\Tests\\": "tests/"
        }
    },
    "extra": {
        "laravel": {
            "providers": [
                "PromoSeven\\UltraMessage\\UltraMessageServiceProvider"
            ],
            "aliases": {
                "UltraMessage": "PromoSeven\\UltraMessage\\Facades\\UltraMessage"
            }
        }
    },
    "minimum-stability": "stable",
    "prefer-stable": true
}
  • Step 2: Create the default config file

Create ultra-message/config/ultra-message.php:

<?php

return [
    'instance_id'    => env('ULTRAMSG_INSTANCE_ID'),
    'token'          => env('ULTRAMSG_TOKEN'),
    'webhook_secret' => env('ULTRAMSG_WEBHOOK_SECRET', null),
    'webhook_path'   => env('ULTRAMSG_WEBHOOK_PATH', 'ultra-message/webhook'),
    'timeout'        => env('ULTRAMSG_TIMEOUT', 30),
    'enabled'        => env('ULTRAMSG_ENABLED', true),
];
  • Step 3: Create the Orchestra Testbench base test case

Create ultra-message/tests/TestCase.php:

<?php

namespace PromoSeven\UltraMessage\Tests;

use Orchestra\Testbench\TestCase as BaseTestCase;
use PromoSeven\UltraMessage\UltraMessageServiceProvider;

abstract class TestCase extends BaseTestCase
{
    protected function getPackageProviders($app): array
    {
        return [UltraMessageServiceProvider::class];
    }

    protected function getEnvironmentSetUp($app): void
    {
        $app['config']->set('ultra-message.instance_id', 'instance123');
        $app['config']->set('ultra-message.token', 'test-token');
        $app['config']->set('ultra-message.enabled', true);
    }
}
  • Step 4: Run composer install
cd ultra-message && composer install

Expected: Vendor directory created, no errors.

  • Step 5: Commit
cd ultra-message
git init
git add .
git commit -m "feat: scaffold ultra-message package"

Task 2: UltraMessageException

Files:

  • Create: ultra-message/src/UltraMessageException.php

  • Step 1: Create the exception class

Create ultra-message/src/UltraMessageException.php:

<?php

namespace PromoSeven\UltraMessage;

use RuntimeException;

class UltraMessageException extends RuntimeException {}
  • Step 2: Commit
git add src/UltraMessageException.php
git commit -m "feat: add UltraMessageException"

Task 3: UltraMessageClient — core HTTP wrapper

Files:

  • Create: ultra-message/src/UltraMessageClient.php

  • Create: ultra-message/tests/UltraMessageClientTest.php

  • Step 1: Write the failing tests

Create ultra-message/tests/UltraMessageClientTest.php:

<?php

namespace PromoSeven\UltraMessage\Tests;

use Illuminate\Support\Facades\Http;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageException;

class UltraMessageClientTest extends TestCase
{
    private UltraMessageClient $client;

    protected function setUp(): void
    {
        parent::setUp();
        $this->client = new UltraMessageClient([
            'instance_id' => 'instance123',
            'token'       => 'test-token',
            'timeout'     => 30,
            'enabled'     => true,
        ]);
    }

    public function test_send_text_posts_correct_payload(): void
    {
        Http::fake([
            'api.ultramsg.com/*' => Http::response(['sent' => 'true', 'id' => 'msg1'], 200),
        ]);

        $result = $this->client->sendText('+971501234567', 'Hello World');

        Http::assertSent(function ($request) {
            return str_contains($request->url(), 'instance123/messages/chat')
                && $request['to'] === '+971501234567'
                && $request['body'] === 'Hello World'
                && $request['token'] === 'test-token';
        });

        $this->assertEquals(['sent' => 'true', 'id' => 'msg1'], $result);
    }

    public function test_send_text_throws_on_api_error(): void
    {
        Http::fake([
            'api.ultramsg.com/*' => Http::response(['error' => 'invalid token'], 200),
        ]);

        $this->expectException(UltraMessageException::class);
        $this->expectExceptionMessage('invalid token');

        $this->client->sendText('+971501234567', 'Hello');
    }

    public function test_send_text_throws_on_http_failure(): void
    {
        Http::fake([
            'api.ultramsg.com/*' => Http::response([], 500),
        ]);

        $this->expectException(UltraMessageException::class);

        $this->client->sendText('+971501234567', 'Hello');
    }

    public function test_send_returns_early_when_disabled(): void
    {
        Http::fake();

        $client = new UltraMessageClient([
            'instance_id' => 'instance123',
            'token'       => 'test-token',
            'timeout'     => 30,
            'enabled'     => false,
        ]);

        $result = $client->sendText('+971501234567', 'Hello');

        Http::assertNothingSent();
        $this->assertEquals([], $result);
    }

    public function test_send_image_posts_correct_payload(): void
    {
        Http::fake([
            'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
        ]);

        $this->client->sendImage('+971501234567', 'https://example.com/img.jpg', 'Caption');

        Http::assertSent(function ($request) {
            return str_contains($request->url(), 'instance123/messages/image')
                && $request['image'] === 'https://example.com/img.jpg'
                && $request['caption'] === 'Caption';
        });
    }

    public function test_send_document_posts_correct_payload(): void
    {
        Http::fake([
            'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
        ]);

        $this->client->sendDocument('+971501234567', 'https://example.com/file.pdf', 'invoice.pdf', 'Your invoice');

        Http::assertSent(function ($request) {
            return str_contains($request->url(), 'instance123/messages/document')
                && $request['document'] === 'https://example.com/file.pdf'
                && $request['filename'] === 'invoice.pdf'
                && $request['caption'] === 'Your invoice';
        });
    }

    public function test_send_location_posts_correct_payload(): void
    {
        Http::fake([
            'api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200),
        ]);

        $this->client->sendLocation('+971501234567', 25.197197, 55.2721877, 'Dubai, UAE');

        Http::assertSent(function ($request) {
            return str_contains($request->url(), 'instance123/messages/location')
                && $request['lat'] == 25.197197
                && $request['lng'] == 55.2721877
                && $request['address'] === 'Dubai, UAE';
        });
    }
}
  • Step 2: Run tests to confirm they fail
cd ultra-message && ./vendor/bin/phpunit tests/UltraMessageClientTest.php

Expected: ERRORS — UltraMessageClient class not found.

  • Step 3: Implement UltraMessageClient

Create ultra-message/src/UltraMessageClient.php:

<?php

namespace PromoSeven\UltraMessage;

use Illuminate\Support\Facades\Http;

class UltraMessageClient
{
    private const BASE_URL = 'https://api.ultramsg.com';

    private string $instanceId;
    private string $token;
    private int $timeout;
    private bool $enabled;

    public function __construct(array $config)
    {
        $this->instanceId = $config['instance_id'] ?? '';
        $this->token      = $config['token'] ?? '';
        $this->timeout    = $config['timeout'] ?? 30;
        $this->enabled    = $config['enabled'] ?? true;
    }

    protected function post(string $endpoint, array $data): array
    {
        if (!$this->enabled) {
            return [];
        }

        $response = Http::timeout($this->timeout)
            ->asForm()
            ->post(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($data, [
                'token' => $this->token,
            ]));

        if ($response->failed()) {
            throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}");
        }

        $body = $response->json() ?? [];

        if (isset($body['error'])) {
            throw new UltraMessageException($body['error']);
        }

        return $body;
    }

    protected function get(string $endpoint, array $query = []): array
    {
        if (!$this->enabled) {
            return [];
        }

        $response = Http::timeout($this->timeout)
            ->get(self::BASE_URL . "/{$this->instanceId}/{$endpoint}", array_merge($query, [
                'token' => $this->token,
            ]));

        if ($response->failed()) {
            throw new UltraMessageException("UltraMSG HTTP error: {$response->status()}");
        }

        $body = $response->json() ?? [];

        if (isset($body['error'])) {
            throw new UltraMessageException($body['error']);
        }

        return $body;
    }

    public function sendText(string $to, string $message, ?string $replyId = null): array
    {
        $data = ['to' => $to, 'body' => $message];
        if ($replyId !== null) {
            $data['quoted_id'] = $replyId;
        }
        return $this->post('messages/chat', $data);
    }

    public function sendImage(string $to, string $imageUrl, string $caption = ''): array
    {
        return $this->post('messages/image', [
            'to'      => $to,
            'image'   => $imageUrl,
            'caption' => $caption,
        ]);
    }

    public function sendDocument(string $to, string $fileUrl, string $filename, string $caption = ''): array
    {
        return $this->post('messages/document', [
            'to'       => $to,
            'document' => $fileUrl,
            'filename' => $filename,
            'caption'  => $caption,
        ]);
    }

    public function sendAudio(string $to, string $audioUrl): array
    {
        return $this->post('messages/audio', [
            'to'    => $to,
            'audio' => $audioUrl,
        ]);
    }

    public function sendVoice(string $to, string $audioUrl): array
    {
        return $this->post('messages/voice', [
            'to'    => $to,
            'audio' => $audioUrl,
        ]);
    }

    public function sendVideo(string $to, string $videoUrl, string $caption = ''): array
    {
        return $this->post('messages/video', [
            'to'      => $to,
            'video'   => $videoUrl,
            'caption' => $caption,
        ]);
    }

    public function sendSticker(string $to, string $stickerUrl): array
    {
        return $this->post('messages/sticker', [
            'to'      => $to,
            'sticker' => $stickerUrl,
        ]);
    }

    public function sendContact(string $to, string $contactId): array
    {
        return $this->post('messages/contact', [
            'to'      => $to,
            'contact' => $contactId,
        ]);
    }

    public function sendLocation(string $to, float $lat, float $lng, string $address = ''): array
    {
        return $this->post('messages/location', [
            'to'      => $to,
            'lat'     => $lat,
            'lng'     => $lng,
            'address' => $address,
        ]);
    }

    public function sendReaction(string $to, string $messageId, string $emoji): array
    {
        return $this->post('messages/reaction', [
            'to'    => $to,
            'msgId' => $messageId,
            'emoji' => $emoji,
        ]);
    }

    public function deleteMessage(string $messageId): array
    {
        return $this->post('messages/delete', [
            'msgId' => $messageId,
        ]);
    }

    public function getInstanceStatus(): array
    {
        return $this->get('instance/status');
    }

    public function getChats(): array
    {
        return $this->get('chats/');
    }

    public function getContacts(): array
    {
        return $this->get('contacts/');
    }

    public function getGroups(): array
    {
        return $this->get('groups/');
    }
}
  • Step 4: Run tests — confirm they pass
./vendor/bin/phpunit tests/UltraMessageClientTest.php

Expected: 5 tests, 5 assertions, PASS.

  • Step 5: Commit
git add src/UltraMessageClient.php tests/UltraMessageClientTest.php
git commit -m "feat: implement UltraMessageClient with all send methods"

Task 4: UltraMessageMessage DTO

Files:

  • Create: ultra-message/src/UltraMessageMessage.php

  • Step 1: Create the DTO

Create ultra-message/src/UltraMessageMessage.php:

<?php

namespace PromoSeven\UltraMessage;

class UltraMessageMessage
{
    public string $type;
    public string $to = '';
    public array $payload = [];

    private function __construct(string $type, array $payload)
    {
        $this->type    = $type;
        $this->payload = $payload;
    }

    public static function text(string $message, ?string $replyId = null): self
    {
        return new self('text', ['body' => $message, 'quoted_id' => $replyId]);
    }

    public static function image(string $url, string $caption = ''): self
    {
        return new self('image', ['image' => $url, 'caption' => $caption]);
    }

    public static function document(string $url, string $filename, string $caption = ''): self
    {
        return new self('document', ['document' => $url, 'filename' => $filename, 'caption' => $caption]);
    }

    public static function audio(string $url): self
    {
        return new self('audio', ['audio' => $url]);
    }

    public static function voice(string $url): self
    {
        return new self('voice', ['audio' => $url]);
    }

    public static function video(string $url, string $caption = ''): self
    {
        return new self('video', ['video' => $url, 'caption' => $caption]);
    }

    public static function sticker(string $url): self
    {
        return new self('sticker', ['sticker' => $url]);
    }

    public static function contact(string $contactId): self
    {
        return new self('contact', ['contact' => $contactId]);
    }

    public static function location(float $lat, float $lng, string $address = ''): self
    {
        return new self('location', ['lat' => $lat, 'lng' => $lng, 'address' => $address]);
    }

    public function to(string $number): self
    {
        $this->to = $number;
        return $this;
    }
}
  • Step 2: Commit
git add src/UltraMessageMessage.php
git commit -m "feat: add UltraMessageMessage DTO"

Task 5: UltraMessageChannel

Files:

  • Create: ultra-message/src/UltraMessageChannel.php

  • Create: ultra-message/tests/UltraMessageChannelTest.php

  • Step 1: Write the failing tests

Create ultra-message/tests/UltraMessageChannelTest.php:

<?php

namespace PromoSeven\UltraMessage\Tests;

use Illuminate\Support\Facades\Http;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageMessage;

class UltraMessageChannelTest extends TestCase
{
    public function test_channel_calls_send_text_for_text_message(): void
    {
        Http::fake(['api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200)]);

        $client  = new UltraMessageClient([
            'instance_id' => 'instance123',
            'token'       => 'test-token',
            'timeout'     => 30,
            'enabled'     => true,
        ]);
        $channel = new UltraMessageChannel($client);

        $notifiable = new class {
            public string $whatsapp_number = '+971501234567';
            public function routeNotificationFor(string $channel, $notification = null): string
            {
                return $this->whatsapp_number;
            }
        };

        $notification = new class extends Notification {
            public function toUltraMessage($notifiable): UltraMessageMessage
            {
                return UltraMessageMessage::text('Test message');
            }
        };

        $channel->send($notifiable, $notification);

        Http::assertSent(function ($request) {
            return str_contains($request->url(), 'messages/chat')
                && $request['body'] === 'Test message'
                && $request['to'] === '+971501234567';
        });
    }

    public function test_channel_uses_message_to_over_notifiable_route(): void
    {
        Http::fake(['api.ultramsg.com/*' => Http::response(['sent' => 'true'], 200)]);

        $client  = new UltraMessageClient([
            'instance_id' => 'instance123',
            'token'       => 'test-token',
            'timeout'     => 30,
            'enabled'     => true,
        ]);
        $channel = new UltraMessageChannel($client);

        $notifiable = new class {
            public function routeNotificationFor(string $channel, $notification = null): string
            {
                return '+9710000000';
            }
        };

        $notification = new class extends Notification {
            public function toUltraMessage($notifiable): UltraMessageMessage
            {
                return UltraMessageMessage::text('Override test')->to('+971999999');
            }
        };

        $channel->send($notifiable, $notification);

        Http::assertSent(fn($r) => $r['to'] === '+971999999');
    }

    public function test_channel_skips_when_no_recipient(): void
    {
        Http::fake();

        $client  = new UltraMessageClient([
            'instance_id' => 'instance123',
            'token'       => 'test-token',
            'timeout'     => 30,
            'enabled'     => true,
        ]);
        $channel = new UltraMessageChannel($client);

        $notifiable = new class {
            public function routeNotificationFor(string $channel, $notification = null): ?string
            {
                return null;
            }
        };

        $notification = new class extends Notification {
            public function toUltraMessage($notifiable): UltraMessageMessage
            {
                return UltraMessageMessage::text('No recipient');
            }
        };

        $channel->send($notifiable, $notification);

        Http::assertNothingSent();
    }
}
  • Step 2: Run tests — confirm they fail
./vendor/bin/phpunit tests/UltraMessageChannelTest.php

Expected: ERRORS — UltraMessageChannel not found.

  • Step 3: Implement UltraMessageChannel

Create ultra-message/src/UltraMessageChannel.php:

<?php

namespace PromoSeven\UltraMessage;

use Illuminate\Notifications\Notification;

class UltraMessageChannel
{
    public function __construct(private UltraMessageClient $client) {}

    public function send(mixed $notifiable, Notification $notification): void
    {
        if (!method_exists($notification, 'toUltraMessage')) {
            return;
        }

        /** @var UltraMessageMessage $message */
        $message = $notification->toUltraMessage($notifiable);

        $to = $message->to ?: $notifiable->routeNotificationFor('ultra_message', $notification);

        if (!$to) {
            return;
        }

        match ($message->type) {
            'text'     => $this->client->sendText($to, $message->payload['body'], $message->payload['quoted_id'] ?? null),
            'image'    => $this->client->sendImage($to, $message->payload['image'], $message->payload['caption'] ?? ''),
            'document' => $this->client->sendDocument($to, $message->payload['document'], $message->payload['filename'], $message->payload['caption'] ?? ''),
            'audio'    => $this->client->sendAudio($to, $message->payload['audio']),
            'voice'    => $this->client->sendVoice($to, $message->payload['audio']),
            'video'    => $this->client->sendVideo($to, $message->payload['video'], $message->payload['caption'] ?? ''),
            'sticker'  => $this->client->sendSticker($to, $message->payload['sticker']),
            'contact'  => $this->client->sendContact($to, $message->payload['contact']),
            'location' => $this->client->sendLocation($to, $message->payload['lat'], $message->payload['lng'], $message->payload['address'] ?? ''),
            default    => throw new UltraMessageException("Unknown message type: {$message->type}"),
        };
    }
}
  • Step 4: Run tests — confirm they pass
./vendor/bin/phpunit tests/UltraMessageChannelTest.php

Expected: 3 tests, PASS.

  • Step 5: Commit
git add src/UltraMessageChannel.php tests/UltraMessageChannelTest.php
git commit -m "feat: implement UltraMessageChannel for Laravel Notifications"

Task 6: UltraMessageFake (test double)

Files:

  • Create: ultra-message/src/UltraMessageFake.php

  • Step 1: Create the fake

Create ultra-message/src/UltraMessageFake.php:

<?php

namespace PromoSeven\UltraMessage;

use PHPUnit\Framework\Assert;

class UltraMessageFake extends UltraMessageClient
{
    private array $sent = [];

    public function __construct()
    {
        // Skip parent constructor — no HTTP config needed in fake mode
    }

    protected function post(string $endpoint, array $data): array
    {
        $this->sent[] = ['endpoint' => $endpoint, 'data' => $data];
        return ['sent' => 'ok'];
    }

    protected function get(string $endpoint, array $query = []): array
    {
        return [];
    }

    public function assertSent(callable $callback): void
    {
        Assert::assertTrue(
            collect($this->sent)->contains($callback),
            'Expected UltraMessage was not sent.'
        );
    }

    public function assertNotSent(): void
    {
        Assert::assertEmpty($this->sent, 'Unexpected UltraMessage messages were sent.');
    }

    public function assertSentCount(int $count): void
    {
        Assert::assertCount($count, $this->sent, "Expected {$count} messages sent, got " . count($this->sent));
    }

    public function getSent(): array
    {
        return $this->sent;
    }
}
  • Step 2: Commit
git add src/UltraMessageFake.php
git commit -m "feat: add UltraMessageFake for test support"

Task 7: Facade + ServiceProvider

Files:

  • Create: ultra-message/src/Facades/UltraMessage.php

  • Create: ultra-message/src/UltraMessageServiceProvider.php

  • Step 1: Create the Facade

Create ultra-message/src/Facades/UltraMessage.php:

<?php

namespace PromoSeven\UltraMessage\Facades;

use Illuminate\Support\Facades\Facade;
use PromoSeven\UltraMessage\UltraMessageClient;
use PromoSeven\UltraMessage\UltraMessageFake;

/**
 * @method static array sendText(string $to, string $message, ?string $replyId = null)
 * @method static array sendImage(string $to, string $imageUrl, string $caption = '')
 * @method static array sendDocument(string $to, string $fileUrl, string $filename, string $caption = '')
 * @method static array sendAudio(string $to, string $audioUrl)
 * @method static array sendVoice(string $to, string $audioUrl)
 * @method static array sendVideo(string $to, string $videoUrl, string $caption = '')
 * @method static array sendSticker(string $to, string $stickerUrl)
 * @method static array sendContact(string $to, string $contactId)
 * @method static array sendLocation(string $to, float $lat, float $lng, string $address = '')
 * @method static array sendReaction(string $to, string $messageId, string $emoji)
 * @method static array deleteMessage(string $messageId)
 * @method static array getInstanceStatus()
 * @method static array getChats()
 * @method static array getContacts()
 * @method static array getGroups()
 *
 * @see \PromoSeven\UltraMessage\UltraMessageClient
 */
class UltraMessage extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return UltraMessageClient::class;
    }

    public static function fake(): UltraMessageFake
    {
        $fake = new UltraMessageFake();
        static::swap($fake);
        return $fake;
    }

    public static function configUsing(callable $resolver): void
    {
        app()->instance('ultra-message.config-resolver', $resolver);
        app()->forgetInstance(UltraMessageClient::class);
    }
}
  • Step 2: Create the ServiceProvider

Create ultra-message/src/UltraMessageServiceProvider.php:

<?php

namespace PromoSeven\UltraMessage;

use Illuminate\Support\ServiceProvider;

class UltraMessageServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__ . '/../config/ultra-message.php', 'ultra-message');

        $this->app->singleton('ultra-message.config-resolver', fn() => null);

        $this->app->singleton(UltraMessageClient::class, function ($app) {
            $resolver = $app->make('ultra-message.config-resolver');
            $config   = $resolver ? call_user_func($resolver) : config('ultra-message');

            return new UltraMessageClient($config);
        });

        $this->app->singleton(UltraMessageChannel::class, function ($app) {
            return new UltraMessageChannel($app->make(UltraMessageClient::class));
        });
    }

    public function boot(): void
    {
        if ($this->app->runningInConsole()) {
            $this->publishes([
                __DIR__ . '/../config/ultra-message.php' => config_path('ultra-message.php'),
            ], 'ultra-message-config');
        }

        $this->loadRoutesFrom(__DIR__ . '/../routes/webhook.php');
    }
}
  • Step 3: Commit
git add src/Facades/UltraMessage.php src/UltraMessageServiceProvider.php
git commit -m "feat: add Facade and ServiceProvider"

Task 8: Webhook route + controller + event

Files:

  • Create: ultra-message/src/Events/UltraMessageWebhookReceived.php

  • Create: ultra-message/src/Http/Controllers/WebhookController.php

  • Create: ultra-message/routes/webhook.php

  • Step 1: Create the event

Create ultra-message/src/Events/UltraMessageWebhookReceived.php:

<?php

namespace PromoSeven\UltraMessage\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class UltraMessageWebhookReceived
{
    use Dispatchable, SerializesModels;

    public function __construct(public readonly array $payload) {}
}
  • Step 2: Create the WebhookController

Create ultra-message/src/Http/Controllers/WebhookController.php:

<?php

namespace PromoSeven\UltraMessage\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use PromoSeven\UltraMessage\Events\UltraMessageWebhookReceived;

class WebhookController extends Controller
{
    public function handle(Request $request): \Illuminate\Http\Response
    {
        $secret = config('ultra-message.webhook_secret');

        if ($secret) {
            $signature = $request->header('X-Hub-Signature-256', '');
            $expected  = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret);

            if (!hash_equals($expected, $signature)) {
                abort(403, 'Invalid webhook signature.');
            }
        }

        event(new UltraMessageWebhookReceived($request->all()));

        return response('OK', 200);
    }
}
  • Step 3: Create the webhook route file

Create ultra-message/routes/webhook.php:

<?php

use Illuminate\Support\Facades\Route;
use PromoSeven\UltraMessage\Http\Controllers\WebhookController;

Route::post(config('ultra-message.webhook_path', 'ultra-message/webhook'), [WebhookController::class, 'handle'])
    ->name('ultra-message.webhook');
  • Step 4: Run the full test suite
./vendor/bin/phpunit

Expected: All tests pass (8 tests minimum).

  • Step 5: Commit
git add src/Events/ src/Http/ routes/
git commit -m "feat: add webhook route, controller, and event"

Task 9: Push package to GitHub

Do this step when the user provides GitHub access.

  • Step 1: Create the GitHub repository named ultra-message under the Promoseven org

  • Step 2: Push

git remote add origin git@github.com:promoseven/ultra-message.git
git branch -M main
git push -u origin main
  • Step 3: Note the repo URL — needed for OperationModule composer.json.

PHASE 2 — OperationModule Integration

All remaining steps are inside C:\Users\IT Department\Desktop\OperationModule\.


Task 10: Add package to OperationModule via Composer

Files:

  • Modify: composer.json

  • Step 1: Add VCS repository + require entry to composer.json

In composer.json, add inside "repositories" (create the key if missing) and update "require":

{
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/promoseven/ultra-message"
        }
    ],
    "require": {
        "php": "^8.2",
        "barryvdh/laravel-dompdf": "^3.1",
        "laravel/framework": "^12.0",
        "laravel/tinker": "^2.10.1",
        "phpoffice/phpspreadsheet": "^5.7",
        "promoseven/ultra-message": "dev-main",
        "spatie/laravel-permission": "^6.25"
    }
}

Until GitHub is set up: use a local path repository instead:

{
    "repositories": [
        {
            "type": "path",
            "url": "../ultra-message"
        }
    ],
    "require": {
        "promoseven/ultra-message": "*"
    }
}
  • Step 2: Install
composer require promoseven/ultra-message

Expected: Package installed, service provider auto-discovered, no errors.

  • Step 3: Publish config
php artisan vendor:publish --tag=ultra-message-config

Expected: config/ultra-message.php created in OperationModule.

  • Step 4: Commit
git add composer.json composer.lock config/ultra-message.php
git commit -m "feat: install ultra-message package"

Task 11: Settings table migration + Setting model

Files:

  • Create: database/migrations/xxxx_create_settings_table.php

  • Create: app/Models/Setting.php

  • Step 1: Create the migration

php artisan make:migration create_settings_table

Edit the generated file in database/migrations/:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('settings', function (Blueprint $table) {
            $table->id();
            $table->string('key')->unique();
            $table->text('value')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('settings');
    }
};
  • Step 2: Run migration
php artisan migrate

Expected: settings table created, no errors.

  • Step 3: Create the Setting model

Create app/Models/Setting.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Setting extends Model
{
    protected $fillable = ['key', 'value'];

    public static function get(string $key, mixed $default = null): mixed
    {
        $setting = static::where('key', $key)->first();
        return $setting ? $setting->value : $default;
    }

    public static function set(string $key, mixed $value): void
    {
        static::updateOrCreate(['key' => $key], ['value' => $value]);
    }
}
  • Step 4: Commit
git add database/migrations/ app/Models/Setting.php
git commit -m "feat: add settings table and Setting model"

Task 12: Add whatsapp_number to suppliers, customers, and users

Files:

  • Create: 3 migration files

  • Modify: app/Models/Supplier.php

  • Modify: app/Models/Customer.php

  • Modify: app/Models/User.php

  • Step 1: Create the migrations

php artisan make:migration add_whatsapp_number_to_suppliers_table
php artisan make:migration add_whatsapp_number_to_customers_table
php artisan make:migration add_whatsapp_number_to_users_table

For each, edit the generated file:

add_whatsapp_number_to_suppliers_table:

public function up(): void
{
    Schema::table('suppliers', function (Blueprint $table) {
        $table->string('whatsapp_number')->nullable()->after('phone');
    });
}

public function down(): void
{
    Schema::table('suppliers', function (Blueprint $table) {
        $table->dropColumn('whatsapp_number');
    });
}

add_whatsapp_number_to_customers_table:

public function up(): void
{
    Schema::table('customers', function (Blueprint $table) {
        $table->string('whatsapp_number')->nullable()->after('phone');
    });
}

public function down(): void
{
    Schema::table('customers', function (Blueprint $table) {
        $table->dropColumn('whatsapp_number');
    });
}

add_whatsapp_number_to_users_table:

public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('whatsapp_number')->nullable()->after('email');
    });
}

public function down(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->dropColumn('whatsapp_number');
    });
}
  • Step 2: Run migrations
php artisan migrate

Expected: 3 columns added, no errors.

  • Step 3: Add routeNotificationFor to Supplier

Read app/Models/Supplier.php first. Add this method to the class body:

public function routeNotificationFor(string $channel, mixed $notification = null): ?string
{
    return $this->whatsapp_number;
}

Also add 'whatsapp_number' to the $fillable array.

  • Step 4: Add routeNotificationFor to Customer

Read app/Models/Customer.php first. Add to the class body:

public function routeNotificationFor(string $channel, mixed $notification = null): ?string
{
    return $this->whatsapp_number;
}

Also add 'whatsapp_number' to $fillable.

  • Step 5: Add routeNotificationFor to User

Read app/Models/User.php first. Add to the class body:

public function routeNotificationFor(string $channel, mixed $notification = null): ?string
{
    return $this->whatsapp_number;
}

Also add 'whatsapp_number' to $fillable.

  • Step 6: Commit
git add database/migrations/ app/Models/Supplier.php app/Models/Customer.php app/Models/User.php
git commit -m "feat: add whatsapp_number field to suppliers, customers, users"

Task 13: Dynamic config + CSRF exclusion in OperationModule

Files:

  • Modify: app/Providers/AppServiceProvider.php

  • Modify: bootstrap/app.php

  • Step 1: Boot dynamic config resolver in AppServiceProvider

Read app/Providers/AppServiceProvider.php. In the boot() method, add:

use App\Models\Setting;
use PromoSeven\UltraMessage\Facades\UltraMessage;

// Inside boot():
UltraMessage::configUsing(function () {
    return [
        'instance_id'    => Setting::get('ultramsg_instance_id', config('ultra-message.instance_id')),
        'token'          => Setting::get('ultramsg_token', config('ultra-message.token')),
        'webhook_secret' => Setting::get('ultramsg_webhook_secret', config('ultra-message.webhook_secret')),
        'webhook_path'   => Setting::get('ultramsg_webhook_path', config('ultra-message.webhook_path', 'ultra-message/webhook')),
        'timeout'        => config('ultra-message.timeout', 30),
        'enabled'        => (bool) Setting::get('ultramsg_enabled', config('ultra-message.enabled', true)),
    ];
});
  • Step 2: Exclude webhook path from CSRF in bootstrap/app.php

Read bootstrap/app.php. Inside the ->withMiddleware(function (Middleware $middleware) { block, add:

$middleware->validateCsrfTokens(except: [
    config('ultra-message.webhook_path', 'ultra-message/webhook'),
    'ultra-message/*',
]);
  • Step 3: Commit
git add app/Providers/AppServiceProvider.php bootstrap/app.php
git commit -m "feat: wire dynamic config resolver and exclude webhook from CSRF"

Task 14: SettingsController + routes

Files:

  • Create: app/Http/Controllers/SettingsController.php

  • Modify: routes/web.php

  • Step 1: Create SettingsController

Create app/Http/Controllers/SettingsController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Setting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use PromoSeven\UltraMessage\Facades\UltraMessage;
use PromoSeven\UltraMessage\UltraMessageException;

class SettingsController extends Controller
{
    public function integrations(): View
    {
        $settings = [
            'enabled'         => Setting::get('ultramsg_enabled', false),
            'instance_id'     => Setting::get('ultramsg_instance_id', ''),
            'token'           => Setting::get('ultramsg_token', ''),
            'webhook_secret'  => Setting::get('ultramsg_webhook_secret', ''),
            'webhook_path'    => Setting::get('ultramsg_webhook_path', 'ultra-message/webhook'),
        ];

        return view('settings.integrations', compact('settings'));
    }

    public function updateWhatsapp(Request $request): RedirectResponse
    {
        $request->validate([
            'instance_id'    => ['required', 'string', 'max:100'],
            'token'          => ['required', 'string', 'max:255'],
            'webhook_secret' => ['nullable', 'string', 'max:255'],
            'webhook_path'   => ['required', 'string', 'max:100'],
        ]);

        Setting::set('ultramsg_enabled', $request->boolean('enabled') ? '1' : '0');
        Setting::set('ultramsg_instance_id', $request->instance_id);
        Setting::set('ultramsg_token', $request->token);
        Setting::set('ultramsg_webhook_secret', $request->webhook_secret ?? '');
        Setting::set('ultramsg_webhook_path', $request->webhook_path);

        return redirect()->route('settings.integrations')->with('success', 'WhatsApp settings saved.');
    }

    public function testWhatsappConnection(): \Illuminate\Http\JsonResponse
    {
        try {
            $status = UltraMessage::getInstanceStatus();
            return response()->json(['success' => true, 'status' => $status]);
        } catch (UltraMessageException $e) {
            return response()->json(['success' => false, 'message' => $e->getMessage()]);
        }
    }
}
  • Step 2: Add routes to routes/web.php

Read routes/web.php. Inside the auth + verified middleware group, add at the end before the closing brace:

// Settings
Route::middleware('role:Admin')->group(function () {
    Route::get('settings/integrations', [SettingsController::class, 'integrations'])->name('settings.integrations');
    Route::post('settings/integrations/whatsapp', [SettingsController::class, 'updateWhatsapp'])->name('settings.integrations.whatsapp');
    Route::get('settings/integrations/test-whatsapp', [SettingsController::class, 'testWhatsappConnection'])->name('settings.integrations.test-whatsapp');
});

Also add the import at the top of the file:

use App\Http\Controllers\SettingsController;
  • Step 3: Commit
git add app/Http/Controllers/SettingsController.php routes/web.php
git commit -m "feat: add SettingsController and settings routes"

Task 15: Settings integrations view

Files:

  • Create: resources/views/settings/integrations.blade.php

  • Step 1: Create the view directory

mkdir -p resources/views/settings
  • Step 2: Create the view

Create resources/views/settings/integrations.blade.php:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            Settings — Integrations
        </h2>
    </x-slot>

    <div class="py-6 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">

        {{-- WhatsApp / UltraMSG Section --}}
        <div class="bg-white shadow rounded-lg">
            <div class="px-6 py-4 border-b border-gray-200 flex items-center gap-3">
                <svg class="w-6 h-6 text-green-500" fill="currentColor" viewBox="0 0 24 24">
                    <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
                </svg>
                <h3 class="text-lg font-semibold text-gray-900">WhatsApp (UltraMSG)</h3>
            </div>

            <form method="POST" action="{{ route('settings.integrations.whatsapp') }}" class="px-6 py-5 space-y-5">
                @csrf

                {{-- Enable toggle --}}
                <div class="flex items-center justify-between">
                    <div>
                        <p class="text-sm font-medium text-gray-700">Enable WhatsApp Notifications</p>
                        <p class="text-xs text-gray-500">When disabled, no messages will be sent regardless of other settings.</p>
                    </div>
                    <label class="relative inline-flex items-center cursor-pointer">
                        <input type="hidden" name="enabled" value="0">
                        <input type="checkbox" name="enabled" value="1" class="sr-only peer"
                            {{ $settings['enabled'] ? 'checked' : '' }}>
                        <div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-green-400 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-500"></div>
                    </label>
                </div>

                <hr class="border-gray-200">

                {{-- Instance ID --}}
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">Instance ID</label>
                    <input type="text" name="instance_id" value="{{ old('instance_id', $settings['instance_id']) }}"
                        placeholder="e.g. instance123456"
                        class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 @error('instance_id') border-red-400 @enderror">
                    @error('instance_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
                </div>

                {{-- API Token --}}
                <div x-data="{ show: false }">
                    <label class="block text-sm font-medium text-gray-700 mb-1">API Token</label>
                    <div class="relative">
                        <input :type="show ? 'text' : 'password'" name="token"
                            value="{{ old('token', $settings['token']) }}"
                            placeholder="Your UltraMSG token"
                            class="w-full border border-gray-300 rounded-md px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 @error('token') border-red-400 @enderror">
                        <button type="button" @click="show = !show"
                            class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600">
                            <svg x-show="!show" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
                            </svg>
                            <svg x-show="show" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21"/>
                            </svg>
                        </button>
                    </div>
                    @error('token') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
                </div>

                {{-- Webhook Secret --}}
                <div x-data="{ show: false }">
                    <label class="block text-sm font-medium text-gray-700 mb-1">Webhook Secret <span class="text-gray-400 font-normal">(optional)</span></label>
                    <div class="relative">
                        <input :type="show ? 'text' : 'password'" name="webhook_secret"
                            value="{{ old('webhook_secret', $settings['webhook_secret']) }}"
                            placeholder="Leave empty to skip HMAC verification"
                            class="w-full border border-gray-300 rounded-md px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
                        <button type="button" @click="show = !show"
                            class="absolute inset-y-0 right-0 px-3 flex items-center text-gray-400 hover:text-gray-600">
                            <svg x-show="!show" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
                            </svg>
                            <svg x-show="show" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21"/>
                            </svg>
                        </button>
                    </div>
                </div>

                {{-- Webhook Path --}}
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">Webhook Path</label>
                    <div class="flex items-center gap-0">
                        <span class="inline-flex items-center px-3 py-2 text-sm text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-md">
                            {{ url('/') }}/
                        </span>
                        <input type="text" name="webhook_path"
                            value="{{ old('webhook_path', $settings['webhook_path']) }}"
                            class="flex-1 border border-gray-300 rounded-r-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 @error('webhook_path') border-red-400 @enderror">
                    </div>
                    <p class="text-xs text-gray-500 mt-1">
                        Full URL: <strong>{{ url('/') }}/{{ $settings['webhook_path'] }}</strong> — paste this in your UltraMSG dashboard.
                    </p>
                    @error('webhook_path') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
                </div>

                {{-- Actions --}}
                <div class="flex items-center justify-between pt-2">
                    <button type="button" id="btn-test-connection"
                        class="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 underline underline-offset-2">
                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
                        </svg>
                        Test Connection
                    </button>
                    <div id="connection-status" class="text-sm hidden"></div>
                    <button type="submit"
                        class="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-md">
                        Save Settings
                    </button>
                </div>

            </form>
        </div>

    </div>

    <script>
    document.getElementById('btn-test-connection').addEventListener('click', function () {
        var statusEl = document.getElementById('connection-status');
        statusEl.textContent = 'Testing…';
        statusEl.className = 'text-sm text-gray-500';
        statusEl.classList.remove('hidden');

        fetch('{{ route('settings.integrations.test-whatsapp') }}', {
            headers: { 'X-Requested-With': 'XMLHttpRequest' }
        })
        .then(function (r) { return r.json(); })
        .then(function (data) {
            if (data.success) {
                statusEl.textContent = 'Connected';
                statusEl.className = 'text-sm text-green-600 font-medium';
            } else {
                statusEl.textContent = 'Failed: ' + data.message;
                statusEl.className = 'text-sm text-red-600 font-medium';
            }
        })
        .catch(function () {
            statusEl.textContent = 'Request failed.';
            statusEl.className = 'text-sm text-red-600 font-medium';
        });
    });
    </script>
</x-app-layout>
  • Step 3: Commit
git add resources/views/settings/
git commit -m "feat: add WhatsApp integration settings view"

Task 16: Add Settings to sidebar (Admin only)

Files:

  • Modify: resources/views/layouts/navigation.blade.php

  • Step 1: Read the current navigation file

Read resources/views/layouts/navigation.blade.php and find where sidebar navigation links are listed.

  • Step 2: Add Settings link visible to Admin role only

Find the last navigation <a> or nav item group in the sidebar and add after it:

@role('Admin')
<a href="{{ route('settings.integrations') }}"
   class="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium {{ request()->routeIs('settings.*') ? 'bg-gray-900 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white' }}">
    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
              d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
    </svg>
    Settings
</a>
@endrole
  • Step 3: Commit
git add resources/views/layouts/navigation.blade.php
git commit -m "feat: add Settings sidebar link for Admin role"

Task 17: Add whatsapp_number field to Supplier and Customer forms

Files:

  • Modify: resources/views/purchase/suppliers/create.blade.php

  • Modify: resources/views/purchase/suppliers/edit.blade.php

  • Modify: resources/views/sales/customers/create.blade.php

  • Modify: resources/views/sales/customers/edit.blade.php

  • Modify: app/Http/Controllers/Purchase/SupplierController.php

  • Modify: app/Http/Controllers/Sales/CustomerController.php

  • Step 1: Read both supplier form views

Read resources/views/purchase/suppliers/create.blade.php and edit.blade.php. Find where the phone field is rendered. After the phone field, add:

<div>
    <label class="block text-sm font-medium text-gray-700 mb-1">WhatsApp Number</label>
    <input type="text" name="whatsapp_number"
        value="{{ old('whatsapp_number', $supplier->whatsapp_number ?? '') }}"
        placeholder="+971501234567"
        class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
    <p class="text-xs text-gray-500 mt-1">International format. Used for WhatsApp notifications.</p>
</div>

For create.blade.php, use old('whatsapp_number', '').

  • Step 2: Read both customer form views

Same treatment for resources/views/sales/customers/create.blade.php and edit.blade.php — add after the phone field.

  • Step 3: Update SupplierController store/update

Read app/Http/Controllers/Purchase/SupplierController.php. In store() and update() methods, add 'whatsapp_number' to the validated fields and the $supplier->fill() / Supplier::create() call. Add to the validation rules:

'whatsapp_number' => ['nullable', 'string', 'max:20'],
  • Step 4: Update CustomerController store/update

Read app/Http/Controllers/Sales/CustomerController.php. Same as above — add whatsapp_number to validation and assignment.

  • Step 5: Commit
git add resources/views/purchase/suppliers/ resources/views/sales/customers/ \
        app/Http/Controllers/Purchase/SupplierController.php \
        app/Http/Controllers/Sales/CustomerController.php
git commit -m "feat: add whatsapp_number field to supplier and customer forms"

PHASE 3 — Notifications

All files in C:\Users\IT Department\Desktop\OperationModule\.


Task 18: PurchaseOrderConfirmedNotification

Files:

  • Create: app/Notifications/Purchase/PurchaseOrderConfirmedNotification.php

  • Modify: app/Http/Controllers/Purchase/PurchaseOrderController.php

  • Step 1: Create the notification

mkdir -p app/Notifications/Purchase

Create app/Notifications/Purchase/PurchaseOrderConfirmedNotification.php:

<?php

namespace App\Notifications\Purchase;

use App\Models\PurchaseOrder;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageMessage;

class PurchaseOrderConfirmedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private PurchaseOrder $order) {}

    public function via(mixed $notifiable): array
    {
        return [UltraMessageChannel::class];
    }

    public function toUltraMessage(mixed $notifiable): UltraMessageMessage
    {
        return UltraMessageMessage::text(
            "Dear {$notifiable->name},\n\nPurchase Order *#{$this->order->number}* has been confirmed.\n\nTotal: {$this->order->currency} {$this->order->total}\nExpected delivery: {$this->order->expected_date}\n\nThank you."
        );
    }
}
  • Step 2: Trigger notification in PurchaseOrderController

Read app/Http/Controllers/Purchase/PurchaseOrderController.php. Find the method that confirms/stores a PO (likely store() or a confirm() action). After the PO is saved/confirmed, add:

use App\Notifications\Purchase\PurchaseOrderConfirmedNotification;
use Illuminate\Support\Facades\Notification;

// After PO confirmed:
if ($order->supplier && $order->supplier->whatsapp_number) {
    $order->supplier->notify(new PurchaseOrderConfirmedNotification($order));
}
  • Step 3: Commit
git add app/Notifications/Purchase/PurchaseOrderConfirmedNotification.php \
        app/Http/Controllers/Purchase/PurchaseOrderController.php
git commit -m "feat: send WhatsApp notification on PO confirmed"

Task 19: GoodsReceiptConfirmedNotification

Files:

  • Create: app/Notifications/Purchase/GoodsReceiptConfirmedNotification.php

  • Modify: app/Http/Controllers/Purchase/GoodsReceiptNoteController.php

  • Step 1: Create the notification

Create app/Notifications/Purchase/GoodsReceiptConfirmedNotification.php:

<?php

namespace App\Notifications\Purchase;

use App\Models\GoodsReceiptNote;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageMessage;

class GoodsReceiptConfirmedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private GoodsReceiptNote $grn) {}

    public function via(mixed $notifiable): array
    {
        return [UltraMessageChannel::class];
    }

    public function toUltraMessage(mixed $notifiable): UltraMessageMessage
    {
        return UltraMessageMessage::text(
            "GRN *#{$this->grn->number}* has been confirmed and goods received.\n\nPO Reference: {$this->grn->purchaseOrder->number}\nDate: {$this->grn->date}"
        );
    }
}
  • Step 2: Trigger in GoodsReceiptNoteController confirm method

Read app/Http/Controllers/Purchase/GoodsReceiptNoteController.php. In the confirm() method, after confirming the GRN, add:

use App\Notifications\Purchase\GoodsReceiptConfirmedNotification;

// Notify the store manager(s)
$storeManagers = \App\Models\User::role('Store Manager')->whereNotNull('whatsapp_number')->get();
\Illuminate\Support\Facades\Notification::send($storeManagers, new GoodsReceiptConfirmedNotification($grn));
  • Step 3: Commit
git add app/Notifications/Purchase/GoodsReceiptConfirmedNotification.php \
        app/Http/Controllers/Purchase/GoodsReceiptNoteController.php
git commit -m "feat: send WhatsApp notification on GRN confirmed"

Task 20: SalesOrderConfirmedNotification

Files:

  • Create: app/Notifications/Sales/SalesOrderConfirmedNotification.php

  • Modify: app/Http/Controllers/Sales/SalesOrderController.php

  • Step 1: Create the notification

mkdir -p app/Notifications/Sales

Create app/Notifications/Sales/SalesOrderConfirmedNotification.php:

<?php

namespace App\Notifications\Sales;

use App\Models\SalesOrder;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageMessage;

class SalesOrderConfirmedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private SalesOrder $order) {}

    public function via(mixed $notifiable): array
    {
        return [UltraMessageChannel::class];
    }

    public function toUltraMessage(mixed $notifiable): UltraMessageMessage
    {
        return UltraMessageMessage::text(
            "Dear {$notifiable->name},\n\nYour order *#{$this->order->number}* has been confirmed.\n\nTotal: {$this->order->currency} {$this->order->total}\n\nWe will keep you updated on the delivery status. Thank you for your business."
        );
    }
}
  • Step 2: Trigger in SalesOrderController confirm method

Read app/Http/Controllers/Sales/SalesOrderController.php. In the confirm() method, after confirmation, add:

use App\Notifications\Sales\SalesOrderConfirmedNotification;

if ($order->customer && $order->customer->whatsapp_number) {
    $order->customer->notify(new SalesOrderConfirmedNotification($order));
}
  • Step 3: Commit
git add app/Notifications/Sales/SalesOrderConfirmedNotification.php \
        app/Http/Controllers/Sales/SalesOrderController.php
git commit -m "feat: send WhatsApp notification on sales order confirmed"

Task 21: InvoiceCreatedNotification

Files:

  • Create: app/Notifications/Sales/InvoiceCreatedNotification.php

  • Modify: app/Http/Controllers/Sales/SalesInvoiceController.php

  • Step 1: Create the notification

Create app/Notifications/Sales/InvoiceCreatedNotification.php:

<?php

namespace App\Notifications\Sales;

use App\Models\SalesInvoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageMessage;

class InvoiceCreatedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private SalesInvoice $invoice) {}

    public function via(mixed $notifiable): array
    {
        return [UltraMessageChannel::class];
    }

    public function toUltraMessage(mixed $notifiable): UltraMessageMessage
    {
        return UltraMessageMessage::text(
            "Dear {$notifiable->name},\n\nInvoice *#{$this->invoice->number}* is ready.\n\nAmount Due: {$this->invoice->currency} {$this->invoice->total}\nDue Date: {$this->invoice->due_date}\n\nPlease arrange payment at your earliest convenience. Thank you."
        );
    }
}
  • Step 2: Trigger in SalesInvoiceController store method

Read app/Http/Controllers/Sales/SalesInvoiceController.php. In store(), after the invoice is saved, add:

use App\Notifications\Sales\InvoiceCreatedNotification;

if ($invoice->salesOrder?->customer && $invoice->salesOrder->customer->whatsapp_number) {
    $invoice->salesOrder->customer->notify(new InvoiceCreatedNotification($invoice));
}
  • Step 3: Commit
git add app/Notifications/Sales/InvoiceCreatedNotification.php \
        app/Http/Controllers/Sales/SalesInvoiceController.php
git commit -m "feat: send WhatsApp notification on sales invoice created"

Task 22: DeliveryDispatchedNotification

Files:

  • Create: app/Notifications/Sales/DeliveryDispatchedNotification.php

  • Modify: app/Http/Controllers/Sales/DeliveryNoteController.php

  • Step 1: Create the notification

Create app/Notifications/Sales/DeliveryDispatchedNotification.php:

<?php

namespace App\Notifications\Sales;

use App\Models\DeliveryNote;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageMessage;

class DeliveryDispatchedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private DeliveryNote $delivery) {}

    public function via(mixed $notifiable): array
    {
        return [UltraMessageChannel::class];
    }

    public function toUltraMessage(mixed $notifiable): UltraMessageMessage
    {
        return UltraMessageMessage::text(
            "Dear {$notifiable->name},\n\nYour delivery *#{$this->delivery->number}* has been dispatched and is on its way.\n\nOrder Reference: {$this->delivery->salesOrder->number}\nDispatch Date: {$this->delivery->dispatch_date}\n\nThank you for your business."
        );
    }
}
  • Step 2: Trigger in DeliveryNoteController dispatch method

Read app/Http/Controllers/Sales/DeliveryNoteController.php. In the dispatch() method, after dispatch, add:

use App\Notifications\Sales\DeliveryDispatchedNotification;

if ($delivery->salesOrder?->customer && $delivery->salesOrder->customer->whatsapp_number) {
    $delivery->salesOrder->customer->notify(new DeliveryDispatchedNotification($delivery));
}
  • Step 3: Commit
git add app/Notifications/Sales/DeliveryDispatchedNotification.php \
        app/Http/Controllers/Sales/DeliveryNoteController.php
git commit -m "feat: send WhatsApp notification on delivery dispatched"

Task 23: LowStockAlertNotification

Files:

  • Create: app/Notifications/Inventory/LowStockAlertNotification.php

  • Modify: app/Http/Controllers/Inventory/StockMovementController.php

  • Step 1: Create the notification

mkdir -p app/Notifications/Inventory

Create app/Notifications/Inventory/LowStockAlertNotification.php:

<?php

namespace App\Notifications\Inventory;

use App\Models\Item;
use App\Models\StockLevel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageMessage;

class LowStockAlertNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private Item $item, private StockLevel $stockLevel) {}

    public function via(mixed $notifiable): array
    {
        return [UltraMessageChannel::class];
    }

    public function toUltraMessage(mixed $notifiable): UltraMessageMessage
    {
        return UltraMessageMessage::text(
            "⚠️ *Low Stock Alert*\n\nItem: *{$this->item->name}* ({$this->item->code})\nCurrent Stock: {$this->stockLevel->quantity} {$this->item->unit}\nReorder Level: {$this->item->reorder_level} {$this->item->unit}\n\nPlease raise a purchase request."
        );
    }
}
  • Step 2: Trigger in StockMovementController after stock is reduced

Read app/Http/Controllers/Inventory/StockMovementController.php. In the store() method, after the stock movement is recorded, add a check:

use App\Notifications\Inventory\LowStockAlertNotification;

// After stock movement saved — check if item is now below reorder level
$stockLevel = \App\Models\StockLevel::where('item_id', $movement->item_id)
    ->where('warehouse_id', $movement->warehouse_id)
    ->first();

if ($stockLevel && $movement->item->reorder_level && $stockLevel->quantity <= $movement->item->reorder_level) {
    $storeManagers = \App\Models\User::role('Store Manager')->whereNotNull('whatsapp_number')->get();
    \Illuminate\Support\Facades\Notification::send(
        $storeManagers,
        new LowStockAlertNotification($movement->item, $stockLevel)
    );
}
  • Step 3: Commit
git add app/Notifications/Inventory/LowStockAlertNotification.php \
        app/Http/Controllers/Inventory/StockMovementController.php
git commit -m "feat: send WhatsApp low stock alert to store managers"

Task 24: ProductionOrderCompletedNotification

Files:

  • Create: app/Notifications/Production/ProductionOrderCompletedNotification.php

  • Modify: app/Http/Controllers/Production/ProductionOrderController.php

  • Step 1: Create the notification

mkdir -p app/Notifications/Production

Create app/Notifications/Production/ProductionOrderCompletedNotification.php:

<?php

namespace App\Notifications\Production;

use App\Models\ProductionOrder;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use PromoSeven\UltraMessage\UltraMessageChannel;
use PromoSeven\UltraMessage\UltraMessageMessage;

class ProductionOrderCompletedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private ProductionOrder $order) {}

    public function via(mixed $notifiable): array
    {
        return [UltraMessageChannel::class];
    }

    public function toUltraMessage(mixed $notifiable): UltraMessageMessage
    {
        return UltraMessageMessage::text(
            "✅ *Production Complete*\n\nProduction Order *#{$this->order->number}* has been completed.\n\nProduct: {$this->order->product->name}\nQuantity: {$this->order->quantity}\nCompleted: " . now()->format('d M Y')
        );
    }
}
  • Step 2: Trigger in ProductionOrderController complete method

Read app/Http/Controllers/Production/ProductionOrderController.php. In the complete() method, after completion, add:

use App\Notifications\Production\ProductionOrderCompletedNotification;

$productionManagers = \App\Models\User::role('Production Manager')->whereNotNull('whatsapp_number')->get();
\Illuminate\Support\Facades\Notification::send(
    $productionManagers,
    new ProductionOrderCompletedNotification($order)
);
  • Step 3: Commit
git add app/Notifications/Production/ProductionOrderCompletedNotification.php \
        app/Http/Controllers/Production/ProductionOrderController.php
git commit -m "feat: send WhatsApp notification on production order completed"

Self-Review Checklist

Spec coverage:

  • Package structure (Tasks 18)
  • Config + dynamic config resolver (Tasks 7, 13)
  • UltraMessageClient — all 16 send/info methods (Task 3)
  • UltraMessageMessage DTO — all types (Task 4)
  • UltraMessageChannel (Task 5)
  • UltraMessageFake + assertSent/assertNotSent/assertSentCount (Task 6)
  • Facade with fake() and configUsing() (Task 7)
  • Webhook route + HMAC verification + event fire (Task 8)
  • GitHub push (Task 9)
  • Settings table + Setting model (Task 11)
  • whatsapp_number on Supplier/Customer/User + routeNotificationFor (Task 12)
  • CSRF exclusion for webhook (Task 13)
  • SettingsController with test connection (Task 14)
  • Settings UI view with masked fields + test button (Task 15)
  • Sidebar link (Admin only) (Task 16)
  • WhatsApp field on Supplier + Customer forms (Task 17)
  • All 7 notification classes (Tasks 1824)
  • All 7 notification triggers in controllers (Tasks 1824)

No placeholders found.

Type consistency: UltraMessageMessage::text/image/document/... constructors defined in Task 4, used identically in Tasks 5 and 1824. UltraMessageChannel + UltraMessageClient method names match throughout.