Compare commits
11 Commits
dca9cd5d99
...
30b2fb3958
| Author | SHA1 | Date | |
|---|---|---|---|
| 30b2fb3958 | |||
| 50e5f3e381 | |||
| d28e7330e6 | |||
| 716f0afcac | |||
| 6061e8ca4f | |||
| 7b399d5167 | |||
| ee89269c0b | |||
| a9db089a90 | |||
| 04ced4d2da | |||
| b19396c996 | |||
| 3252cf7fb8 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -19,6 +19,8 @@
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/packages/azure-mailer/vendor
|
||||
/packages/ultra-message/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\RfqInvitation;
|
||||
use App\Models\Setting;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Models\SupplierQuoteItem;
|
||||
use App\Models\User;
|
||||
@ -46,7 +47,9 @@ class RfqPortalController extends Controller
|
||||
$confirmCode = strtoupper(substr(bin2hex(random_bytes(3)), 0, 5));
|
||||
session(['rfq_confirm_' . $token => $confirmCode]);
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode'));
|
||||
$vatRate = (float) Setting::get('vat_rate', 0);
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode', 'vatRate'));
|
||||
}
|
||||
|
||||
public function submit(Request $request, string $token)
|
||||
@ -58,13 +61,16 @@ class RfqPortalController extends Controller
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'terms' => ['accepted'],
|
||||
'confirm_code' => ['required', 'string'],
|
||||
'lead_time_days' => ['nullable', 'integer', 'min:0'],
|
||||
'payment_terms' => ['nullable', 'string', 'max:200'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'items' => ['required', 'array'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
|
||||
'terms' => ['accepted'],
|
||||
'confirm_code' => ['required', 'string'],
|
||||
'lead_time_days' => ['nullable', 'integer', 'min:0'],
|
||||
'payment_terms' => ['nullable', 'string', 'max:200'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'items' => ['required', 'array'],
|
||||
'items.*.unit_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'items.*.is_vatable' => ['nullable', 'boolean'],
|
||||
'items.*.not_available' => ['nullable', 'boolean'],
|
||||
'items.*.supplier_description' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$expectedCode = session('rfq_confirm_' . $token);
|
||||
@ -89,24 +95,40 @@ class RfqPortalController extends Controller
|
||||
'total_amount' => 0,
|
||||
]);
|
||||
|
||||
$total = 0;
|
||||
$subtotal = 0;
|
||||
$vatAmount = 0;
|
||||
$vatRate = (float) Setting::get('vat_rate', 0);
|
||||
|
||||
foreach ($purchaseItems as $i => $item) {
|
||||
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
|
||||
$qty = (float)$item->quantity_required;
|
||||
$totalPrice = round($unitPrice * $qty, 3);
|
||||
$total += $totalPrice;
|
||||
$notAvailable = !empty($validated['items'][$i]['not_available']);
|
||||
$unitPrice = $notAvailable ? 0 : (float)($validated['items'][$i]['unit_price'] ?? 0);
|
||||
$qty = (float)$item->quantity_required;
|
||||
$totalPrice = $notAvailable ? 0 : round($unitPrice * $qty, 3);
|
||||
$isVatable = !$notAvailable && !empty($validated['items'][$i]['is_vatable']);
|
||||
$supplierDescription = !empty($validated['items'][$i]['supplier_description'])
|
||||
? trim($validated['items'][$i]['supplier_description'])
|
||||
: null;
|
||||
|
||||
$subtotal += $totalPrice;
|
||||
|
||||
if ($isVatable && $vatRate > 0) {
|
||||
$vatAmount += round($totalPrice * $vatRate / 100, 3);
|
||||
}
|
||||
|
||||
SupplierQuoteItem::create([
|
||||
'supplier_quote_id' => $quote->id,
|
||||
'description' => $item->description,
|
||||
'unit' => $item->unit ?? '',
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_price' => $totalPrice,
|
||||
'supplier_quote_id' => $quote->id,
|
||||
'description' => $item->description,
|
||||
'supplier_description'=> $supplierDescription,
|
||||
'unit' => $item->unit ?? '',
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_price' => $totalPrice,
|
||||
'is_vatable' => $isVatable,
|
||||
'not_available' => $notAvailable,
|
||||
]);
|
||||
}
|
||||
|
||||
$quote->update(['total_amount' => round($total, 3)]);
|
||||
$quote->update(['total_amount' => round($subtotal + $vatAmount, 3)]);
|
||||
$invitation->update(['status' => 'submitted']);
|
||||
|
||||
// If at least 1 quote is in, move to comparison stage
|
||||
|
||||
27
app/Http/Controllers/Settings/VatSettingController.php
Normal file
27
app/Http/Controllers/Settings/VatSettingController.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VatSettingController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$vatRate = Setting::get('vat_rate', '0');
|
||||
return view('settings.vat', compact('vatRate'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'vat_rate' => ['required', 'numeric', 'min:0', 'max:100'],
|
||||
]);
|
||||
|
||||
Setting::set('vat_rate', (string) $validated['vat_rate']);
|
||||
|
||||
return response()->json(['message' => 'VAT rate saved.', 'vat_rate' => $validated['vat_rate']]);
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,13 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class SupplierQuoteItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price',
|
||||
'supplier_quote_id', 'description', 'supplier_description', 'unit', 'quantity',
|
||||
'unit_price', 'total_price', 'is_vatable', 'not_available',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_vatable' => 'boolean',
|
||||
'not_available' => 'boolean',
|
||||
];
|
||||
|
||||
public function quote()
|
||||
|
||||
@ -8,11 +8,11 @@
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../ultra-message"
|
||||
"url": "./packages/ultra-message"
|
||||
},
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../azure-mailer"
|
||||
"url": "./packages/azure-mailer"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
|
||||
8
composer.lock
generated
8
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5daed94f08a2aff0bba86affd37a0cd6",
|
||||
"content-hash": "644cad003bc58ba3ea106336e06e0396",
|
||||
"packages": [
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
@ -3283,8 +3283,8 @@
|
||||
"version": "dev-master",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "../azure-mailer",
|
||||
"reference": "d240f4f64afcd094b30cd460b82f809f3b2003f9"
|
||||
"url": "./packages/azure-mailer",
|
||||
"reference": "b6d04a3e3a01a8439853efe8f52f672ae8a04b75"
|
||||
},
|
||||
"require": {
|
||||
"illuminate/cache": "^11.0|^12.0",
|
||||
@ -3334,7 +3334,7 @@
|
||||
"version": "dev-master",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "../ultra-message",
|
||||
"url": "./packages/ultra-message",
|
||||
"reference": "56160235808f7d9d9dd3ec8d0eefa20a17807647"
|
||||
},
|
||||
"require": {
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('supplier_quote_items', function (Blueprint $table) {
|
||||
$table->boolean('is_vatable')->default(false)->after('total_price');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('supplier_quote_items', function (Blueprint $table) {
|
||||
$table->dropColumn('is_vatable');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('supplier_quote_items', function (Blueprint $table) {
|
||||
$table->string('supplier_description')->nullable()->after('description');
|
||||
$table->boolean('not_available')->default(false)->after('is_vatable');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('supplier_quote_items', function (Blueprint $table) {
|
||||
$table->dropColumn(['supplier_description', 'not_available']);
|
||||
});
|
||||
}
|
||||
};
|
||||
440
docs/superpowers/plans/2026-06-01-vat-rfq.md
Normal file
440
docs/superpowers/plans/2026-06-01-vat-rfq.md
Normal file
@ -0,0 +1,440 @@
|
||||
# VAT Settings + RFQ Per-Item Vatable Checkbox — 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:** Add a global VAT rate setting and per-item vatable checkboxes on the supplier RFQ portal, with live VAT breakdown in totals.
|
||||
|
||||
**Architecture:** Global VAT rate stored in existing `settings` key/value table. Per-item `is_vatable` flag added to `supplier_quote_items`. RFQ portal view updated with checkbox column and three-row footer (subtotal / VAT / grand total). New `VatSettingController` + `settings/vat` page added under the Admin-only System sidebar section.
|
||||
|
||||
**Tech Stack:** Laravel 12, PHP 8.2, SQLite, Blade, Alpine-free vanilla JS, Tailwind (inline styles per project convention)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Migration — add `is_vatable` to `supplier_quote_items`
|
||||
|
||||
**Files:**
|
||||
- Modify: `database/migrations/2026_06_01_090734_add_is_vatable_to_supplier_quote_items.php`
|
||||
- Modify: `app/Models/SupplierQuoteItem.php`
|
||||
|
||||
- [ ] **Step 1: Write the migration**
|
||||
|
||||
Replace the generated migration body with:
|
||||
|
||||
```php
|
||||
<?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::table('supplier_quote_items', function (Blueprint $table) {
|
||||
$table->boolean('is_vatable')->default(false)->after('total_price');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('supplier_quote_items', function (Blueprint $table) {
|
||||
$table->dropColumn('is_vatable');
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run migration**
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
Expected: `Migrating: 2026_06_01_090734_add_is_vatable_to_supplier_quote_items` then `Migrated`.
|
||||
|
||||
- [ ] **Step 3: Update SupplierQuoteItem model**
|
||||
|
||||
In `app/Models/SupplierQuoteItem.php`, update `$fillable`:
|
||||
|
||||
```php
|
||||
protected $fillable = [
|
||||
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price', 'is_vatable',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_vatable' => 'boolean',
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add database/migrations/2026_06_01_090734_add_is_vatable_to_supplier_quote_items.php app/Models/SupplierQuoteItem.php
|
||||
git commit -m "feat: add is_vatable column to supplier_quote_items"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: VatSettingController + route + view
|
||||
|
||||
**Files:**
|
||||
- Create: `app/Http/Controllers/Settings/VatSettingController.php`
|
||||
- Create: `resources/views/settings/vat.blade.php`
|
||||
- Modify: `routes/web.php`
|
||||
|
||||
- [ ] **Step 1: Create VatSettingController**
|
||||
|
||||
Create `app/Http/Controllers/Settings/VatSettingController.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VatSettingController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$vatRate = Setting::get('vat_rate', '0');
|
||||
return view('settings.vat', compact('vatRate'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'vat_rate' => ['required', 'numeric', 'min:0', 'max:100'],
|
||||
]);
|
||||
|
||||
Setting::set('vat_rate', (string) $validated['vat_rate']);
|
||||
|
||||
return response()->json(['message' => 'VAT rate saved.', 'vat_rate' => $validated['vat_rate']]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add routes**
|
||||
|
||||
In `routes/web.php`, after the integrations routes (around line 148), add:
|
||||
|
||||
```php
|
||||
// VAT settings
|
||||
Route::get('settings/vat', [VatSettingController::class, 'index'])->name('settings.vat');
|
||||
Route::post('settings/vat', [VatSettingController::class, 'update'])->name('settings.vat.update');
|
||||
```
|
||||
|
||||
Also add the import at the top of the file with the other Settings controllers:
|
||||
```php
|
||||
use App\Http\Controllers\Settings\VatSettingController;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the VAT settings view**
|
||||
|
||||
Create `resources/views/settings/vat.blade.php`:
|
||||
|
||||
```blade
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Settings — VAT')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-5">
|
||||
<h1 class="page-title">VAT Settings</h1>
|
||||
<p class="page-subtitle">Set the global VAT rate applied to vatable items on supplier quotes.</p>
|
||||
</div>
|
||||
|
||||
<div style="max-width:480px;">
|
||||
<div style="background:white;border:1px solid #e2e8f0;border-radius:0.875rem;overflow:hidden;">
|
||||
<div style="padding:1.25rem 1.5rem;border-bottom:1px solid #e2e8f0;background:#f8fafc;">
|
||||
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;">VAT Configuration</div>
|
||||
</div>
|
||||
<div style="padding:1.5rem;">
|
||||
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:6px;">
|
||||
VAT Rate (%)
|
||||
</label>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<input type="number" id="vat-rate-input" value="{{ $vatRate }}"
|
||||
min="0" max="100" step="0.01" placeholder="e.g. 10"
|
||||
style="width:160px;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;font-weight:600;outline:none;"
|
||||
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
|
||||
<span style="font-size:14px;color:#64748b;font-weight:500;">%</span>
|
||||
</div>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:8px;">
|
||||
Enter 0 to disable VAT. Suppliers will see the VAT checkbox on their quote form when this is greater than 0.
|
||||
</p>
|
||||
<button onclick="saveVat()"
|
||||
style="margin-top:20px;padding:10px 24px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">
|
||||
Save VAT Rate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
||||
function saveVat() {
|
||||
var rate = document.getElementById('vat-rate-input').value;
|
||||
fetch('{{ route('settings.vat.update') }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vat_rate: rate })
|
||||
}).then(function(r) {
|
||||
return r.json().then(function(body) {
|
||||
if (!r.ok) return Promise.reject(body);
|
||||
return body;
|
||||
});
|
||||
}).then(function() {
|
||||
showToast('VAT rate saved.', 'success');
|
||||
}).catch(function(err) {
|
||||
showToast((err.errors && err.errors.vat_rate ? err.errors.vat_rate[0] : null) || 'Failed to save.', 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/Http/Controllers/Settings/VatSettingController.php resources/views/settings/vat.blade.php routes/web.php
|
||||
git commit -m "feat: add VAT settings page and controller"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add VAT link to sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/layouts/app.blade.php`
|
||||
|
||||
- [ ] **Step 1: Add sidebar link**
|
||||
|
||||
In `resources/views/layouts/app.blade.php`, after the Integrations `<a>` tag (around line 198, just before `@endrole`), add:
|
||||
|
||||
```blade
|
||||
<a href="{{ route('settings.vat') }}" style="
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px;
|
||||
font-size:13px; text-decoration:none;
|
||||
{{ request()->routeIs('settings.vat*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }}
|
||||
" onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/>
|
||||
</svg>
|
||||
VAT Settings
|
||||
</a>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add resources/views/layouts/app.blade.php
|
||||
git commit -m "feat: add VAT Settings link to sidebar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update RfqPortalController — load VAT rate and store is_vatable
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Http/Controllers/Purchase/RfqPortalController.php`
|
||||
|
||||
- [ ] **Step 1: Update show() to pass VAT rate**
|
||||
|
||||
Add `use App\Models\Setting;` at the top of the file.
|
||||
|
||||
Update the `show()` method — add before the `return view(...)` line:
|
||||
|
||||
```php
|
||||
$vatRate = (float) Setting::get('vat_rate', 0);
|
||||
```
|
||||
|
||||
Update the return to:
|
||||
|
||||
```php
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode', 'vatRate'));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update submit() to store is_vatable and calculate VAT-inclusive total**
|
||||
|
||||
Add `'items.*.is_vatable' => ['nullable', 'boolean']` to the validation rules array.
|
||||
|
||||
Replace the items loop and total calculation:
|
||||
|
||||
```php
|
||||
$subtotal = 0;
|
||||
$vatAmount = 0;
|
||||
$vatRate = (float) Setting::get('vat_rate', 0);
|
||||
|
||||
foreach ($purchaseItems as $i => $item) {
|
||||
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
|
||||
$qty = (float)$item->quantity_required;
|
||||
$totalPrice = round($unitPrice * $qty, 3);
|
||||
$isVatable = !empty($validated['items'][$i]['is_vatable']);
|
||||
$subtotal += $totalPrice;
|
||||
|
||||
if ($isVatable && $vatRate > 0) {
|
||||
$vatAmount += round($totalPrice * $vatRate / 100, 3);
|
||||
}
|
||||
|
||||
SupplierQuoteItem::create([
|
||||
'supplier_quote_id' => $quote->id,
|
||||
'description' => $item->description,
|
||||
'unit' => $item->unit ?? '',
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_price' => $totalPrice,
|
||||
'is_vatable' => $isVatable,
|
||||
]);
|
||||
}
|
||||
|
||||
$grand = round($subtotal + $vatAmount, 3);
|
||||
$quote->update(['total_amount' => $grand]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/Http/Controllers/Purchase/RfqPortalController.php
|
||||
git commit -m "feat: load VAT rate and store is_vatable in RFQ portal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update rfq/show.blade.php — checkbox column + VAT footer + JS
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/rfq/show.blade.php`
|
||||
|
||||
- [ ] **Step 1: Add VAT rate JS variable**
|
||||
|
||||
At the start of the `<script>` block, add:
|
||||
|
||||
```javascript
|
||||
var _vatRate = {{ $vatRate }};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace calcRow() and add recalcTotals()**
|
||||
|
||||
Replace the entire `calcRow` function and grand total logic with:
|
||||
|
||||
```javascript
|
||||
var _totals = {};
|
||||
var _vatable = {};
|
||||
|
||||
function calcRow(i, qty) {
|
||||
var inp = document.querySelector('input[name="items[' + i + '][unit_price]"]');
|
||||
var cb = document.querySelector('input[name="items[' + i + '][is_vatable]"]');
|
||||
var up = parseFloat(inp ? inp.value : 0) || 0;
|
||||
var tot = Math.round(up * qty * 1000) / 1000;
|
||||
_totals[i] = tot;
|
||||
_vatable[i] = cb ? cb.checked : false;
|
||||
var el = document.getElementById('tot-' + i);
|
||||
if (el) el.textContent = tot > 0 ? 'BD ' + tot.toFixed(3) : '—';
|
||||
recalcTotals();
|
||||
}
|
||||
|
||||
function recalcTotals() {
|
||||
var subtotal = 0;
|
||||
var vatAmount = 0;
|
||||
Object.keys(_totals).forEach(function(i) {
|
||||
subtotal += _totals[i];
|
||||
if (_vatable[i] && _vatRate > 0) {
|
||||
vatAmount += Math.round(_totals[i] * _vatRate / 100 * 1000) / 1000;
|
||||
}
|
||||
});
|
||||
var grand = Math.round((subtotal + vatAmount) * 1000) / 1000;
|
||||
|
||||
var elSub = document.getElementById('subtotal-row');
|
||||
var elVat = document.getElementById('vat-row');
|
||||
var elGrand = document.getElementById('grand-total');
|
||||
|
||||
if (elSub) elSub.textContent = 'BD ' + subtotal.toFixed(3);
|
||||
if (elVat) {
|
||||
elVat.textContent = 'BD ' + vatAmount.toFixed(3);
|
||||
var vatTr = document.getElementById('vat-tr');
|
||||
if (vatTr) vatTr.style.display = _vatRate > 0 ? '' : 'none';
|
||||
}
|
||||
if (elGrand) elGrand.textContent = 'BD ' + grand.toFixed(3);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update table header**
|
||||
|
||||
Replace the `<thead>` block:
|
||||
|
||||
```html
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Description</th>
|
||||
<th>Qty</th>
|
||||
<th>Unit</th>
|
||||
<th style="text-align:center;">VAT?</th>
|
||||
<th style="text-align:right;">Unit Price (BD)</th>
|
||||
<th style="text-align:right;">Total (BD)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update each item row**
|
||||
|
||||
Replace the `@foreach` tbody rows:
|
||||
|
||||
```blade
|
||||
@foreach($items as $i => $item)
|
||||
<tr>
|
||||
<td style="color:#94a3b8;font-size:12px;">{{ $i + 1 }}</td>
|
||||
<td style="font-weight:500;">{{ $item->description }}</td>
|
||||
<td>{{ rtrim(rtrim(number_format((float)$item->quantity_required, 3), '0'), '.') }}</td>
|
||||
<td style="color:#64748b;">{{ $item->unit ?: '—' }}</td>
|
||||
<td style="text-align:center;">
|
||||
@if($vatRate > 0)
|
||||
<input type="checkbox" name="items[{{ $i }}][is_vatable]" value="1"
|
||||
onchange="calcRow({{ $i }}, {{ (float)$item->quantity_required }})"
|
||||
style="width:16px;height:16px;accent-color:#2563eb;cursor:pointer;">
|
||||
@else
|
||||
<span style="color:#cbd5e1;font-size:11px;">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<input type="number" class="price" name="items[{{ $i }}][unit_price]"
|
||||
min="0" step="0.001" required placeholder="0.000"
|
||||
oninput="calcRow({{ $i }}, {{ (float)$item->quantity_required }})">
|
||||
</td>
|
||||
<td style="text-align:right;font-weight:600;" id="tot-{{ $i }}">—</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update tfoot**
|
||||
|
||||
Replace the `<tfoot>` block:
|
||||
|
||||
```html
|
||||
<tfoot>
|
||||
<tr style="background:#f8fafc;">
|
||||
<td colspan="6" style="text-align:right;font-size:13px;color:#475569;">Subtotal:</td>
|
||||
<td style="text-align:right;font-size:14px;color:#475569;" id="subtotal-row">BD 0.000</td>
|
||||
</tr>
|
||||
<tr id="vat-tr" style="background:#fffbeb;{{ $vatRate > 0 ? '' : 'display:none;' }}">
|
||||
<td colspan="6" style="text-align:right;font-size:13px;color:#92400e;">VAT ({{ $vatRate }}%):</td>
|
||||
<td style="text-align:right;font-size:14px;color:#92400e;" id="vat-row">BD 0.000</td>
|
||||
</tr>
|
||||
<tr style="background:#f8fafc;border-top:2px solid #e2e8f0;">
|
||||
<td colspan="6" style="text-align:right;font-size:13px;color:#475569;font-weight:700;">Grand Total:</td>
|
||||
<td style="text-align:right;font-size:15px;color:#2563eb;font-weight:700;" id="grand-total">BD 0.000</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add resources/views/rfq/show.blade.php
|
||||
git commit -m "feat: add VAT checkbox column and live breakdown to RFQ portal"
|
||||
```
|
||||
133
docs/superpowers/specs/2026-06-01-vat-rfq-design.md
Normal file
133
docs/superpowers/specs/2026-06-01-vat-rfq-design.md
Normal file
@ -0,0 +1,133 @@
|
||||
# VAT Settings + RFQ Per-Item Vatable Checkbox — Design Spec
|
||||
|
||||
**Date:** 2026-06-01
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Two connected features:
|
||||
|
||||
1. **Global VAT setting** — Admin sets a VAT rate (%) once in Settings. Stored in the existing `settings` key/value table as `vat_rate`.
|
||||
2. **Per-item vatable checkbox on RFQ portal** — When a supplier fills in their quote, each item row has a "VAT?" checkbox. Ticking it applies the global VAT rate to that item's total. The footer shows a live breakdown: Subtotal → VAT → Grand Total. Everything is stored against the quote on submission.
|
||||
|
||||
---
|
||||
|
||||
## 1. VAT Global Setting
|
||||
|
||||
### Route & Controller
|
||||
- `GET settings/vat` → `VatSettingController@index` — renders the VAT settings page
|
||||
- `POST settings/vat` → `VatSettingController@update` — saves the rate via AJAX, returns JSON
|
||||
|
||||
### Storage
|
||||
- Uses the existing `Setting` model (`settings` table, key/value pairs)
|
||||
- Key: `vat_rate`, value: decimal string e.g. `"10"` for 10%
|
||||
- Default when not set: `0`
|
||||
|
||||
### View: `resources/views/settings/vat.blade.php`
|
||||
- Extends `layouts.app`
|
||||
- Single card with a number input: "VAT Rate (%)" — accepts decimals (e.g. 5, 10, 14.5)
|
||||
- Save button submits via `fetch()` AJAX (no page reload, per project rules)
|
||||
- Shows current saved value on load
|
||||
- Success/error feedback via `showToast()`
|
||||
|
||||
### Sidebar
|
||||
- Add "VAT Settings" link under the `@role('Admin')` System section in `layouts/app.blade.php`
|
||||
- Active state: `request()->routeIs('settings.vat')`
|
||||
|
||||
---
|
||||
|
||||
## 2. RFQ Portal — Per-Item Vatable Checkbox
|
||||
|
||||
### Database
|
||||
- New migration: add `is_vatable` boolean (default `false`) to `supplier_quote_items` table
|
||||
- `SupplierQuoteItem::$fillable` gains `is_vatable`
|
||||
|
||||
### Controller: `RfqPortalController`
|
||||
|
||||
**`show()` method:**
|
||||
- Load VAT rate: `$vatRate = (float) Setting::get('vat_rate', 0)`
|
||||
- Pass `$vatRate` to `rfq.show` view
|
||||
|
||||
**`submit()` method:**
|
||||
- Add validation rule: `'items.*.is_vatable' => ['nullable', 'boolean']`
|
||||
- When creating each `SupplierQuoteItem`, set `is_vatable` from submitted checkbox value
|
||||
- Calculate VAT: sum of `(unit_price × quantity)` for vatable items × `vatRate / 100`
|
||||
- Store `total_amount` on `SupplierQuote` as the VAT-inclusive grand total
|
||||
|
||||
### View: `resources/views/rfq/show.blade.php`
|
||||
|
||||
**Table header** — add "VAT?" column:
|
||||
```
|
||||
# | Description | Qty | Unit | Unit Price (BD) | VAT? | Total (BD)
|
||||
```
|
||||
|
||||
**Each item row** — add checkbox cell:
|
||||
```html
|
||||
<input type="checkbox" name="items[N][is_vatable]" value="1"
|
||||
onchange="calcRow(N, qty)" style="accent-color:#2563eb;width:16px;height:16px;">
|
||||
```
|
||||
|
||||
**Footer** — replace single "Grand Total" row with three rows:
|
||||
```
|
||||
Subtotal (before VAT): BD XXX.XXX
|
||||
VAT (10%): BD XXX.XXX ← hidden when vatRate is 0
|
||||
Grand Total: BD XXX.XXX
|
||||
```
|
||||
|
||||
**JavaScript `calcRow()` update:**
|
||||
- Each row calculates its own total (unit_price × qty), unchanged
|
||||
- `recalcTotals()` called after every row change:
|
||||
- Sums all row totals → subtotal
|
||||
- Sums row totals for checked (vatable) rows × `vatRate/100` → vatAmount
|
||||
- grand = subtotal + vatAmount
|
||||
- Updates subtotal, VAT, and grand total display elements
|
||||
- VAT rate injected as a JS variable: `var _vatRate = {{ $vatRate }};`
|
||||
- VAT row is hidden when `_vatRate === 0`
|
||||
|
||||
### Mobile stacked layout
|
||||
On mobile (≤640px), the table renders as cards. The VAT checkbox appears as a labelled row inside each card.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Flow Summary
|
||||
|
||||
```
|
||||
Admin sets vat_rate = 10 in settings/vat
|
||||
↓
|
||||
RfqPortalController::show() reads vat_rate, passes to view
|
||||
↓
|
||||
Supplier ticks VAT on items 1 and 3
|
||||
↓
|
||||
JS: subtotal = sum(all items), vat = sum(vatable items) × 0.10, grand = subtotal + vat
|
||||
↓
|
||||
Supplier submits form
|
||||
↓
|
||||
RfqPortalController::submit():
|
||||
- stores is_vatable per SupplierQuoteItem
|
||||
- total_amount on SupplierQuote = subtotal + vat (VAT-inclusive)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Files Touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `routes/web.php` | Add `GET/POST settings/vat` routes |
|
||||
| `app/Http/Controllers/Settings/VatSettingController.php` | New controller |
|
||||
| `resources/views/settings/vat.blade.php` | New view |
|
||||
| `resources/views/layouts/app.blade.php` | Add sidebar link |
|
||||
| `database/migrations/..._add_is_vatable_to_supplier_quote_items.php` | New migration |
|
||||
| `app/Models/SupplierQuoteItem.php` | Add `is_vatable` to `$fillable` |
|
||||
| `app/Http/Controllers/Purchase/RfqPortalController.php` | Load VAT rate, store `is_vatable`, calc VAT total |
|
||||
| `resources/views/rfq/show.blade.php` | Add checkbox column, VAT footer breakdown, JS update |
|
||||
|
||||
---
|
||||
|
||||
## 5. Out of Scope
|
||||
|
||||
- Showing VAT breakdown on the internal quote comparison view (can be added later)
|
||||
- Per-supplier or per-item VAT rates (single global rate only)
|
||||
- VAT registration numbers
|
||||
3
packages/azure-mailer/.gitignore
vendored
Normal file
3
packages/azure-mailer/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/vendor/
|
||||
composer.lock
|
||||
*.cache
|
||||
82
packages/azure-mailer/README.md
Normal file
82
packages/azure-mailer/README.md
Normal file
@ -0,0 +1,82 @@
|
||||
# promoseven/azure-mailer
|
||||
|
||||
Laravel mail transport for Microsoft 365 via the Azure AD Graph API.
|
||||
Replaces SMTP with a Client Credentials OAuth2 flow — drop-in compatible with
|
||||
Laravel's `Mail` facade, Mailables, Notifications, and queued mail.
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 8.2+
|
||||
- Laravel 11 or 12
|
||||
- An Azure AD App Registration with `Mail.Send` application permission
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require promoseven/azure-mailer
|
||||
```
|
||||
|
||||
## Azure AD Setup
|
||||
|
||||
1. Go to **Azure Portal → App Registrations → New registration**
|
||||
2. Note the **Tenant ID**, **Client ID**
|
||||
3. Under **Certificates & secrets**, create a new **Client secret**
|
||||
4. Under **API permissions**, add **Microsoft Graph → Application permissions → Mail.Send**
|
||||
5. Click **Grant admin consent**
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to `config/mail.php` under `mailers`:
|
||||
|
||||
```php
|
||||
'azure' => [
|
||||
'transport' => 'azure',
|
||||
'tenant_id' => env('AZURE_TENANT_ID'),
|
||||
'client_id' => env('AZURE_CLIENT_ID'),
|
||||
'client_secret' => env('AZURE_CLIENT_SECRET'),
|
||||
'from_address' => env('AZURE_MAIL_FROM_ADDRESS'),
|
||||
],
|
||||
```
|
||||
|
||||
Set `.env`:
|
||||
|
||||
```env
|
||||
MAIL_MAILER=azure
|
||||
|
||||
AZURE_TENANT_ID=your-tenant-id
|
||||
AZURE_CLIENT_ID=your-client-id
|
||||
AZURE_CLIENT_SECRET=your-client-secret
|
||||
AZURE_MAIL_FROM_ADDRESS=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
The `from_address` must be a mailbox in your Microsoft 365 tenant.
|
||||
|
||||
## Advanced config (optional)
|
||||
|
||||
Publish the config file to override defaults:
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=azure-mailer-config
|
||||
```
|
||||
|
||||
This creates `config/azure-mailer.php`:
|
||||
|
||||
```php
|
||||
return [
|
||||
'save_to_sent_items' => false, // set true to keep copies in Sent folder
|
||||
'timeout' => 30, // HTTP timeout in seconds
|
||||
'graph_api_version' => 'v1.0', // or 'beta'
|
||||
];
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
No changes needed — use Laravel mail exactly as before:
|
||||
|
||||
```php
|
||||
Mail::to('user@example.com')->send(new OrderConfirmation($order));
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
40
packages/azure-mailer/composer.json
Normal file
40
packages/azure-mailer/composer.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "promoseven/azure-mailer",
|
||||
"description": "Laravel mail transport for Microsoft 365 via Azure AD Graph API",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"illuminate/support": "^11.0|^12.0",
|
||||
"illuminate/http": "^11.0|^12.0",
|
||||
"illuminate/cache": "^11.0|^12.0",
|
||||
"illuminate/mail": "^11.0|^12.0",
|
||||
"symfony/mailer": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"orchestra/testbench": "^10.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PromoSeven\\AzureMailer\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"PromoSeven\\AzureMailer\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"PromoSeven\\AzureMailer\\AzureMailerServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vendor/bin/phpunit"
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
7
packages/azure-mailer/config/azure-mailer.php
Normal file
7
packages/azure-mailer/config/azure-mailer.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'save_to_sent_items' => false,
|
||||
'timeout' => 30,
|
||||
'graph_api_version' => 'v1.0',
|
||||
];
|
||||
11
packages/azure-mailer/phpunit.xml
Normal file
11
packages/azure-mailer/phpunit.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
37
packages/azure-mailer/src/AzureMailerServiceProvider.php
Normal file
37
packages/azure-mailer/src/AzureMailerServiceProvider.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer;
|
||||
|
||||
use Illuminate\Mail\MailManager;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use PromoSeven\AzureMailer\Graph\GraphClient;
|
||||
use PromoSeven\AzureMailer\Graph\TokenManager;
|
||||
use PromoSeven\AzureMailer\Transport\AzureTransport;
|
||||
|
||||
class AzureMailerServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../config/azure-mailer.php', 'azure-mailer');
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->publishes([
|
||||
__DIR__ . '/../config/azure-mailer.php' => config_path('azure-mailer.php'),
|
||||
], 'azure-mailer-config');
|
||||
|
||||
$this->callAfterResolving(MailManager::class, function (MailManager $manager) {
|
||||
$manager->extend('azure', function (array $config) {
|
||||
$merged = array_merge(config('azure-mailer', []), $config);
|
||||
|
||||
return new AzureTransport(
|
||||
new GraphClient(new TokenManager($merged), $merged),
|
||||
$merged
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Exceptions;
|
||||
|
||||
class AuthenticationException extends \RuntimeException
|
||||
{
|
||||
public static function fromResponse(string $error, string $description): self
|
||||
{
|
||||
return new self("Azure AD authentication failed: [{$error}] {$description}");
|
||||
}
|
||||
}
|
||||
13
packages/azure-mailer/src/Exceptions/GraphApiException.php
Normal file
13
packages/azure-mailer/src/Exceptions/GraphApiException.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Exceptions;
|
||||
|
||||
class GraphApiException extends \RuntimeException
|
||||
{
|
||||
public static function fromResponse(string $code, string $message): self
|
||||
{
|
||||
return new self("Graph API error: [{$code}] {$message}");
|
||||
}
|
||||
}
|
||||
51
packages/azure-mailer/src/Graph/GraphClient.php
Normal file
51
packages/azure-mailer/src/Graph/GraphClient.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Graph;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
|
||||
|
||||
class GraphClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TokenManager $tokenManager,
|
||||
private readonly array $config
|
||||
) {}
|
||||
|
||||
public function send(array $payload): void
|
||||
{
|
||||
$url = $this->endpoint();
|
||||
|
||||
$response = Http::withToken($this->tokenManager->getToken())
|
||||
->timeout($this->config['timeout'] ?? 30)
|
||||
->post($url, $payload);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
Log::warning('azure-mailer: 401 received, retrying with fresh token');
|
||||
$this->tokenManager->invalidate();
|
||||
|
||||
$response = Http::withToken($this->tokenManager->getToken())
|
||||
->timeout($this->config['timeout'] ?? 30)
|
||||
->post($url, $payload);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$error = $response->json('error', []);
|
||||
throw GraphApiException::fromResponse(
|
||||
$error['code'] ?? (string) $response->status(),
|
||||
$error['message'] ?? 'Unknown error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function endpoint(): string
|
||||
{
|
||||
$version = $this->config['graph_api_version'] ?? 'v1.0';
|
||||
$from = $this->config['from_address'];
|
||||
|
||||
return "https://graph.microsoft.com/{$version}/users/{$from}/sendMail";
|
||||
}
|
||||
}
|
||||
63
packages/azure-mailer/src/Graph/TokenManager.php
Normal file
63
packages/azure-mailer/src/Graph/TokenManager.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Graph;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
|
||||
|
||||
class TokenManager
|
||||
{
|
||||
public function __construct(private readonly array $config) {}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
$key = $this->cacheKey();
|
||||
|
||||
if ($token = Cache::get($key)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->fetchAndCache($key);
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
Cache::forget($this->cacheKey());
|
||||
}
|
||||
|
||||
private function fetchAndCache(string $key): string
|
||||
{
|
||||
$response = Http::asForm()->post(
|
||||
"https://login.microsoftonline.com/{$this->config['tenant_id']}/oauth2/v2.0/token",
|
||||
[
|
||||
'grant_type' => 'client_credentials',
|
||||
'client_id' => $this->config['client_id'],
|
||||
'client_secret' => $this->config['client_secret'],
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
]
|
||||
);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$body = $response->json();
|
||||
throw AuthenticationException::fromResponse(
|
||||
$body['error'] ?? 'unknown_error',
|
||||
$body['error_description'] ?? 'No description provided'
|
||||
);
|
||||
}
|
||||
|
||||
$body = $response->json();
|
||||
$ttl = max(1, ($body['expires_in'] ?? 3600) - 60);
|
||||
|
||||
Cache::put($key, $body['access_token'], $ttl);
|
||||
|
||||
return $body['access_token'];
|
||||
}
|
||||
|
||||
private function cacheKey(): string
|
||||
{
|
||||
return 'azure_mailer_token_' . $this->config['client_id'];
|
||||
}
|
||||
}
|
||||
86
packages/azure-mailer/src/Transport/AzureTransport.php
Normal file
86
packages/azure-mailer/src/Transport/AzureTransport.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Transport;
|
||||
|
||||
use PromoSeven\AzureMailer\Graph\GraphClient;
|
||||
use Symfony\Component\Mailer\SentMessage;
|
||||
use Symfony\Component\Mailer\Transport\AbstractTransport;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
|
||||
class AzureTransport extends AbstractTransport
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GraphClient $client,
|
||||
private readonly array $config = []
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function doSend(SentMessage $message): void
|
||||
{
|
||||
$email = $message->getOriginalMessage();
|
||||
|
||||
if (! $email instanceof Email) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->client->send($this->buildPayload($email));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'azure';
|
||||
}
|
||||
|
||||
private function buildPayload(Email $email): array
|
||||
{
|
||||
return [
|
||||
'message' => [
|
||||
'subject' => $email->getSubject() ?? '',
|
||||
'body' => [
|
||||
'contentType' => $email->getHtmlBody() !== null ? 'HTML' : 'Text',
|
||||
'content' => $email->getHtmlBody() ?? $email->getTextBody() ?? '',
|
||||
],
|
||||
'toRecipients' => $this->mapAddresses($email->getTo()),
|
||||
'ccRecipients' => $this->mapAddresses($email->getCc()),
|
||||
'bccRecipients' => $this->mapAddresses($email->getBcc()),
|
||||
'replyTo' => $this->mapAddresses($email->getReplyTo()),
|
||||
'attachments' => $this->mapAttachments($email),
|
||||
],
|
||||
'saveToSentItems' => (bool) ($this->config['save_to_sent_items'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapAddresses(array $addresses): array
|
||||
{
|
||||
return array_map(fn ($addr) => [
|
||||
'emailAddress' => [
|
||||
'address' => $addr->getAddress(),
|
||||
'name' => $addr->getName() ?? '',
|
||||
],
|
||||
], $addresses);
|
||||
}
|
||||
|
||||
private function mapAttachments(Email $email): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($email->getAttachments() as $attachment) {
|
||||
if (! $attachment instanceof DataPart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'@odata.type' => '#microsoft.graph.fileAttachment',
|
||||
'name' => $attachment->getFilename() ?? 'attachment',
|
||||
'contentType' => $attachment->getMediaType() . '/' . $attachment->getMediaSubtype(),
|
||||
'contentBytes' => base64_encode($attachment->getBody()),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
29
packages/azure-mailer/tests/ExceptionsTest.php
Normal file
29
packages/azure-mailer/tests/ExceptionsTest.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Tests;
|
||||
|
||||
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
|
||||
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
|
||||
|
||||
class ExceptionsTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
public function test_authentication_exception_formats_message(): void
|
||||
{
|
||||
$e = AuthenticationException::fromResponse('invalid_client', 'The client secret is incorrect.');
|
||||
|
||||
$this->assertInstanceOf(\RuntimeException::class, $e);
|
||||
$this->assertStringContainsString('invalid_client', $e->getMessage());
|
||||
$this->assertStringContainsString('The client secret is incorrect.', $e->getMessage());
|
||||
}
|
||||
|
||||
public function test_graph_api_exception_formats_message(): void
|
||||
{
|
||||
$e = GraphApiException::fromResponse('ErrorItemNotFound', 'The specified object was not found.');
|
||||
|
||||
$this->assertInstanceOf(\RuntimeException::class, $e);
|
||||
$this->assertStringContainsString('ErrorItemNotFound', $e->getMessage());
|
||||
$this->assertStringContainsString('The specified object was not found.', $e->getMessage());
|
||||
}
|
||||
}
|
||||
0
packages/azure-mailer/tests/Graph/.gitkeep
Normal file
0
packages/azure-mailer/tests/Graph/.gitkeep
Normal file
124
packages/azure-mailer/tests/Graph/GraphClientTest.php
Normal file
124
packages/azure-mailer/tests/Graph/GraphClientTest.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Tests\Graph;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PromoSeven\AzureMailer\Exceptions\GraphApiException;
|
||||
use PromoSeven\AzureMailer\Graph\GraphClient;
|
||||
use PromoSeven\AzureMailer\Graph\TokenManager;
|
||||
use PromoSeven\AzureMailer\Tests\TestCase;
|
||||
|
||||
class GraphClientTest extends TestCase
|
||||
{
|
||||
private array $config = [
|
||||
'tenant_id' => 'test-tenant',
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-secret',
|
||||
'from_address' => 'sender@example.com',
|
||||
'timeout' => 30,
|
||||
'graph_api_version' => 'v1.0',
|
||||
];
|
||||
|
||||
private array $payload = [
|
||||
'message' => [
|
||||
'subject' => 'Test',
|
||||
'body' => ['contentType' => 'HTML', 'content' => '<p>Hello</p>'],
|
||||
'toRecipients' => [['emailAddress' => ['address' => 'to@example.com', 'name' => '']]],
|
||||
'ccRecipients' => [],
|
||||
'bccRecipients' => [],
|
||||
'replyTo' => [],
|
||||
'attachments' => [],
|
||||
],
|
||||
'saveToSentItems' => false,
|
||||
];
|
||||
|
||||
public function test_sends_payload_with_bearer_token(): void
|
||||
{
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
|
||||
'graph.microsoft.com/*' => Http::response('', 202),
|
||||
]);
|
||||
|
||||
$client = new GraphClient(new TokenManager($this->config), $this->config);
|
||||
$client->send($this->payload);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'graph.microsoft.com/v1.0/users/sender@example.com/sendMail')
|
||||
&& $request->hasHeader('Authorization', 'Bearer tok');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_retries_with_fresh_token_on_401(): void
|
||||
{
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('azure-mailer: 401 received, retrying with fresh token');
|
||||
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::sequence()
|
||||
->push(['access_token' => 'stale-token', 'expires_in' => 3600], 200)
|
||||
->push(['access_token' => 'fresh-token', 'expires_in' => 3600], 200),
|
||||
'graph.microsoft.com/*' => Http::sequence()
|
||||
->push('', 401)
|
||||
->push('', 202),
|
||||
]);
|
||||
|
||||
$client = new GraphClient(new TokenManager($this->config), $this->config);
|
||||
$client->send($this->payload);
|
||||
|
||||
Http::assertSentCount(4); // 2 token fetches + 2 graph calls
|
||||
}
|
||||
|
||||
public function test_throws_graph_api_exception_on_second_401(): void
|
||||
{
|
||||
Log::shouldReceive('warning')->once();
|
||||
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
|
||||
'graph.microsoft.com/*' => Http::response([
|
||||
'error' => ['code' => 'InvalidAuthenticationToken', 'message' => 'Token is expired.'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
$this->expectException(GraphApiException::class);
|
||||
$this->expectExceptionMessage('InvalidAuthenticationToken');
|
||||
|
||||
$client = new GraphClient(new TokenManager($this->config), $this->config);
|
||||
$client->send($this->payload);
|
||||
}
|
||||
|
||||
public function test_throws_graph_api_exception_on_other_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
|
||||
'graph.microsoft.com/*' => Http::response([
|
||||
'error' => ['code' => 'ErrorInvalidRecipients', 'message' => 'Recipient address is invalid.'],
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$this->expectException(GraphApiException::class);
|
||||
$this->expectExceptionMessage('ErrorInvalidRecipients');
|
||||
|
||||
$client = new GraphClient(new TokenManager($this->config), $this->config);
|
||||
$client->send($this->payload);
|
||||
}
|
||||
|
||||
public function test_uses_graph_api_version_from_config(): void
|
||||
{
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response(['access_token' => 'tok', 'expires_in' => 3600], 200),
|
||||
'graph.microsoft.com/*' => Http::response('', 202),
|
||||
]);
|
||||
|
||||
$config = array_merge($this->config, ['graph_api_version' => 'beta']);
|
||||
$client = new GraphClient(new TokenManager($config), $config);
|
||||
$client->send($this->payload);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'graph.microsoft.com/beta/');
|
||||
});
|
||||
}
|
||||
}
|
||||
119
packages/azure-mailer/tests/Graph/TokenManagerTest.php
Normal file
119
packages/azure-mailer/tests/Graph/TokenManagerTest.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Tests\Graph;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PromoSeven\AzureMailer\Exceptions\AuthenticationException;
|
||||
use PromoSeven\AzureMailer\Graph\TokenManager;
|
||||
use PromoSeven\AzureMailer\Tests\TestCase;
|
||||
|
||||
class TokenManagerTest extends TestCase
|
||||
{
|
||||
private array $config = [
|
||||
'tenant_id' => 'test-tenant',
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-secret',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_fetches_token_from_azure_on_cache_miss(): void
|
||||
{
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response([
|
||||
'access_token' => 'my-access-token',
|
||||
'expires_in' => 3600,
|
||||
'token_type' => 'Bearer',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$manager = new TokenManager($this->config);
|
||||
$token = $manager->getToken();
|
||||
|
||||
$this->assertSame('my-access-token', $token);
|
||||
Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'test-tenant/oauth2/v2.0/token')
|
||||
&& $request['grant_type'] === 'client_credentials'
|
||||
&& $request['client_id'] === 'test-client-id'
|
||||
&& $request['client_secret'] === 'test-secret'
|
||||
&& $request['scope'] === 'https://graph.microsoft.com/.default';
|
||||
});
|
||||
}
|
||||
|
||||
public function test_returns_cached_token_without_hitting_azure(): void
|
||||
{
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response([
|
||||
'access_token' => 'first-token',
|
||||
'expires_in' => 3600,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$manager = new TokenManager($this->config);
|
||||
$manager->getToken(); // first call — hits Azure
|
||||
$manager->getToken(); // second call — should use cache
|
||||
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
public function test_invalidate_clears_cached_token(): void
|
||||
{
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::sequence()
|
||||
->push(['access_token' => 'token-one', 'expires_in' => 3600], 200)
|
||||
->push(['access_token' => 'token-two', 'expires_in' => 3600], 200),
|
||||
]);
|
||||
|
||||
$manager = new TokenManager($this->config);
|
||||
$manager->getToken(); // fetches token-one
|
||||
$manager->invalidate(); // clears cache
|
||||
$second = $manager->getToken(); // fetches token-two
|
||||
|
||||
$this->assertSame('token-two', $second);
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_token_is_cached_with_ttl_of_expires_in_minus_60(): void
|
||||
{
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response([
|
||||
'access_token' => 'ttl-token',
|
||||
'expires_in' => 120, // 120 - 60 = 60 second TTL
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$manager = new TokenManager($this->config);
|
||||
$manager->getToken();
|
||||
|
||||
// Token should be in cache immediately after fetch
|
||||
$this->assertTrue(Cache::has('azure_mailer_token_test-client-id'));
|
||||
|
||||
// Advance time past the TTL (61 seconds)
|
||||
$this->travel(61)->seconds();
|
||||
|
||||
// Token should now be expired from cache
|
||||
$this->assertFalse(Cache::has('azure_mailer_token_test-client-id'));
|
||||
}
|
||||
|
||||
public function test_throws_authentication_exception_on_azure_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response([
|
||||
'error' => 'invalid_client',
|
||||
'error_description' => 'The client secret supplied is incorrect.',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
$this->expectException(AuthenticationException::class);
|
||||
$this->expectExceptionMessage('invalid_client');
|
||||
|
||||
(new TokenManager($this->config))->getToken();
|
||||
}
|
||||
}
|
||||
33
packages/azure-mailer/tests/ServiceProviderTest.php
Normal file
33
packages/azure-mailer/tests/ServiceProviderTest.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Tests;
|
||||
|
||||
use Illuminate\Mail\MailManager;
|
||||
use PromoSeven\AzureMailer\Transport\AzureTransport;
|
||||
|
||||
class ServiceProviderTest extends TestCase
|
||||
{
|
||||
protected function defineEnvironment($app): void
|
||||
{
|
||||
parent::defineEnvironment($app);
|
||||
|
||||
$app['config']->set('mail.mailers.azure', [
|
||||
'transport' => 'azure',
|
||||
'tenant_id' => 'test-tenant',
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-secret',
|
||||
'from_address' => 'sender@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_azure_transport_is_registered_with_mail_manager(): void
|
||||
{
|
||||
$manager = $this->app->make(MailManager::class);
|
||||
$transport = $manager->mailer('azure')->getSymfonyTransport();
|
||||
|
||||
$this->assertInstanceOf(AzureTransport::class, $transport);
|
||||
$this->assertSame('azure', (string) $transport);
|
||||
}
|
||||
}
|
||||
21
packages/azure-mailer/tests/TestCase.php
Normal file
21
packages/azure-mailer/tests/TestCase.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Tests;
|
||||
|
||||
use Orchestra\Testbench\TestCase as OrchestraTestCase;
|
||||
use PromoSeven\AzureMailer\AzureMailerServiceProvider;
|
||||
|
||||
abstract class TestCase extends OrchestraTestCase
|
||||
{
|
||||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return [AzureMailerServiceProvider::class];
|
||||
}
|
||||
|
||||
protected function defineEnvironment($app): void
|
||||
{
|
||||
$app['config']->set('cache.default', 'array');
|
||||
}
|
||||
}
|
||||
0
packages/azure-mailer/tests/Transport/.gitkeep
Normal file
0
packages/azure-mailer/tests/Transport/.gitkeep
Normal file
131
packages/azure-mailer/tests/Transport/AzureTransportTest.php
Normal file
131
packages/azure-mailer/tests/Transport/AzureTransportTest.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PromoSeven\AzureMailer\Tests\Transport;
|
||||
|
||||
use PromoSeven\AzureMailer\Graph\GraphClient;
|
||||
use PromoSeven\AzureMailer\Tests\TestCase;
|
||||
use PromoSeven\AzureMailer\Transport\AzureTransport;
|
||||
use Symfony\Component\Mailer\Envelope;
|
||||
use Symfony\Component\Mime\Email;
|
||||
|
||||
class AzureTransportTest extends TestCase
|
||||
{
|
||||
public function test_sends_html_body(): void
|
||||
{
|
||||
$captured = [];
|
||||
$client = $this->createMock(GraphClient::class);
|
||||
$client->expects($this->once())
|
||||
->method('send')
|
||||
->willReturnCallback(function (array $payload) use (&$captured) {
|
||||
$captured = $payload;
|
||||
});
|
||||
|
||||
$email = (new Email())
|
||||
->from('from@example.com')
|
||||
->to('to@example.com')
|
||||
->subject('Hello')
|
||||
->html('<p>World</p>');
|
||||
|
||||
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
|
||||
$transport->send($email, Envelope::create($email));
|
||||
|
||||
$this->assertSame('Hello', $captured['message']['subject']);
|
||||
$this->assertSame('HTML', $captured['message']['body']['contentType']);
|
||||
$this->assertSame('<p>World</p>', $captured['message']['body']['content']);
|
||||
$this->assertSame('to@example.com', $captured['message']['toRecipients'][0]['emailAddress']['address']);
|
||||
$this->assertFalse($captured['saveToSentItems']);
|
||||
}
|
||||
|
||||
public function test_falls_back_to_text_body_when_no_html(): void
|
||||
{
|
||||
$captured = [];
|
||||
$client = $this->createMock(GraphClient::class);
|
||||
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
|
||||
|
||||
$email = (new Email())
|
||||
->from('from@example.com')
|
||||
->to('to@example.com')
|
||||
->subject('Text only')
|
||||
->text('Plain text content');
|
||||
|
||||
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
|
||||
$transport->send($email, Envelope::create($email));
|
||||
|
||||
$this->assertSame('Text', $captured['message']['body']['contentType']);
|
||||
$this->assertSame('Plain text content', $captured['message']['body']['content']);
|
||||
}
|
||||
|
||||
public function test_maps_cc_bcc_and_reply_to(): void
|
||||
{
|
||||
$captured = [];
|
||||
$client = $this->createMock(GraphClient::class);
|
||||
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
|
||||
|
||||
$email = (new Email())
|
||||
->from('from@example.com')
|
||||
->to('to@example.com')
|
||||
->cc('cc@example.com')
|
||||
->bcc('bcc@example.com')
|
||||
->replyTo('reply@example.com')
|
||||
->subject('Recipients test')
|
||||
->html('<p>hi</p>');
|
||||
|
||||
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
|
||||
$transport->send($email, Envelope::create($email));
|
||||
|
||||
$this->assertSame('cc@example.com', $captured['message']['ccRecipients'][0]['emailAddress']['address']);
|
||||
$this->assertSame('bcc@example.com', $captured['message']['bccRecipients'][0]['emailAddress']['address']);
|
||||
$this->assertSame('reply@example.com', $captured['message']['replyTo'][0]['emailAddress']['address']);
|
||||
}
|
||||
|
||||
public function test_encodes_attachments_as_base64(): void
|
||||
{
|
||||
$captured = [];
|
||||
$client = $this->createMock(GraphClient::class);
|
||||
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
|
||||
|
||||
$email = (new Email())
|
||||
->from('from@example.com')
|
||||
->to('to@example.com')
|
||||
->subject('With attachment')
|
||||
->html('<p>see attachment</p>')
|
||||
->attach('PDF content here', 'report.pdf', 'application/pdf');
|
||||
|
||||
$transport = new AzureTransport($client, ['save_to_sent_items' => false]);
|
||||
$transport->send($email, Envelope::create($email));
|
||||
|
||||
$attachment = $captured['message']['attachments'][0];
|
||||
$this->assertSame('#microsoft.graph.fileAttachment', $attachment['@odata.type']);
|
||||
$this->assertSame('report.pdf', $attachment['name']);
|
||||
$this->assertSame(base64_encode('PDF content here'), $attachment['contentBytes']);
|
||||
$this->assertSame('application/pdf', $attachment['contentType']);
|
||||
}
|
||||
|
||||
public function test_save_to_sent_items_is_configurable(): void
|
||||
{
|
||||
$captured = [];
|
||||
$client = $this->createMock(GraphClient::class);
|
||||
$client->method('send')->willReturnCallback(function ($p) use (&$captured) { $captured = $p; });
|
||||
|
||||
$email = (new Email())
|
||||
->from('from@example.com')
|
||||
->to('to@example.com')
|
||||
->subject('Save it')
|
||||
->html('<p>keep</p>');
|
||||
|
||||
$transport = new AzureTransport($client, ['save_to_sent_items' => true]);
|
||||
$transport->send($email, Envelope::create($email));
|
||||
|
||||
$this->assertTrue($captured['saveToSentItems']);
|
||||
}
|
||||
|
||||
public function test_to_string_returns_azure(): void
|
||||
{
|
||||
$client = $this->createMock(GraphClient::class);
|
||||
$transport = new AzureTransport($client, []);
|
||||
|
||||
$this->assertSame('azure', (string) $transport);
|
||||
}
|
||||
}
|
||||
2
packages/ultra-message/.gitignore
vendored
Normal file
2
packages/ultra-message/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/vendor/
|
||||
composer.lock
|
||||
42
packages/ultra-message/composer.json
Normal file
42
packages/ultra-message/composer.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vendor/bin/phpunit"
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
10
packages/ultra-message/config/ultra-message.php
Normal file
10
packages/ultra-message/config/ultra-message.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?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),
|
||||
];
|
||||
11
packages/ultra-message/phpunit.xml.dist
Normal file
11
packages/ultra-message/phpunit.xml.dist
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
1
packages/ultra-message/routes/.gitkeep
Normal file
1
packages/ultra-message/routes/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
7
packages/ultra-message/routes/webhook.php
Normal file
7
packages/ultra-message/routes/webhook.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?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');
|
||||
@ -0,0 +1,13 @@
|
||||
<?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) {}
|
||||
}
|
||||
47
packages/ultra-message/src/Facades/UltraMessage.php
Normal file
47
packages/ultra-message/src/Facades/UltraMessage.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
1
packages/ultra-message/src/Http/Controllers/.gitkeep
Normal file
1
packages/ultra-message/src/Http/Controllers/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace PromoSeven\UltraMessage\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use PromoSeven\UltraMessage\Events\UltraMessageWebhookReceived;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $request): 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);
|
||||
}
|
||||
}
|
||||
39
packages/ultra-message/src/UltraMessageChannel.php
Normal file
39
packages/ultra-message/src/UltraMessageChannel.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?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}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
187
packages/ultra-message/src/UltraMessageClient.php
Normal file
187
packages/ultra-message/src/UltraMessageClient.php
Normal file
@ -0,0 +1,187 @@
|
||||
<?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/');
|
||||
}
|
||||
}
|
||||
7
packages/ultra-message/src/UltraMessageException.php
Normal file
7
packages/ultra-message/src/UltraMessageException.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace PromoSeven\UltraMessage;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class UltraMessageException extends RuntimeException {}
|
||||
49
packages/ultra-message/src/UltraMessageFake.php
Normal file
49
packages/ultra-message/src/UltraMessageFake.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
67
packages/ultra-message/src/UltraMessageMessage.php
Normal file
67
packages/ultra-message/src/UltraMessageMessage.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
37
packages/ultra-message/src/UltraMessageServiceProvider.php
Normal file
37
packages/ultra-message/src/UltraMessageServiceProvider.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?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->bind(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->bind(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');
|
||||
}
|
||||
}
|
||||
21
packages/ultra-message/tests/TestCase.php
Normal file
21
packages/ultra-message/tests/TestCase.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
110
packages/ultra-message/tests/UltraMessageChannelTest.php
Normal file
110
packages/ultra-message/tests/UltraMessageChannelTest.php
Normal file
@ -0,0 +1,110 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
128
packages/ultra-message/tests/UltraMessageClientTest.php
Normal file
128
packages/ultra-message/tests/UltraMessageClientTest.php
Normal file
@ -0,0 +1,128 @@
|
||||
<?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';
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -197,6 +197,17 @@
|
||||
</svg>
|
||||
Integrations
|
||||
</a>
|
||||
<a href="{{ route('settings.vat') }}" style="
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:7px 12px 7px 24px; border-radius:7px; margin-bottom:1px;
|
||||
font-size:13px; text-decoration:none;
|
||||
{{ request()->routeIs('settings.vat*') ? 'background:#1e293b;color:#fff;font-weight:500;' : 'color:#94a3b8;' }}
|
||||
" onmouseover="if(!this.style.color.includes('fff'))this.style.color='#e2e8f0'" onmouseout="if(!this.style.background.includes('1e293b'))this.style.color='#94a3b8'">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z"/>
|
||||
</svg>
|
||||
VAT Settings
|
||||
</a>
|
||||
@endrole
|
||||
|
||||
<div style="height:16px;"></div>
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
{{-- Per-item rows --}}
|
||||
@foreach($items as $i => $reqItem)
|
||||
@php
|
||||
$rowPrices = $quotes->map(fn($q) => optional($q->items->get($i))->unit_price)->filter()->values();
|
||||
$rowPrices = $quotes->map(fn($q) => ($q->items->get($i) && !$q->items->get($i)->not_available) ? $q->items->get($i)->unit_price : null)->filter()->values();
|
||||
$minPrice = $rowPrices->count() ? $rowPrices->min() : null;
|
||||
@endphp
|
||||
<tr>
|
||||
@ -57,14 +57,24 @@
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px;">Qty: {{ $reqItem->quantity }} {{ $reqItem->unit }}</div>
|
||||
</td>
|
||||
@foreach($quotes as $q)
|
||||
@php $qItem = $q->items->get($i); $isMin = $qItem && $minPrice !== null && (float)$qItem->unit_price === (float)$minPrice && $rowPrices->count() > 1; @endphp
|
||||
@php $qItem = $q->items->get($i); $isMin = $qItem && !$qItem->not_available && $minPrice !== null && (float)$qItem->unit_price === (float)$minPrice && $rowPrices->count() > 1; @endphp
|
||||
<td style="padding:10px 14px;border-bottom:1px solid #f1f5f9;text-align:center;background:{{ $isMin ? '#f0fdf4' : '' }};">
|
||||
@if($qItem)
|
||||
<div style="font-weight:600;color:{{ $isMin ? '#15803d' : '#0f172a' }};">
|
||||
@if($isMin)<span style="font-size:10px;font-weight:700;color:#15803d;display:block;">LOWEST</span>@endif
|
||||
BD {{ number_format($qItem->unit_price, 3) }}
|
||||
</div>
|
||||
<div style="font-size:11px;color:#64748b;margin-top:2px;">BD {{ number_format($qItem->total_price, 3) }}</div>
|
||||
@if($qItem->not_available)
|
||||
<span style="font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;padding:2px 8px;border-radius:4px;border:1px solid #fecaca;">Not available</span>
|
||||
@else
|
||||
@if($qItem->supplier_description)
|
||||
<div style="font-size:10px;color:#64748b;margin-bottom:2px;font-style:italic;">
|
||||
"{{ $qItem->supplier_description }}"
|
||||
<span style="background:#fef3c7;color:#92400e;font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;border:1px solid #fde68a;font-style:normal;margin-left:2px;">adjusted</span>
|
||||
</div>
|
||||
@endif
|
||||
<div style="font-weight:600;color:{{ $isMin ? '#15803d' : '#0f172a' }};">
|
||||
@if($isMin)<span style="font-size:10px;font-weight:700;color:#15803d;display:block;">LOWEST</span>@endif
|
||||
BD {{ number_format($qItem->unit_price, 3) }}
|
||||
</div>
|
||||
<div style="font-size:11px;color:#64748b;margin-top:2px;">BD {{ number_format($qItem->total_price, 3) }}</div>
|
||||
@endif
|
||||
@else
|
||||
<span style="color:#e2e8f0;">—</span>
|
||||
@endif
|
||||
|
||||
@ -12,18 +12,18 @@
|
||||
input:focus,textarea:focus{border-color:#2563eb;}
|
||||
.btn{width:100%;padding:15px;background:linear-gradient(135deg,#2563eb,#1d4ed8);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:700;cursor:pointer;}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
thead th{background:#f8fafc;padding:10px 12px;text-align:left;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;}
|
||||
tbody td{padding:10px 12px;border-bottom:1px solid #f1f5f9;vertical-align:middle;}
|
||||
tfoot td{padding:12px;font-weight:700;}
|
||||
input[type=number].price{width:130px;text-align:right;}
|
||||
thead th{background:#f8fafc;padding:11px 16px;text-align:left;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;}
|
||||
tbody td{padding:12px 16px;border-bottom:1px solid #f1f5f9;vertical-align:middle;}
|
||||
tfoot td{padding:13px 16px;font-weight:700;}
|
||||
input[type=number].price{width:140px;text-align:right;}
|
||||
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;}
|
||||
|
||||
/* ── Desktop: centered card ── */
|
||||
@media(min-width:641px){
|
||||
body{padding:28px 16px;}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);max-width:740px;margin:0 auto;overflow:hidden;}
|
||||
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:28px 32px;}
|
||||
.body{padding:32px;}
|
||||
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.08);max-width:980px;margin:0 auto;overflow:hidden;}
|
||||
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:28px 36px;}
|
||||
.body{padding:36px;}
|
||||
}
|
||||
|
||||
/* ── Mobile: full page ── */
|
||||
@ -78,19 +78,62 @@
|
||||
<th>Description</th>
|
||||
<th>Qty</th>
|
||||
<th>Unit</th>
|
||||
<th style="text-align:center;">N/A?</th>
|
||||
<th style="text-align:center;">VAT?</th>
|
||||
<th style="text-align:right;">Unit Price (BD)</th>
|
||||
<th style="text-align:right;">Total (BD)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($items as $i => $item)
|
||||
<tr>
|
||||
<tr id="row-{{ $i }}">
|
||||
<td style="color:#94a3b8;font-size:12px;">{{ $i + 1 }}</td>
|
||||
<td style="font-weight:500;">{{ $item->description }}</td>
|
||||
<td>
|
||||
{{-- Display mode --}}
|
||||
<div id="desc-display-{{ $i }}" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||
<span id="desc-text-{{ $i }}" style="font-weight:500;">{{ $item->description }}</span>
|
||||
<button type="button" onclick="editDesc({{ $i }})"
|
||||
title="Edit item name"
|
||||
style="flex-shrink:0;background:none;border:1px solid #e2e8f0;border-radius:5px;padding:2px 6px;cursor:pointer;color:#64748b;font-size:11px;line-height:1.4;"
|
||||
onmouseover="this.style.borderColor='#2563eb';this.style.color='#2563eb'"
|
||||
onmouseout="this.style.borderColor='#e2e8f0';this.style.color='#64748b'">
|
||||
✎ Edit
|
||||
</button>
|
||||
<span id="desc-adj-{{ $i }}" style="display:none;font-size:10px;font-weight:700;background:#fef3c7;color:#92400e;padding:1px 6px;border-radius:4px;border:1px solid #fde68a;">adjusted</span>
|
||||
</div>
|
||||
{{-- Edit mode --}}
|
||||
<div id="desc-edit-{{ $i }}" style="display:none;align-items:center;gap:6px;">
|
||||
<input type="text" id="desc-input-{{ $i }}"
|
||||
value="{{ $item->description }}"
|
||||
style="flex:1;min-width:0;padding:5px 8px;border:1.5px solid #2563eb;border-radius:6px;font-size:13px;font-weight:500;outline:none;width:auto;">
|
||||
<button type="button" onclick="saveDesc({{ $i }}, {{ json_encode($item->description) }})"
|
||||
style="flex-shrink:0;background:#2563eb;color:#fff;border:none;border-radius:5px;padding:4px 8px;cursor:pointer;font-size:12px;font-weight:700;">✓</button>
|
||||
<button type="button" onclick="cancelDesc({{ $i }})"
|
||||
style="flex-shrink:0;background:#f1f5f9;color:#64748b;border:1px solid #e2e8f0;border-radius:5px;padding:4px 8px;cursor:pointer;font-size:12px;">✕</button>
|
||||
</div>
|
||||
{{-- Hidden field submitted with form --}}
|
||||
<input type="hidden" name="items[{{ $i }}][supplier_description]" id="desc-hidden-{{ $i }}" value="">
|
||||
</td>
|
||||
<td>{{ rtrim(rtrim(number_format((float)$item->quantity_required, 3), '0'), '.') }}</td>
|
||||
<td style="color:#64748b;">{{ $item->unit ?: '—' }}</td>
|
||||
{{-- N/A checkbox --}}
|
||||
<td style="text-align:center;">
|
||||
<input type="checkbox" name="items[{{ $i }}][not_available]" id="na-{{ $i }}" value="1"
|
||||
onchange="toggleNA({{ $i }}, {{ (float)$item->quantity_required }})"
|
||||
style="width:16px;height:16px;accent-color:#dc2626;cursor:pointer;">
|
||||
</td>
|
||||
{{-- VAT checkbox --}}
|
||||
<td style="text-align:center;">
|
||||
@if($vatRate > 0)
|
||||
<input type="checkbox" name="items[{{ $i }}][is_vatable]" id="vat-cb-{{ $i }}" value="1"
|
||||
onchange="calcRow({{ $i }}, {{ (float)$item->quantity_required }})"
|
||||
style="width:16px;height:16px;accent-color:#2563eb;cursor:pointer;">
|
||||
@else
|
||||
<span style="color:#cbd5e1;font-size:11px;">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<input type="number" class="price" name="items[{{ $i }}][unit_price]"
|
||||
<input type="number" class="price" id="price-{{ $i }}" name="items[{{ $i }}][unit_price]"
|
||||
min="0" step="0.001" required placeholder="0.000"
|
||||
oninput="calcRow({{ $i }}, {{ (float)$item->quantity_required }})">
|
||||
</td>
|
||||
@ -100,8 +143,16 @@
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="background:#f8fafc;">
|
||||
<td colspan="5" style="text-align:right;font-size:13px;color:#475569;">Grand Total:</td>
|
||||
<td style="text-align:right;font-size:15px;color:#2563eb;" id="grand-total">BD 0.000</td>
|
||||
<td colspan="7" style="text-align:right;font-size:13px;color:#475569;">Subtotal:</td>
|
||||
<td style="text-align:right;font-size:14px;color:#475569;" id="subtotal-row">BD 0.000</td>
|
||||
</tr>
|
||||
<tr id="vat-tr" style="background:#eef2fb;{{ $vatRate > 0 ? '' : 'display:none;' }}">
|
||||
<td colspan="7" style="text-align:right;font-size:13px;color:#3b5ea6;">VAT ({{ $vatRate }}%):</td>
|
||||
<td style="text-align:right;font-size:14px;color:#3b5ea6;" id="vat-row">BD 0.000</td>
|
||||
</tr>
|
||||
<tr style="background:#f8fafc;border-top:2px solid #e2e8f0;">
|
||||
<td colspan="7" style="text-align:right;font-size:13px;color:#475569;font-weight:700;">Grand Total:</td>
|
||||
<td style="text-align:right;font-size:15px;color:#2563eb;font-weight:700;" id="grand-total">BD 0.000</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@ -204,17 +255,103 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var _totals = {};
|
||||
var _vatRate = {{ $vatRate }};
|
||||
var _totals = {};
|
||||
var _vatable = {};
|
||||
var _navail = {};
|
||||
|
||||
// ── Description inline edit ──────────────────────────────
|
||||
function editDesc(i) {
|
||||
document.getElementById('desc-display-' + i).style.display = 'none';
|
||||
var ed = document.getElementById('desc-edit-' + i);
|
||||
ed.style.display = 'flex';
|
||||
document.getElementById('desc-input-' + i).focus();
|
||||
}
|
||||
|
||||
function saveDesc(i, original) {
|
||||
var val = document.getElementById('desc-input-' + i).value.trim();
|
||||
if (!val) val = original;
|
||||
document.getElementById('desc-text-' + i).textContent = val;
|
||||
document.getElementById('desc-hidden-' + i).value = (val !== original) ? val : '';
|
||||
var adj = document.getElementById('desc-adj-' + i);
|
||||
adj.style.display = (val !== original) ? 'inline-block' : 'none';
|
||||
document.getElementById('desc-edit-' + i).style.display = 'none';
|
||||
document.getElementById('desc-display-' + i).style.display = 'flex';
|
||||
}
|
||||
|
||||
function cancelDesc(i) {
|
||||
var original = document.getElementById('desc-text-' + i).textContent;
|
||||
document.getElementById('desc-input-' + i).value = original;
|
||||
document.getElementById('desc-edit-' + i).style.display = 'none';
|
||||
document.getElementById('desc-display-' + i).style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── N/A toggle ───────────────────────────────────────────
|
||||
function toggleNA(i, qty) {
|
||||
var naCb = document.getElementById('na-' + i);
|
||||
var priceIn = document.getElementById('price-' + i);
|
||||
var vatCb = document.getElementById('vat-cb-' + i);
|
||||
var totEl = document.getElementById('tot-' + i);
|
||||
var row = document.getElementById('row-' + i);
|
||||
var na = naCb.checked;
|
||||
_navail[i] = na;
|
||||
|
||||
if (na) {
|
||||
priceIn.value = '';
|
||||
priceIn.disabled = true;
|
||||
priceIn.removeAttribute('required');
|
||||
priceIn.style.opacity = '.35';
|
||||
if (vatCb) { vatCb.checked = false; vatCb.disabled = true; vatCb.style.opacity = '.35'; }
|
||||
totEl.innerHTML = '<span style="font-size:11px;font-weight:700;color:#dc2626;background:#fef2f2;padding:2px 7px;border-radius:4px;">Not available</span>';
|
||||
row.style.opacity = '.6';
|
||||
_totals[i] = 0;
|
||||
_vatable[i] = false;
|
||||
} else {
|
||||
priceIn.disabled = false;
|
||||
priceIn.setAttribute('required', '');
|
||||
priceIn.style.opacity = '1';
|
||||
if (vatCb) { vatCb.disabled = false; vatCb.style.opacity = '1'; }
|
||||
totEl.textContent = '—';
|
||||
row.style.opacity = '1';
|
||||
_totals[i] = 0;
|
||||
_vatable[i] = false;
|
||||
calcRow(i, qty);
|
||||
return;
|
||||
}
|
||||
recalcTotals();
|
||||
}
|
||||
|
||||
// ── Price / VAT calculation ──────────────────────────────
|
||||
function calcRow(i, qty) {
|
||||
var inp = document.querySelector('input[name="items[' + i + '][unit_price]"]');
|
||||
if (_navail[i]) return;
|
||||
var inp = document.getElementById('price-' + i);
|
||||
var cb = document.getElementById('vat-cb-' + i);
|
||||
var up = parseFloat(inp ? inp.value : 0) || 0;
|
||||
var tot = Math.round(up * qty * 1000) / 1000;
|
||||
_totals[i] = tot;
|
||||
_totals[i] = tot;
|
||||
_vatable[i] = cb ? cb.checked : false;
|
||||
var el = document.getElementById('tot-' + i);
|
||||
if (el) el.textContent = 'BD ' + tot.toFixed(3);
|
||||
var grand = Object.values(_totals).reduce(function(a,b){return a+b;}, 0);
|
||||
var ge = document.getElementById('grand-total');
|
||||
if (ge) ge.textContent = 'BD ' + grand.toFixed(3);
|
||||
if (el) el.textContent = tot > 0 ? 'BD ' + tot.toFixed(3) : '—';
|
||||
recalcTotals();
|
||||
}
|
||||
|
||||
function recalcTotals() {
|
||||
var subtotal = 0;
|
||||
var vatAmount = 0;
|
||||
Object.keys(_totals).forEach(function(i) {
|
||||
if (_navail[i]) return;
|
||||
subtotal += _totals[i];
|
||||
if (_vatable[i] && _vatRate > 0) {
|
||||
vatAmount += Math.round(_totals[i] * _vatRate / 100 * 1000) / 1000;
|
||||
}
|
||||
});
|
||||
var grand = Math.round((subtotal + vatAmount) * 1000) / 1000;
|
||||
var elSub = document.getElementById('subtotal-row');
|
||||
var elVat = document.getElementById('vat-row');
|
||||
var elGrand = document.getElementById('grand-total');
|
||||
if (elSub) elSub.textContent = 'BD ' + subtotal.toFixed(3);
|
||||
if (elVat) elVat.textContent = 'BD ' + vatAmount.toFixed(3);
|
||||
if (elGrand) elGrand.textContent = 'BD ' + grand.toFixed(3);
|
||||
}
|
||||
|
||||
var _expected = '{{ $confirmCode }}';
|
||||
|
||||
59
resources/views/settings/vat.blade.php
Normal file
59
resources/views/settings/vat.blade.php
Normal file
@ -0,0 +1,59 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Settings — VAT')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-5">
|
||||
<h1 class="page-title">VAT Settings</h1>
|
||||
<p class="page-subtitle">Set the global VAT rate applied to vatable items on supplier quotes.</p>
|
||||
</div>
|
||||
|
||||
<div style="max-width:480px;">
|
||||
<div style="background:white;border:1px solid #e2e8f0;border-radius:0.875rem;overflow:hidden;">
|
||||
<div style="padding:1.25rem 1.5rem;border-bottom:1px solid #e2e8f0;background:#f8fafc;">
|
||||
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;">VAT Configuration</div>
|
||||
</div>
|
||||
<div style="padding:1.5rem;">
|
||||
<label style="display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:6px;">
|
||||
VAT Rate (%)
|
||||
</label>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<input type="number" id="vat-rate-input" value="{{ $vatRate }}"
|
||||
min="0" max="100" step="0.01" placeholder="e.g. 10"
|
||||
style="width:160px;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:14px;font-weight:600;outline:none;"
|
||||
onfocus="this.style.borderColor='#2563eb'" onblur="this.style.borderColor='#e2e8f0'">
|
||||
<span style="font-size:14px;color:#64748b;font-weight:500;">%</span>
|
||||
</div>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:8px;">
|
||||
Enter 0 to disable VAT. Suppliers will see a VAT checkbox on each item when this is greater than 0.
|
||||
</p>
|
||||
<button onclick="saveVat()"
|
||||
style="margin-top:20px;padding:10px 24px;background:#2563eb;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">
|
||||
Save VAT Rate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
||||
function saveVat() {
|
||||
var rate = document.getElementById('vat-rate-input').value;
|
||||
fetch('{{ route('settings.vat.update') }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vat_rate: rate })
|
||||
}).then(function(r) {
|
||||
return r.json().then(function(body) {
|
||||
if (!r.ok) return Promise.reject(body);
|
||||
return body;
|
||||
});
|
||||
}).then(function() {
|
||||
showToast('VAT rate saved.', 'success');
|
||||
}).catch(function(err) {
|
||||
var msg = (err.errors && err.errors.vat_rate) ? err.errors.vat_rate[0] : (err.message || 'Failed to save.');
|
||||
showToast(msg, 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@ -29,6 +29,7 @@ use App\Http\Controllers\Sales\SalesOrderController;
|
||||
use App\Http\Controllers\MailAccountController;
|
||||
use App\Http\Controllers\SettingsController;
|
||||
use App\Http\Controllers\Settings\ProjectSettingController;
|
||||
use App\Http\Controllers\Settings\VatSettingController;
|
||||
use App\Models\Settings\Location;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@ -163,6 +164,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('settings/projects/companies/{company}/departments', [ProjectSettingController::class, 'storeDepartment'])->name('settings.projects.companies.departments.store');
|
||||
Route::patch('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'updateDepartment'])->name('settings.projects.companies.departments.update');
|
||||
Route::delete('settings/projects/companies/{company}/departments/{department}', [ProjectSettingController::class, 'destroyDepartment'])->name('settings.projects.companies.departments.destroy');
|
||||
|
||||
// VAT settings
|
||||
Route::get('settings/vat', [VatSettingController::class, 'index'])->name('settings.vat');
|
||||
Route::post('settings/vat', [VatSettingController::class, 'update'])->name('settings.vat.update');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user