Compare commits

..

No commits in common. "30b2fb3958d1316418f2933337baffaba210207e" and "dca9cd5d993ff4295307bbafa26d61f33b996866" have entirely different histories.

53 changed files with 53 additions and 2627 deletions

2
.gitignore vendored
View File

@ -19,8 +19,6 @@
/storage/*.key
/storage/pail
/vendor
/packages/azure-mailer/vendor
/packages/ultra-message/vendor
Homestead.json
Homestead.yaml
Thumbs.db

View File

@ -4,7 +4,6 @@ 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;
@ -47,9 +46,7 @@ class RfqPortalController extends Controller
$confirmCode = strtoupper(substr(bin2hex(random_bytes(3)), 0, 5));
session(['rfq_confirm_' . $token => $confirmCode]);
$vatRate = (float) Setting::get('vat_rate', 0);
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode', 'vatRate'));
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items', 'confirmCode'));
}
public function submit(Request $request, string $token)
@ -61,16 +58,13 @@ 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' => ['nullable', 'numeric', 'min:0'],
'items.*.is_vatable' => ['nullable', 'boolean'],
'items.*.not_available' => ['nullable', 'boolean'],
'items.*.supplier_description' => ['nullable', 'string', 'max:500'],
'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'],
]);
$expectedCode = session('rfq_confirm_' . $token);
@ -95,40 +89,24 @@ class RfqPortalController extends Controller
'total_amount' => 0,
]);
$subtotal = 0;
$vatAmount = 0;
$vatRate = (float) Setting::get('vat_rate', 0);
$total = 0;
foreach ($purchaseItems as $i => $item) {
$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);
}
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
$qty = (float)$item->quantity_required;
$totalPrice = round($unitPrice * $qty, 3);
$total += $totalPrice;
SupplierQuoteItem::create([
'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,
'supplier_quote_id' => $quote->id,
'description' => $item->description,
'unit' => $item->unit ?? '',
'quantity' => $qty,
'unit_price' => $unitPrice,
'total_price' => $totalPrice,
]);
}
$quote->update(['total_amount' => round($subtotal + $vatAmount, 3)]);
$quote->update(['total_amount' => round($total, 3)]);
$invitation->update(['status' => 'submitted']);
// If at least 1 quote is in, move to comparison stage

View File

@ -1,27 +0,0 @@
<?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']]);
}
}

View File

@ -7,13 +7,7 @@ use Illuminate\Database\Eloquent\Model;
class SupplierQuoteItem extends Model
{
protected $fillable = [
'supplier_quote_id', 'description', 'supplier_description', 'unit', 'quantity',
'unit_price', 'total_price', 'is_vatable', 'not_available',
];
protected $casts = [
'is_vatable' => 'boolean',
'not_available' => 'boolean',
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price',
];
public function quote()

View File

@ -8,11 +8,11 @@
"repositories": [
{
"type": "path",
"url": "./packages/ultra-message"
"url": "../ultra-message"
},
{
"type": "path",
"url": "./packages/azure-mailer"
"url": "../azure-mailer"
}
],
"require": {

8
composer.lock generated
View File

@ -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": "644cad003bc58ba3ea106336e06e0396",
"content-hash": "5daed94f08a2aff0bba86affd37a0cd6",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@ -3283,8 +3283,8 @@
"version": "dev-master",
"dist": {
"type": "path",
"url": "./packages/azure-mailer",
"reference": "b6d04a3e3a01a8439853efe8f52f672ae8a04b75"
"url": "../azure-mailer",
"reference": "d240f4f64afcd094b30cd460b82f809f3b2003f9"
},
"require": {
"illuminate/cache": "^11.0|^12.0",
@ -3334,7 +3334,7 @@
"version": "dev-master",
"dist": {
"type": "path",
"url": "./packages/ultra-message",
"url": "../ultra-message",
"reference": "56160235808f7d9d9dd3ec8d0eefa20a17807647"
},
"require": {

View File

@ -1,25 +0,0 @@
<?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');
});
}
};

View File

@ -1,26 +0,0 @@
<?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']);
});
}
};

View File

@ -1,440 +0,0 @@
# 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"
```

View File

@ -1,133 +0,0 @@
# 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

View File

@ -1,3 +0,0 @@
/vendor/
composer.lock
*.cache

View File

@ -1,82 +0,0 @@
# 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

View File

@ -1,40 +0,0 @@
{
"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
}

View File

@ -1,7 +0,0 @@
<?php
return [
'save_to_sent_items' => false,
'timeout' => 30,
'graph_api_version' => 'v1.0',
];

View File

@ -1,11 +0,0 @@
<?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>

View File

@ -1,37 +0,0 @@
<?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
);
});
});
}
}

View File

@ -1,13 +0,0 @@
<?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}");
}
}

View File

@ -1,13 +0,0 @@
<?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}");
}
}

View File

@ -1,51 +0,0 @@
<?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";
}
}

View File

@ -1,63 +0,0 @@
<?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'];
}
}

View File

@ -1,86 +0,0 @@
<?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;
}
}

View File

@ -1,29 +0,0 @@
<?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());
}
}

View File

@ -1,124 +0,0 @@
<?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/');
});
}
}

View File

@ -1,119 +0,0 @@
<?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();
}
}

View File

@ -1,33 +0,0 @@
<?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);
}
}

View File

@ -1,21 +0,0 @@
<?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');
}
}

View File

@ -1,131 +0,0 @@
<?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);
}
}

View File

@ -1,2 +0,0 @@
/vendor/
composer.lock

View File

@ -1,42 +0,0 @@
{
"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
}

View File

@ -1,10 +0,0 @@
<?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),
];

View File

@ -1,11 +0,0 @@
<?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>

View File

@ -1 +0,0 @@

View File

@ -1,7 +0,0 @@
<?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');

View File

@ -1,13 +0,0 @@
<?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) {}
}

View File

@ -1,47 +0,0 @@
<?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);
}
}

View File

@ -1,29 +0,0 @@
<?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);
}
}

View File

@ -1,39 +0,0 @@
<?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}"),
};
}
}

View File

@ -1,187 +0,0 @@
<?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/');
}
}

View File

@ -1,7 +0,0 @@
<?php
namespace PromoSeven\UltraMessage;
use RuntimeException;
class UltraMessageException extends RuntimeException {}

View File

@ -1,49 +0,0 @@
<?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;
}
}

View File

@ -1,67 +0,0 @@
<?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;
}
}

View File

@ -1,37 +0,0 @@
<?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');
}
}

View File

@ -1,21 +0,0 @@
<?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);
}
}

View File

@ -1,110 +0,0 @@
<?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();
}
}

View File

@ -1,128 +0,0 @@
<?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';
});
}
}

View File

@ -197,17 +197,6 @@
</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>

View File

@ -48,7 +48,7 @@
{{-- Per-item rows --}}
@foreach($items as $i => $reqItem)
@php
$rowPrices = $quotes->map(fn($q) => ($q->items->get($i) && !$q->items->get($i)->not_available) ? $q->items->get($i)->unit_price : null)->filter()->values();
$rowPrices = $quotes->map(fn($q) => optional($q->items->get($i))->unit_price)->filter()->values();
$minPrice = $rowPrices->count() ? $rowPrices->min() : null;
@endphp
<tr>
@ -57,24 +57,14 @@
<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 && !$qItem->not_available && $minPrice !== null && (float)$qItem->unit_price === (float)$minPrice && $rowPrices->count() > 1; @endphp
@php $qItem = $q->items->get($i); $isMin = $qItem && $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)
@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
<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>
@else
<span style="color:#e2e8f0;"></span>
@endif

View File

@ -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: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;}
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;}
.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:980px;margin:0 auto;overflow:hidden;}
.hd{background:linear-gradient(135deg,#2563eb,#1d4ed8);padding:28px 36px;}
.body{padding:36px;}
.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;}
}
/* ── Mobile: full page ── */
@ -78,62 +78,19 @@
<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 id="row-{{ $i }}">
<tr>
<td style="color:#94a3b8;font-size:12px;">{{ $i + 1 }}</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 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>
{{-- 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" id="price-{{ $i }}" name="items[{{ $i }}][unit_price]"
<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>
@ -143,16 +100,8 @@
</tbody>
<tfoot>
<tr style="background:#f8fafc;">
<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>
<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>
</tr>
</tfoot>
</table>
@ -255,103 +204,17 @@
</div>
<script>
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 ──────────────────────────────
var _totals = {};
function calcRow(i, qty) {
if (_navail[i]) return;
var inp = document.getElementById('price-' + i);
var cb = document.getElementById('vat-cb-' + i);
var inp = document.querySelector('input[name="items[' + i + '][unit_price]"]');
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;
_totals[i] = tot;
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) {
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);
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);
}
var _expected = '{{ $confirmCode }}';

View File

@ -1,59 +0,0 @@
@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

View File

@ -29,7 +29,6 @@ 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;
@ -164,10 +163,6 @@ 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');
});
});