mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 12:51:00 +00:00
feat(sales): fresh sales implementation
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Application;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
|
||||
/**
|
||||
* Renders document numbers from a per-company format string.
|
||||
*
|
||||
* A format is a run of `{{NAME}}` / `{{NAME:value}}` tokens; anything outside a
|
||||
* recognised token contributes nothing to the result. Tokens naming a sequence
|
||||
* are resolved against the highest number already stored for the company (and,
|
||||
* for the per-customer sequence, the customer), so the rendered number is the
|
||||
* one the document about to be written should carry.
|
||||
*/
|
||||
class SerialNumberService
|
||||
{
|
||||
public const VALID_PLACEHOLDERS = ['CUSTOMER_SERIES', 'SEQUENCE', 'DATE_FORMAT', 'SERIES', 'RANDOM_SEQUENCE', 'DELIMITER', 'CUSTOMER_SEQUENCE'];
|
||||
|
||||
/**
|
||||
* Bytes a token name is spelled with.
|
||||
*/
|
||||
private const NAME_BYTES = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_';
|
||||
|
||||
/**
|
||||
* Bytes a token value is spelled with when it runs longer than one byte.
|
||||
*/
|
||||
private const VALUE_BYTES = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_';
|
||||
|
||||
/**
|
||||
* Longest multi-byte token value that is still read as one value.
|
||||
*/
|
||||
private const VALUE_LIMIT = 6;
|
||||
|
||||
private $model;
|
||||
|
||||
private $ob;
|
||||
|
||||
private $customer;
|
||||
|
||||
private $company;
|
||||
|
||||
private $settingKey;
|
||||
|
||||
private $sequenceScope = [];
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $nextSequenceNumber;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $nextCustomerSequenceNumber;
|
||||
|
||||
/**
|
||||
* Point the service at the model class whose rows carry the sequences.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setModel($model)
|
||||
{
|
||||
$this->model = $model;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt an existing row's sequences so an update keeps its numbers.
|
||||
*
|
||||
* The per-customer sequence is only adopted while the row still belongs to
|
||||
* the customer in play; moving a document to another customer therefore
|
||||
* leaves it to be renumbered for the new one.
|
||||
*/
|
||||
public function setModelObject($id = null)
|
||||
{
|
||||
$this->ob = $this->model::find($id);
|
||||
|
||||
if ($this->ob && $this->ob->sequence_number) {
|
||||
$this->nextSequenceNumber = $this->ob->sequence_number;
|
||||
}
|
||||
|
||||
if (isset($this->ob->customer_sequence_number, $this->customer)
|
||||
&& $this->ob->customer_id == $this->customer->id) {
|
||||
$this->nextCustomerSequenceNumber = $this->ob->customer_sequence_number;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setCompany($company)
|
||||
{
|
||||
$this->company = $company;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the customer the per-customer sequence and series belong to.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setCustomer($customer = null)
|
||||
{
|
||||
$this->customer = Customer::find($customer);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the company setting the number format is read from.
|
||||
*
|
||||
* Without this the key is derived from the model class name, which is not
|
||||
* enough for documents that share a table (credit notes are Invoice rows
|
||||
* but carry their own format).
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setSettingKey(string $key)
|
||||
{
|
||||
$this->settingKey = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the sequence lookups to a subset of the model's rows.
|
||||
*
|
||||
* Takes column => value constraints that are applied on top of the company
|
||||
* (and customer) filters, so documents sharing a table can each keep an
|
||||
* independent, gapless sequence.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setSequenceScope(array $constraints)
|
||||
{
|
||||
$this->sequenceScope = $constraints;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the number the next document should carry.
|
||||
*
|
||||
* Passing no format falls back to the company setting for this document
|
||||
* kind.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getNextNumber(?string $format = null)
|
||||
{
|
||||
$derivedKey = strtolower(class_basename($this->model)).'_number_format';
|
||||
|
||||
if ($format === null) {
|
||||
$format = CompanySetting::getSetting(
|
||||
$this->settingKey ?: $derivedKey,
|
||||
$this->company
|
||||
);
|
||||
}
|
||||
|
||||
$this->setNextNumbers();
|
||||
|
||||
return $this->generateSerialNumber($format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in whichever of the two sequences is still unresolved.
|
||||
*/
|
||||
public function setNextNumbers()
|
||||
{
|
||||
if (! $this->nextSequenceNumber) {
|
||||
$this->setNextSequenceNumber();
|
||||
}
|
||||
|
||||
if (! $this->nextCustomerSequenceNumber) {
|
||||
$this->setNextCustomerSequenceNumber();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company-wide sequence as one past the highest in use.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setNextSequenceNumber()
|
||||
{
|
||||
$highest = $this->scopedQuery()
|
||||
->whereNotNull('sequence_number')
|
||||
->orderByDesc('sequence_number')
|
||||
->first();
|
||||
|
||||
$this->nextSequenceNumber = $highest ? $highest->sequence_number + 1 : 1;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-customer sequence as one past the highest in use.
|
||||
*
|
||||
* With no customer resolved the lookup falls back to customer 1 rather
|
||||
* than skipping the customer filter.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setNextCustomerSequenceNumber()
|
||||
{
|
||||
$highest = $this->scopedQuery()
|
||||
->where('customer_id', $this->customer ? $this->customer->id : 1)
|
||||
->whereNotNull('customer_sequence_number')
|
||||
->orderByDesc('customer_sequence_number')
|
||||
->first();
|
||||
|
||||
$this->nextCustomerSequenceNumber = $highest ? $highest->customer_sequence_number + 1 : 1;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the recognised tokens of a format, in the order they appear.
|
||||
*
|
||||
* Each entry is a `name` / `value` pair; a token written without a value
|
||||
* yields an empty string. Tokens whose name is not one this service knows
|
||||
* about are dropped, as is any text between tokens.
|
||||
*/
|
||||
public static function getPlaceholders(string $format)
|
||||
{
|
||||
$recognised = collect();
|
||||
$end = strlen($format);
|
||||
$cursor = 0;
|
||||
|
||||
while ($cursor < $end) {
|
||||
$token = self::readToken($format, $cursor, $end);
|
||||
|
||||
if ($token === null) {
|
||||
$cursor++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $value, $cursor] = $token;
|
||||
|
||||
if (in_array($name, self::VALID_PLACEHOLDERS)) {
|
||||
$recognised->push([
|
||||
'name' => $name,
|
||||
'value' => $value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $recognised;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the token opening at the given offset, if there is one.
|
||||
*
|
||||
* Both the name/value split and the value itself are ambiguous: a value
|
||||
* may be written with or without a leading colon, and may be either a run
|
||||
* of word bytes or a single arbitrary byte. Candidates are therefore tried
|
||||
* longest-name first, colon first, and word-run before single byte; the
|
||||
* first spelling whose closing braces line up wins.
|
||||
*
|
||||
* @return array{0: string, 1: string, 2: int}|null name, value, offset just past the token
|
||||
*/
|
||||
private static function readToken(string $format, int $start, int $end)
|
||||
{
|
||||
if (substr($format, $start, 2) !== '{{') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nameAt = $start + 2;
|
||||
|
||||
for ($width = self::runLength($format, self::NAME_BYTES, $nameAt, $end); $width > 0; $width--) {
|
||||
foreach ([true, false] as $colon) {
|
||||
$valueAt = $nameAt + $width;
|
||||
|
||||
if ($colon) {
|
||||
if (($format[$valueAt] ?? null) !== ':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$valueAt++;
|
||||
}
|
||||
|
||||
$value = self::readValue($format, $valueAt, $end);
|
||||
|
||||
if ($value !== null) {
|
||||
return [substr($format, $nameAt, $width), $value[0], $value[1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a token's value plus its closing braces at the given offset.
|
||||
*
|
||||
* @return array{0: string, 1: int}|null value, offset just past the closing braces
|
||||
*/
|
||||
private static function readValue(string $format, int $at, int $end)
|
||||
{
|
||||
$run = self::runLength($format, self::VALUE_BYTES, $at, $end);
|
||||
|
||||
if ($run > 0 && $run <= self::VALUE_LIMIT && substr($format, $at + $run, 2) === '}}') {
|
||||
return [substr($format, $at, $run), $at + $run + 2];
|
||||
}
|
||||
|
||||
$byte = $format[$at] ?? null;
|
||||
|
||||
if ($byte !== null && $byte !== "\n" && substr($format, $at + 1, 2) === '}}') {
|
||||
return [$byte, $at + 3];
|
||||
}
|
||||
|
||||
if (substr($format, $at, 2) === '}}') {
|
||||
return ['', $at + 2];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the bytes at the given offset that belong to the given set.
|
||||
*/
|
||||
private static function runLength(string $format, string $bytes, int $at, int $end): int
|
||||
{
|
||||
return $at < $end ? strspn($format, $bytes, $at) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate what every recognised token of the format renders to.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateSerialNumber(string $format)
|
||||
{
|
||||
$serialNumber = '';
|
||||
|
||||
foreach (self::getPlaceholders($format) as $placeholder) {
|
||||
$serialNumber .= $this->renderPlaceholder($placeholder['name'], $placeholder['value']);
|
||||
}
|
||||
|
||||
return $serialNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one token.
|
||||
*
|
||||
* A token whose name is not one of the computed ones (a series or a
|
||||
* delimiter) simply renders its own value.
|
||||
*/
|
||||
private function renderPlaceholder(string $name, string $value): string
|
||||
{
|
||||
return match ($name) {
|
||||
'SEQUENCE' => str_pad($this->nextSequenceNumber, $value ?: 6, 0, STR_PAD_LEFT),
|
||||
'CUSTOMER_SEQUENCE' => str_pad($this->nextCustomerSequenceNumber, $value, 0, STR_PAD_LEFT),
|
||||
'DATE_FORMAT' => date($value ?: 'Y'),
|
||||
'RANDOM_SEQUENCE' => substr(bin2hex(random_bytes($value ?: 6)), 0, $value ?: 6),
|
||||
'CUSTOMER_SERIES' => isset($this->customer) ? ($this->customer->prefix ?? 'CST') : 'CST',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a lookup narrowed to the company and the configured scope.
|
||||
*/
|
||||
private function scopedQuery()
|
||||
{
|
||||
$query = $this->model::query()->where('company_id', $this->company);
|
||||
|
||||
foreach ($this->sequenceScope as $column => $value) {
|
||||
$query->where($column, $value);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Console;
|
||||
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Daily sweep that retires estimates whose offer has run out.
|
||||
*
|
||||
* Anything that has not already reached a terminal state — accepted, rejected
|
||||
* or expired — and whose expiry date fell before today is moved to expired.
|
||||
* Drafts are swept along with the rest, and the comparison is on the date
|
||||
* alone, so an estimate expiring today survives until tomorrow.
|
||||
*/
|
||||
class CheckEstimateStatus extends Command
|
||||
{
|
||||
protected $signature = 'check:estimates:status';
|
||||
|
||||
protected $description = 'Check invoices status.';
|
||||
|
||||
/**
|
||||
* Expire every estimate that has outlived its expiry date.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$today = Carbon::now();
|
||||
|
||||
$expired = Estimate::STATUS_EXPIRED;
|
||||
|
||||
$settled = [
|
||||
Estimate::STATUS_ACCEPTED,
|
||||
Estimate::STATUS_REJECTED,
|
||||
$expired,
|
||||
];
|
||||
|
||||
$lapsed = Estimate::whereNotIn('status', $settled)
|
||||
->whereDate('expiry_date', '<', $today)
|
||||
->get();
|
||||
|
||||
foreach ($lapsed as $estimate) {
|
||||
$estimate->status = $expired;
|
||||
printf("Estimate %s is EXPIRED \n", $estimate->estimate_number);
|
||||
$estimate->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Console;
|
||||
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Daily sweep that flags invoices nobody paid in time.
|
||||
*
|
||||
* A document qualifies when it is a plain invoice — a credit note is never
|
||||
* owed, so it stays out of the sweep whatever date it carries — is not already
|
||||
* flagged, has left the draft stage without reaching completion, and its due
|
||||
* date fell before today. The comparison is on the date alone, so an invoice
|
||||
* due today is still in good standing until tomorrow.
|
||||
*/
|
||||
class CheckInvoiceStatus extends Command
|
||||
{
|
||||
protected $signature = 'check:invoices:status';
|
||||
|
||||
protected $description = 'Check invoices status.';
|
||||
|
||||
/**
|
||||
* Flag every invoice that has slipped past its due date.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$today = Carbon::now();
|
||||
|
||||
$exempt = [Invoice::STATUS_COMPLETED, Invoice::STATUS_DRAFT];
|
||||
|
||||
$overdue = Invoice::where('type', Invoice::TYPE_INVOICE)
|
||||
->whereNotIn('status', $exempt)
|
||||
->where('overdue', false)
|
||||
->whereDate('due_date', '<', $today)
|
||||
->get();
|
||||
|
||||
foreach ($overdue as $invoice) {
|
||||
$invoice->overdue = true;
|
||||
printf("Invoice %s is OVERDUE \n", $invoice->invoice_number);
|
||||
$invoice->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EstimateTemplatesController extends Controller
|
||||
{
|
||||
/**
|
||||
* List the PDF templates an estimate can be rendered with, each already
|
||||
* paired with its preview image.
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Estimate::class);
|
||||
|
||||
return response()->json([
|
||||
'estimateTemplates' => PdfTemplateUtils::getFormattedTemplates('estimate'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Sales\Application\EstimateService;
|
||||
use App\Domains\Sales\Http\Requests\DeleteEstimatesRequest;
|
||||
use App\Domains\Sales\Http\Requests\EstimatesRequest;
|
||||
use App\Domains\Sales\Http\Requests\SendEstimatesRequest;
|
||||
use App\Domains\Sales\Http\Resources\EstimateResource;
|
||||
use App\Domains\Sales\Http\Resources\InvoiceResource;
|
||||
use App\Domains\Sales\Jobs\GenerateEstimatePdfJob;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Mail\Markdown;
|
||||
|
||||
/**
|
||||
* Company-scoped estimate endpoints: listing, the write surface, bulk removal,
|
||||
* mailing, and the two document conversions.
|
||||
*/
|
||||
class EstimatesController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EstimateService $estimateService) {}
|
||||
|
||||
/**
|
||||
* Paginated estimates of the active company, joined to their customer so the
|
||||
* list can be filtered and sorted by customer name.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Estimate::class);
|
||||
|
||||
$filters = $request->all();
|
||||
$perPage = $request->has('limit') ? $request->input('limit') : 10;
|
||||
|
||||
$page = Estimate::query()
|
||||
->whereCompany()
|
||||
->join('customers', fn ($join) => $join->on('customers.id', '=', 'estimates.customer_id'))
|
||||
->applyFilters($filters)
|
||||
->select(['estimates.*', 'customers.name'])
|
||||
->orderByDesc('created_at')
|
||||
->paginateData($perPage);
|
||||
|
||||
return EstimateResource::collection($page)->additional([
|
||||
'meta' => [
|
||||
'estimate_total_count' => Estimate::query()->whereCompany()->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new estimate, optionally mailing it straight away, and queue the
|
||||
* PDF render.
|
||||
*/
|
||||
public function store(EstimatesRequest $request)
|
||||
{
|
||||
$this->authorize('create', Estimate::class);
|
||||
|
||||
$estimate = $this->estimateService->create(...$this->writeArguments($request));
|
||||
|
||||
if ($request->has('estimateSend')) {
|
||||
$this->estimateService->send($estimate, $request->only(['title', 'body']));
|
||||
}
|
||||
|
||||
GenerateEstimatePdfJob::dispatch($estimate);
|
||||
|
||||
return EstimateResource::make($estimate);
|
||||
}
|
||||
|
||||
public function show(Request $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('view', $estimate);
|
||||
|
||||
return EstimateResource::make($estimate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite an estimate — lines and taxes are replaced wholesale — and
|
||||
* re-render its PDF.
|
||||
*/
|
||||
public function update(EstimatesRequest $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('update', $estimate);
|
||||
|
||||
$estimate = $this->estimateService->update($estimate, ...$this->writeArguments($request));
|
||||
|
||||
GenerateEstimatePdfJob::dispatch($estimate, true);
|
||||
|
||||
return EstimateResource::make($estimate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk removal. Ids outside the active company are silently skipped.
|
||||
*/
|
||||
public function delete(DeleteEstimatesRequest $request)
|
||||
{
|
||||
$this->authorize('delete multiple estimates');
|
||||
|
||||
$ids = Estimate::query()
|
||||
->whereCompany()
|
||||
->whereIn('id', $request->input('ids'))
|
||||
->pluck('id');
|
||||
|
||||
Estimate::destroy($ids);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function send(SendEstimatesRequest $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('send estimate', $estimate);
|
||||
|
||||
return response()->json(
|
||||
$this->estimateService->send($estimate, $request->all())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the mail body the customer would receive, without sending it.
|
||||
*/
|
||||
public function sendPreview(SendEstimatesRequest $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('send estimate', $estimate);
|
||||
|
||||
$data = $this->estimateService->sendEstimateData($estimate, $request->all());
|
||||
$data['url'] = $estimate->estimatePdfUrl;
|
||||
|
||||
$renderer = new Markdown(view(), config('mail.markdown'));
|
||||
|
||||
return $renderer->render('emails.send.estimate', ['data' => $data]);
|
||||
}
|
||||
|
||||
public function clone(Request $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('view', $estimate);
|
||||
$this->authorize('create', Estimate::class);
|
||||
|
||||
return EstimateResource::make($this->estimateService->clone($estimate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading the source estimate is checked on top of the invoice-create
|
||||
* ability so the conversion cannot reach across companies.
|
||||
*/
|
||||
public function convertToInvoice(Request $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('view', $estimate);
|
||||
$this->authorize('create', Invoice::class);
|
||||
|
||||
return InvoiceResource::make($this->estimateService->convertToInvoice($estimate));
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, Estimate $estimate)
|
||||
{
|
||||
$this->authorize('send estimate', $estimate);
|
||||
|
||||
$this->estimateService->changeStatus($estimate, $request->input('status'));
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments create() and update() share, keyed by parameter name.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function writeArguments(EstimatesRequest $request): array
|
||||
{
|
||||
$fields = $request->input('customFields');
|
||||
|
||||
return [
|
||||
'attributes' => $request->getEstimatePayload(),
|
||||
'items' => $request->input('items'),
|
||||
'taxes' => $request->has('taxes') ? $request->input('taxes') : null,
|
||||
'customFields' => is_iterable($fields) ? $fields : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InvoiceTemplatesController extends Controller
|
||||
{
|
||||
/**
|
||||
* List the PDF templates an invoice can be rendered with, each already
|
||||
* paired with its preview image.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', Invoice::class);
|
||||
|
||||
return response()->json([
|
||||
'invoiceTemplates' => PdfTemplateUtils::getFormattedTemplates('invoice'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Sales\Application\RecurringInvoiceService;
|
||||
use App\Domains\Sales\Http\Requests\RecurringInvoiceRequest;
|
||||
use App\Domains\Sales\Http\Resources\RecurringInvoiceResource;
|
||||
use App\Domains\Sales\Models\RecurringInvoice;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* The admin surface for standing orders — the schedules that mint invoices on
|
||||
* a cron expression.
|
||||
*
|
||||
* Each row is an invoice held in template form, so the write endpoints hand
|
||||
* the service the same three parcels a document does: the row's own columns,
|
||||
* the line items, and the document-level taxes.
|
||||
*/
|
||||
class RecurringInvoiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RecurringInvoiceService $recurringInvoiceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Page through the company's schedules.
|
||||
*
|
||||
* Alongside the page itself the payload carries how many schedules the
|
||||
* company holds in total, which the listing screen shows even when a
|
||||
* filter has narrowed the rows down to a handful.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->authorize('viewAny', RecurringInvoice::class);
|
||||
|
||||
$perPage = $request->has('limit') ? $request->input('limit') : 10;
|
||||
|
||||
$schedules = RecurringInvoice::whereCompany()
|
||||
->applyFilters($request->all())
|
||||
->paginateData($perPage);
|
||||
|
||||
$companyTotal = RecurringInvoice::whereCompany()->count();
|
||||
|
||||
return RecurringInvoiceResource::collection($schedules)
|
||||
->additional(['meta' => [
|
||||
'recurring_invoice_total_count' => $companyTotal,
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a new schedule from the submitted template.
|
||||
*/
|
||||
public function store(RecurringInvoiceRequest $request)
|
||||
{
|
||||
$this->authorize('create', RecurringInvoice::class);
|
||||
|
||||
$schedule = $this->recurringInvoiceService->create(
|
||||
attributes: $request->getRecurringInvoicePayload(),
|
||||
items: $request->input('items'),
|
||||
taxes: $request->has('taxes') ? $request->input('taxes') : null,
|
||||
customFields: $this->customFields($request),
|
||||
);
|
||||
|
||||
return new RecurringInvoiceResource($schedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one schedule.
|
||||
*/
|
||||
public function show(RecurringInvoice $recurringInvoice)
|
||||
{
|
||||
$this->authorize('view', $recurringInvoice);
|
||||
|
||||
return new RecurringInvoiceResource($recurringInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restate a schedule, template and all.
|
||||
*
|
||||
* Items and taxes are replaced wholesale rather than reconciled, so the
|
||||
* submission is the schedule's new contents in full.
|
||||
*/
|
||||
public function update(RecurringInvoiceRequest $request, RecurringInvoice $recurringInvoice)
|
||||
{
|
||||
$this->authorize('update', $recurringInvoice);
|
||||
|
||||
$this->recurringInvoiceService->update(
|
||||
recurringInvoice: $recurringInvoice,
|
||||
attributes: $request->getRecurringInvoicePayload(),
|
||||
items: $request->input('items'),
|
||||
taxes: $request->has('taxes') ? $request->input('taxes') : null,
|
||||
customFields: $this->customFields($request),
|
||||
);
|
||||
|
||||
return new RecurringInvoiceResource($recurringInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop several schedules at once.
|
||||
*
|
||||
* The submitted ids are narrowed to the acting company before anything is
|
||||
* removed, so ids belonging elsewhere are quietly passed over. Invoices
|
||||
* already minted by a dropped schedule survive it — they are merely cut
|
||||
* loose from the parent.
|
||||
*/
|
||||
public function delete(Request $request)
|
||||
{
|
||||
$this->authorize('delete multiple recurring invoices');
|
||||
|
||||
$ids = RecurringInvoice::whereCompany()
|
||||
->whereIn('id', $request->input('ids'))
|
||||
->pluck('id');
|
||||
|
||||
$this->recurringInvoiceService->delete($ids);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The submitted custom-field values, or nothing when the payload carries
|
||||
* something the service cannot walk.
|
||||
*/
|
||||
private function customFields(RecurringInvoiceRequest $request): ?iterable
|
||||
{
|
||||
$values = $request->input('customFields');
|
||||
|
||||
return is_iterable($values) ? $values : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Sales\Models\RecurringInvoice;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Previews the moment a schedule would fire for the first time.
|
||||
*/
|
||||
class RecurringInvoiceFrequencyController extends Controller
|
||||
{
|
||||
/**
|
||||
* Read a cron expression and a start date, and answer with the first
|
||||
* firing they produce.
|
||||
*
|
||||
* The schedule form asks for this while it is still being filled in, so
|
||||
* nothing here is gated or written down — the date is worked out, handed
|
||||
* back and forgotten. A start date the parser cannot read, or an
|
||||
* expression it cannot parse, comes back as a server error rather than a
|
||||
* validation message.
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$nextRun = RecurringInvoice::getNextInvoiceDate(
|
||||
$request->input('frequency'),
|
||||
$request->input('starts_at'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'next_invoice_at' => $nextRun,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\Company;
|
||||
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Application\SerialNumberService;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Serves the number-format previews the settings and document screens use.
|
||||
*/
|
||||
class SerialNumberController extends Controller
|
||||
{
|
||||
/**
|
||||
* Render the number the next document of the requested kind would carry.
|
||||
*
|
||||
* The `key` query parameter picks the document kind; an unknown one is
|
||||
* reported as a plain failure rather than an error. `format` overrides the
|
||||
* company's stored format so the settings screen can preview edits, and
|
||||
* `model_id` lets an existing document keep the numbers it already has.
|
||||
*/
|
||||
public function nextNumber(Request $request, Invoice $invoice, Estimate $estimate, Payment $payment): JsonResponse
|
||||
{
|
||||
$serial = (new SerialNumberService)
|
||||
->setCompany($request->header('company'))
|
||||
->setCustomer($request->userId);
|
||||
|
||||
// Invoices and credit notes live in one table, so each is pinned to its
|
||||
// own row type: the preview must never count the other kind's rows.
|
||||
switch ($request->key) {
|
||||
case 'invoice':
|
||||
$serial->setModel($invoice)
|
||||
->setSequenceScope(['type' => Invoice::TYPE_INVOICE]);
|
||||
|
||||
break;
|
||||
|
||||
case 'credit_note':
|
||||
$serial->setModel($invoice)
|
||||
->setSettingKey('credit_note_number_format')
|
||||
->setSequenceScope(['type' => Invoice::TYPE_CREDIT_NOTE]);
|
||||
|
||||
break;
|
||||
|
||||
case 'estimate':
|
||||
$serial->setModel($estimate);
|
||||
|
||||
break;
|
||||
|
||||
case 'payment':
|
||||
$serial->setModel($payment);
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$nextNumber = $serial->setModelObject($request->model_id)
|
||||
->getNextNumber($request->input('format'));
|
||||
} catch (\Exception $exception) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'nextNumber' => $nextNumber,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List the tokens a submitted format string is made of.
|
||||
*/
|
||||
public function placeholders(Request $request): JsonResponse
|
||||
{
|
||||
$format = $request->input('format');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'placeholders' => $format ? SerialNumberService::getPlaceholders($format) : [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\CustomerPortal;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Sales\Http\Resources\CustomerPortal\EstimateResource;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AcceptEstimateController extends Controller
|
||||
{
|
||||
/**
|
||||
* Record the contact's verdict on one of their own offers.
|
||||
*
|
||||
* Known defect, kept as-is: the submitted value is written through
|
||||
* without a whitelist, so the stored status is whatever string arrives.
|
||||
*
|
||||
* @param string $id
|
||||
* @return Response
|
||||
*/
|
||||
public function __invoke(Request $request, Company $company, $id)
|
||||
{
|
||||
$contact = Auth::guard('customer')->id();
|
||||
|
||||
$estimate = $company->estimates()->whereCustomer($contact)->where('id', $id)->first();
|
||||
|
||||
if ($estimate === null) {
|
||||
return response()->json(['error' => 'estimate_not_found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$verdict = $request->only('status');
|
||||
|
||||
$estimate->update($verdict);
|
||||
|
||||
return EstimateResource::make($estimate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\CustomerPortal;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\Http\Resources\EstimateResource;
|
||||
use App\Domains\Sales\Mail\EstimateViewedMail;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Platform\Mail\Models\EmailLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class EstimatePdfController extends Controller
|
||||
{
|
||||
/**
|
||||
* Stream the offer behind an emailed link. Opening it counts as reading
|
||||
* the offer, so the document is marked seen on the way through.
|
||||
*/
|
||||
public function getPdf(EmailLog $emailLog, Request $request)
|
||||
{
|
||||
$estimate = $this->documentBehind($emailLog);
|
||||
|
||||
$this->recordReading($estimate);
|
||||
|
||||
return $estimate->getGeneratedPDFOrStream('estimate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the same offer as JSON for the viewer shell.
|
||||
*
|
||||
* Note the payload is the back-office representation, not the trimmed
|
||||
* portal one its invoice counterpart uses. Left as it stands.
|
||||
*/
|
||||
public function getEstimate(EmailLog $emailLog)
|
||||
{
|
||||
return EstimateResource::make($this->documentBehind($emailLog));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade an email-log token for the offer it was issued for.
|
||||
*
|
||||
* Holding the token is the whole credential, so the guard is narrow: the
|
||||
* log must point at an offer, and the link must still be inside the
|
||||
* company's expiry window.
|
||||
*/
|
||||
private function documentBehind(EmailLog $emailLog): Estimate
|
||||
{
|
||||
$document = $emailLog->mailable;
|
||||
|
||||
if (! $document instanceof Estimate) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($emailLog->isExpired()) {
|
||||
abort(403, 'Link Expired.');
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote an offer that is still awaiting a reader, and tell the issuer
|
||||
* about it when they asked to be told.
|
||||
*/
|
||||
private function recordReading(Estimate $estimate): void
|
||||
{
|
||||
$unread = [Estimate::STATUS_SENT, Estimate::STATUS_DRAFT];
|
||||
|
||||
if (! in_array($estimate->status, $unread)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$estimate->update(['status' => Estimate::STATUS_VIEWED]);
|
||||
|
||||
$wanted = CompanySetting::getSetting('notify_estimate_viewed', $estimate->company_id);
|
||||
|
||||
if ($wanted != 'YES') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'estimate' => Estimate::findOrFail($estimate->id)->toArray(),
|
||||
'user' => Customer::find($estimate->customer_id)->toArray(),
|
||||
];
|
||||
|
||||
$mailbox = CompanySetting::getSetting('notification_email', $estimate->company_id);
|
||||
|
||||
Mail::to($mailbox)->send(new EstimateViewedMail($payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\CustomerPortal;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Sales\Http\Resources\CustomerPortal\EstimateResource;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class EstimatesController extends Controller
|
||||
{
|
||||
/**
|
||||
* Page through the offers addressed to the signed-in contact.
|
||||
*
|
||||
* Unsent work stays private to the issuer, so drafts reach neither the
|
||||
* page itself nor the counter beside it.
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 10;
|
||||
|
||||
if ($request->has('limit')) {
|
||||
$perPage = $request->limit;
|
||||
}
|
||||
|
||||
$contact = Auth::guard('customer')->id();
|
||||
|
||||
$query = Estimate::with(['items', 'customer', 'taxes', 'creator'])
|
||||
->where('status', '<>', Estimate::STATUS_DRAFT)
|
||||
->whereCustomer($contact);
|
||||
|
||||
$query->applyFilters($request->only([
|
||||
'status',
|
||||
'estimate_number',
|
||||
'from_date',
|
||||
'to_date',
|
||||
'orderByField',
|
||||
'orderBy',
|
||||
]));
|
||||
|
||||
$page = $query->latest()->paginateData($perPage);
|
||||
|
||||
$visible = Estimate::query()
|
||||
->where('status', '<>', Estimate::STATUS_DRAFT)
|
||||
->whereCustomer($contact)
|
||||
->count();
|
||||
|
||||
return EstimateResource::collection($page)
|
||||
->additional(['meta' => [
|
||||
'estimateTotalCount' => $visible,
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand back a single offer, looked up inside the portal's company and
|
||||
* narrowed to the signed-in contact so ids cannot be probed.
|
||||
*
|
||||
* @param string $id
|
||||
* @return Response
|
||||
*/
|
||||
public function show(Company $company, $id)
|
||||
{
|
||||
$contact = Auth::guard('customer')->id();
|
||||
|
||||
$estimate = $company->estimates()->whereCustomer($contact)->where('id', $id)->first();
|
||||
|
||||
if ($estimate === null) {
|
||||
return response()->json(['error' => 'estimate_not_found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return EstimateResource::make($estimate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\CustomerPortal;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\Http\Resources\CustomerPortal\InvoiceResource;
|
||||
use App\Domains\Sales\Mail\InvoiceViewedMail;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Platform\Mail\Models\EmailLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class InvoicePdfController extends Controller
|
||||
{
|
||||
/**
|
||||
* Answer an emailed link: the rendered document when the caller asks for
|
||||
* the file, otherwise the portal shell that frames it. Either way the
|
||||
* visit counts as the customer having read the bill.
|
||||
*/
|
||||
public function getPdf(EmailLog $emailLog, Request $request)
|
||||
{
|
||||
$invoice = $this->documentBehind($emailLog);
|
||||
|
||||
$this->recordReading($invoice);
|
||||
|
||||
if ($request->has('pdf')) {
|
||||
return $invoice->getGeneratedPDFOrStream('invoice');
|
||||
}
|
||||
|
||||
$issuer = $invoice->company_id;
|
||||
|
||||
return view('app')->with([
|
||||
'customer_logo' => get_company_setting('customer_portal_logo', $issuer),
|
||||
'current_theme' => get_company_setting('customer_portal_theme', $issuer),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the same document as JSON for the viewer shell, in the trimmed
|
||||
* portal shape.
|
||||
*/
|
||||
public function getInvoice(EmailLog $emailLog)
|
||||
{
|
||||
return InvoiceResource::make($this->documentBehind($emailLog));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade an email-log token for the document it was issued for.
|
||||
*
|
||||
* Holding the token is the whole credential, so the guard is narrow. The
|
||||
* log must point at a billing document (a token minted for some other
|
||||
* kind of mail must not disclose one, however the ids line up), and the
|
||||
* link must still be inside the company's expiry window.
|
||||
*/
|
||||
private function documentBehind(EmailLog $emailLog): Invoice
|
||||
{
|
||||
$document = $emailLog->mailable;
|
||||
|
||||
if (! $document instanceof Invoice) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($emailLog->isExpired()) {
|
||||
abort(403, 'Link Expired.');
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a document that is still awaiting a reader, and tell the issuer
|
||||
* about it when they asked to be told.
|
||||
*/
|
||||
private function recordReading(Invoice $invoice): void
|
||||
{
|
||||
$unread = [Invoice::STATUS_SENT, Invoice::STATUS_DRAFT];
|
||||
|
||||
if (! in_array($invoice->status, $unread)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice->update([
|
||||
'status' => Invoice::STATUS_VIEWED,
|
||||
'viewed' => true,
|
||||
]);
|
||||
|
||||
$wanted = CompanySetting::getSetting('notify_invoice_viewed', $invoice->company_id);
|
||||
|
||||
if ($wanted != 'YES') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'invoice' => Invoice::findOrFail($invoice->id)->toArray(),
|
||||
'user' => Customer::find($invoice->customer_id)->toArray(),
|
||||
];
|
||||
|
||||
$mailbox = CompanySetting::getSetting('notification_email', $invoice->company_id);
|
||||
|
||||
Mail::to($mailbox)->send(new InvoiceViewedMail($payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Controllers\CustomerPortal;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Sales\Http\Resources\CustomerPortal\InvoiceResource;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class InvoicesController extends Controller
|
||||
{
|
||||
/**
|
||||
* Page through the billing documents the signed-in contact has received.
|
||||
*
|
||||
* Drafts are withheld; the whole query string is handed to the filter
|
||||
* scope, so anything the model knows how to narrow by is accepted here.
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 10;
|
||||
|
||||
if ($request->has('limit')) {
|
||||
$perPage = $request->limit;
|
||||
}
|
||||
|
||||
$contact = Auth::guard('customer')->id();
|
||||
$filters = $request->all();
|
||||
|
||||
$page = Invoice::with(['items', 'customer', 'creator', 'taxes'])
|
||||
->where('status', '<>', Invoice::STATUS_DRAFT)
|
||||
->applyFilters($filters)
|
||||
->whereCustomer($contact)
|
||||
->latest()
|
||||
->paginateData($perPage);
|
||||
|
||||
// The counter tallies issued documents alone. A credit note reverses
|
||||
// an invoice rather than adding one, so it is left out of the total
|
||||
// even though it is listed among the rows above.
|
||||
$received = Invoice::query()
|
||||
->where('type', Invoice::TYPE_INVOICE)
|
||||
->where('status', '<>', Invoice::STATUS_DRAFT)
|
||||
->whereCustomer($contact)
|
||||
->count();
|
||||
|
||||
return InvoiceResource::collection($page)
|
||||
->additional(['meta' => [
|
||||
'invoiceTotalCount' => $received,
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand back a single billing document, looked up inside the portal's
|
||||
* company and narrowed to the signed-in contact.
|
||||
*
|
||||
* @param string $id
|
||||
* @return Response
|
||||
*/
|
||||
public function show(Company $company, $id)
|
||||
{
|
||||
$contact = Auth::guard('customer')->id();
|
||||
|
||||
$invoice = $company->invoices()->whereCustomer($contact)->where('id', $id)->first();
|
||||
|
||||
if ($invoice === null) {
|
||||
return response()->json(['error' => 'invoice_not_found'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return InvoiceResource::make($invoice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Payload of the bulk estimate removal endpoint: a list of ids, each of which
|
||||
* has to be an estimate. Company scoping is applied by the controller when it
|
||||
* resolves the ids, not here.
|
||||
*/
|
||||
class DeleteEstimatesRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The ability is checked in the controller.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ids' => 'required',
|
||||
'ids.*' => ['required', Rule::exists('estimates', 'id')],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Requests;
|
||||
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Rules\CreditNoteDeletedTogether;
|
||||
use App\Rules\RelationNotExist;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Payload of the bulk invoice removal endpoint: a list of ids, each of which
|
||||
* has to name a real invoice that nothing is still hanging off. Company scoping
|
||||
* is applied by the controller when it resolves the ids, not here.
|
||||
*/
|
||||
class DeleteInvoiceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The ability is checked in the controller.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* An id survives validation when the invoice exists, carries no payment,
|
||||
* and takes any credit note written against it along in the same batch.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$batch = (array) $this->input('ids', []);
|
||||
|
||||
return [
|
||||
'ids' => 'required',
|
||||
'ids.*' => [
|
||||
'required',
|
||||
Rule::exists('invoices', 'id'),
|
||||
new RelationNotExist(Invoice::class, 'payments'),
|
||||
new CreditNoteDeletedTogether($batch),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Requests;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Platform\Pdf\Rules\PdfTemplateExists;
|
||||
use App\Support\DocumentTotals;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Validates the estimate write surface and assembles the attributes the service
|
||||
* layer persists. Money arrives as integer minor units.
|
||||
*/
|
||||
class EstimatesRequest extends FormRequest
|
||||
{
|
||||
use Concerns\ValidatesDocumentTaxPlaceholders;
|
||||
|
||||
/**
|
||||
* Gatekeeping happens in the controller, against the estimate itself.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'estimate_date' => 'required',
|
||||
'expiry_date' => 'nullable',
|
||||
'customer_id' => 'required',
|
||||
'estimate_number' => ['required', $this->uniqueNumber()],
|
||||
'exchange_rate' => $this->foreignCurrency() ? 'required' : 'nullable',
|
||||
'discount' => 'numeric|required',
|
||||
'discount_val' => 'integer|required',
|
||||
'sub_total' => 'integer|required',
|
||||
'total' => 'integer|numeric|max:999999999999|required',
|
||||
'tax' => 'required',
|
||||
'template_name' => ['required', new PdfTemplateExists('estimate')],
|
||||
'items' => 'required|array',
|
||||
'items.*.description' => 'nullable',
|
||||
'items.*' => 'required|max:255',
|
||||
'items.*.name' => 'required',
|
||||
'items.*.quantity' => 'numeric|required',
|
||||
'items.*.price' => 'integer|required',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$this->validateDocumentTaxPlaceholders($validator);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored attributes for a create or an update.
|
||||
*
|
||||
* Totals are recomputed here from the submitted lines (GHSA-8c69): whatever
|
||||
* sub_total / total / tax the client sent is discarded. The document is
|
||||
* always denominated in the customer's currency.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getEstimatePayload()
|
||||
{
|
||||
$companyId = $this->header('company');
|
||||
$rate = CompanySetting::getSetting('currency', $companyId) != $this->currency_id
|
||||
? $this->exchange_rate
|
||||
: 1;
|
||||
|
||||
$perItemTax = CompanySetting::getSetting('tax_per_item', $companyId) ?? 'NO ';
|
||||
$perItemDiscount = CompanySetting::getSetting('discount_per_item', $companyId) ?? 'NO';
|
||||
|
||||
$sums = DocumentTotals::compute(
|
||||
$this->items ?? [],
|
||||
$this->taxes ?? [],
|
||||
$this->discount_val,
|
||||
$perItemTax,
|
||||
(bool) $this->tax_included,
|
||||
$perItemDiscount
|
||||
);
|
||||
|
||||
$sending = $this->has('estimateSend');
|
||||
|
||||
return collect($this->except(['items', 'taxes']))
|
||||
->merge([
|
||||
'creator_id' => $this->user()?->id,
|
||||
'status' => $sending ? Estimate::STATUS_SENT : Estimate::STATUS_DRAFT,
|
||||
'company_id' => $companyId,
|
||||
'tax_per_item' => $perItemTax,
|
||||
'discount_per_item' => $perItemDiscount,
|
||||
'sub_total' => $sums['sub_total'],
|
||||
'total' => $sums['total'],
|
||||
'tax' => $sums['tax'],
|
||||
'exchange_rate' => $rate,
|
||||
'base_discount_val' => $this->discount_val * $rate,
|
||||
'base_sub_total' => $sums['sub_total'] * $rate,
|
||||
'base_total' => $sums['total'] * $rate,
|
||||
'base_tax' => $sums['tax'] * $rate,
|
||||
'currency_id' => Customer::find($this->customer_id)->currency_id,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbers are unique inside a company; on a replace the estimate being
|
||||
* written is exempt from its own number.
|
||||
*/
|
||||
private function uniqueNumber(): Unique
|
||||
{
|
||||
$rule = Rule::unique('estimates')->where('company_id', $this->header('company'));
|
||||
|
||||
return $this->isMethod('PUT')
|
||||
? $rule->ignore($this->route('estimate')->id)
|
||||
: $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the billed customer settles in something other than the
|
||||
* company's own currency, which makes a rate mandatory.
|
||||
*/
|
||||
private function foreignCurrency(): bool
|
||||
{
|
||||
$homeCurrency = CompanySetting::getSetting('currency', $this->header('company'));
|
||||
$billed = Customer::find($this->customer_id);
|
||||
|
||||
if (! $homeCurrency || ! $billed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (string) $billed->currency_id !== $homeCurrency;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Requests;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Pdf\Rules\PdfTemplateExists;
|
||||
use App\Support\DocumentTotals;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Validates the invoice write surface and assembles the attributes the service
|
||||
* layer persists. Money arrives as integer minor units.
|
||||
*/
|
||||
class InvoicesRequest extends FormRequest
|
||||
{
|
||||
use Concerns\ValidatesDocumentTaxPlaceholders;
|
||||
|
||||
/**
|
||||
* Gatekeeping happens in the controller, against the invoice itself.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'invoice_date' => 'required',
|
||||
'due_date' => 'nullable',
|
||||
'customer_id' => 'required',
|
||||
'invoice_number' => ['required', $this->uniqueNumber()],
|
||||
'exchange_rate' => $this->foreignCurrency() ? 'required' : 'nullable',
|
||||
'discount' => 'numeric|required',
|
||||
'discount_val' => 'integer|required',
|
||||
'sub_total' => 'numeric|required',
|
||||
'total' => 'numeric|max:999999999999|required',
|
||||
'tax' => 'required',
|
||||
'template_name' => ['required', new PdfTemplateExists('invoice')],
|
||||
'items' => 'required|array',
|
||||
'items.*' => 'required|max:255',
|
||||
'items.*.description' => 'nullable',
|
||||
'items.*.name' => 'required',
|
||||
'items.*.quantity' => 'numeric|required',
|
||||
'items.*.price' => 'numeric|required',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$this->validateDocumentTaxPlaceholders($validator);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored attributes for a create or an update.
|
||||
*
|
||||
* Totals are recomputed here from the submitted lines (GHSA-8c69): whatever
|
||||
* sub_total / total / tax the client sent is discarded. The document is
|
||||
* always denominated in the customer's currency, and it is never allowed to
|
||||
* declare itself a credit note: those are minted by the credit-note service
|
||||
* alone.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getInvoicePayload(): array
|
||||
{
|
||||
$companyId = $this->header('company');
|
||||
$rate = CompanySetting::getSetting('currency', $companyId) != $this->currency_id
|
||||
? $this->exchange_rate
|
||||
: 1;
|
||||
|
||||
$perItemTax = CompanySetting::getSetting('tax_per_item', $companyId) ?? 'NO ';
|
||||
$perItemDiscount = CompanySetting::getSetting('discount_per_item', $companyId) ?? 'NO';
|
||||
$taxIncluded = (bool) $this->tax_included;
|
||||
|
||||
$sums = DocumentTotals::compute(
|
||||
$this->items ?? [],
|
||||
$this->taxes ?? [],
|
||||
$this->discount_val,
|
||||
$perItemTax,
|
||||
$taxIncluded,
|
||||
$perItemDiscount
|
||||
);
|
||||
|
||||
return array_merge($this->except(['items', 'taxes']), [
|
||||
'creator_id' => $this->user()?->id,
|
||||
'type' => Invoice::TYPE_INVOICE,
|
||||
'related_invoice_id' => null,
|
||||
'credit_reason' => null,
|
||||
'status' => $this->exists('invoiceSend') ? Invoice::STATUS_SENT : Invoice::STATUS_DRAFT,
|
||||
'paid_status' => Invoice::STATUS_UNPAID,
|
||||
'company_id' => $companyId,
|
||||
'tax_per_item' => $perItemTax,
|
||||
'discount_per_item' => $perItemDiscount,
|
||||
'sub_total' => $sums['sub_total'],
|
||||
'total' => $sums['total'],
|
||||
'tax' => $sums['tax'],
|
||||
'due_amount' => $sums['total'],
|
||||
'sent' => (bool) $this->sent,
|
||||
'viewed' => (bool) $this->viewed,
|
||||
'exchange_rate' => $rate,
|
||||
'base_total' => $sums['total'] * $rate,
|
||||
'base_discount_val' => $this->discount_val * $rate,
|
||||
'base_sub_total' => $sums['sub_total'] * $rate,
|
||||
'base_tax' => $sums['tax'] * $rate,
|
||||
'base_due_amount' => $sums['total'] * $rate,
|
||||
'currency_id' => Customer::find($this->customer_id)->currency_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbers are unique inside a company; on a replace the invoice being
|
||||
* written is exempt from its own number.
|
||||
*/
|
||||
private function uniqueNumber(): Unique
|
||||
{
|
||||
$rule = Rule::unique('invoices')->where('company_id', $this->header('company'));
|
||||
|
||||
return $this->isMethod('PUT')
|
||||
? $rule->ignore($this->route('invoice')->id)
|
||||
: $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the billed customer settles in something other than the
|
||||
* company's own currency, which makes a rate mandatory.
|
||||
*/
|
||||
private function foreignCurrency(): bool
|
||||
{
|
||||
$homeCurrency = CompanySetting::getSetting('currency', $this->header('company'));
|
||||
$billed = Customer::find($this->customer_id);
|
||||
|
||||
if (! $homeCurrency || ! $billed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (string) $billed->currency_id !== $homeCurrency;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Requests;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\Models\RecurringInvoice;
|
||||
use App\Support\DocumentTotals;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Vets a standing order and reshapes it into the row the service stores.
|
||||
*
|
||||
* A schedule carries a whole invoice in template form, so what arrives is a
|
||||
* document's payload with three extra fields bolted on: the cron expression
|
||||
* that decides when it fires, the date it starts running, and the limit that
|
||||
* eventually retires it. Everything else — items, discounts, taxes — is
|
||||
* checked and recomputed exactly as it is on a real invoice.
|
||||
*/
|
||||
class RecurringInvoiceRequest extends FormRequest
|
||||
{
|
||||
use Concerns\ValidatesDocumentTaxPlaceholders;
|
||||
|
||||
/**
|
||||
* Every caller is let through; the controller holds the gate.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules for the schedule and for the invoice template it carries.
|
||||
*
|
||||
* The cron expression, the start date and the status are checked for
|
||||
* presence only: a string the cron parser cannot read gets no validation
|
||||
* message and instead surfaces as an error when the first firing is worked
|
||||
* out. The limit fields answer to the chosen limit mode — a count is
|
||||
* demanded for COUNT, an end date for DATE, and neither for NONE.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$homeCurrency = CompanySetting::getSetting('currency', $this->header('company'));
|
||||
|
||||
$rules = [
|
||||
'starts_at' => [
|
||||
'required',
|
||||
],
|
||||
'send_automatically' => [
|
||||
'required',
|
||||
'boolean',
|
||||
],
|
||||
'customer_id' => [
|
||||
'required',
|
||||
],
|
||||
'exchange_rate' => [
|
||||
'nullable',
|
||||
],
|
||||
'discount' => [
|
||||
'numeric',
|
||||
'required',
|
||||
],
|
||||
'discount_val' => [
|
||||
'integer',
|
||||
'required',
|
||||
],
|
||||
'sub_total' => [
|
||||
'integer',
|
||||
'required',
|
||||
],
|
||||
'total' => [
|
||||
'integer',
|
||||
'max:999999999999',
|
||||
'required',
|
||||
],
|
||||
'tax' => [
|
||||
'required',
|
||||
],
|
||||
'status' => [
|
||||
'required',
|
||||
],
|
||||
'frequency' => [
|
||||
'required',
|
||||
],
|
||||
'limit_by' => [
|
||||
'required',
|
||||
],
|
||||
'limit_count' => [
|
||||
'required_if:limit_by,COUNT',
|
||||
],
|
||||
'limit_date' => [
|
||||
'required_if:limit_by,DATE',
|
||||
],
|
||||
'items' => [
|
||||
'required',
|
||||
],
|
||||
'items.*' => [
|
||||
'required',
|
||||
],
|
||||
'items.*.description' => [
|
||||
'nullable',
|
||||
],
|
||||
];
|
||||
|
||||
// A contact billed in some other currency than the company's turns the
|
||||
// otherwise optional rate into a hard requirement. The contact is
|
||||
// looked up by bare id, so one belonging to another company answers
|
||||
// here just the same.
|
||||
$contact = Customer::find($this->customer_id);
|
||||
|
||||
if ($contact && $homeCurrency && (string) $contact->currency_id !== $homeCurrency) {
|
||||
$rules['exchange_rate'] = [
|
||||
'required',
|
||||
];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any per-item tax row that carries an amount without a type.
|
||||
*/
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$this->validateDocumentTaxPlaceholders($validator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the submission into the columns of the schedule row.
|
||||
*
|
||||
* The submitted sub-total, tax and grand total are thrown away and worked
|
||||
* out again from the line items, because every invoice this schedule mints
|
||||
* inherits them. The stored currency is always the contact's; the
|
||||
* submitted currency id only decides whether an exchange rate is carried
|
||||
* or pinned at one.
|
||||
*/
|
||||
public function getRecurringInvoicePayload()
|
||||
{
|
||||
$company = $this->header('company');
|
||||
|
||||
$companyCurrency = CompanySetting::getSetting('currency', $company);
|
||||
$submittedCurrency = $this->currency_id;
|
||||
$rate = $companyCurrency != $submittedCurrency ? $this->exchange_rate : 1;
|
||||
$contactCurrency = Customer::find($this->customer_id)->currency_id;
|
||||
|
||||
$nextRun = RecurringInvoice::getNextInvoiceDate($this->frequency, $this->starts_at);
|
||||
|
||||
$perItemTax = CompanySetting::getSetting('tax_per_item', $company) ?? 'NO ';
|
||||
$perItemDiscount = CompanySetting::getSetting('discount_per_item', $company) ?? 'NO';
|
||||
|
||||
$totals = DocumentTotals::compute(
|
||||
$this->items ?? [],
|
||||
$this->taxes ?? [],
|
||||
$this->discount_val,
|
||||
$perItemTax,
|
||||
(bool) $this->tax_included,
|
||||
$perItemDiscount
|
||||
);
|
||||
|
||||
$submitted = collect($this->except('items', 'taxes'));
|
||||
|
||||
return $submitted
|
||||
->merge([
|
||||
'creator_id' => $this->user()->id,
|
||||
'company_id' => $company,
|
||||
'next_invoice_at' => $nextRun,
|
||||
'tax_per_item' => $perItemTax,
|
||||
'discount_per_item' => $perItemDiscount,
|
||||
'sub_total' => $totals['sub_total'],
|
||||
'total' => $totals['total'],
|
||||
'tax' => $totals['tax'],
|
||||
'due_amount' => $totals['total'],
|
||||
'exchange_rate' => $rate,
|
||||
'base_sub_total' => $totals['sub_total'] * $rate,
|
||||
'base_total' => $totals['total'] * $rate,
|
||||
'base_tax' => $totals['tax'] * $rate,
|
||||
'currency_id' => $contactCurrency,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* The envelope of an estimate mail: who it goes to, what it says, and the
|
||||
* optional carbon copies.
|
||||
*/
|
||||
class SendEstimatesRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The send ability is checked in the controller.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'subject' => 'required',
|
||||
'body' => 'required',
|
||||
'from' => 'required',
|
||||
'to' => 'required',
|
||||
'cc' => 'nullable',
|
||||
'bcc' => 'nullable',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* The envelope of an invoice mail: the message itself, who it comes from, who
|
||||
* it goes to, and the optional carbon copies.
|
||||
*/
|
||||
class SendInvoiceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* The send ability is checked in the controller.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => 'required',
|
||||
'subject' => 'required',
|
||||
'from' => 'required',
|
||||
'to' => 'required',
|
||||
'cc' => 'nullable',
|
||||
'bcc' => 'nullable',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources\CustomerPortal;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of estimates for the customer portal.
|
||||
*
|
||||
* Namespace and class name together select the portal EstimateResource as the
|
||||
* member resource; the pagination envelope comes from the framework.
|
||||
*/
|
||||
class EstimateCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources\CustomerPortal;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of estimate lines for the customer portal.
|
||||
*
|
||||
* Namespace and class name together select the portal EstimateItemResource as
|
||||
* the member resource.
|
||||
*/
|
||||
class EstimateItemCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources\CustomerPortal;
|
||||
|
||||
use App\Domains\Metadata\Http\Resources\CustomerPortal\CustomFieldValueResource;
|
||||
use App\Domains\Taxation\Http\Resources\CustomerPortal\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* One line of an estimate as the customer portal publishes it.
|
||||
*
|
||||
* The same line fields as the admin view, in the estimate payload's own field
|
||||
* ordering, with the line's taxes and custom field values published through the
|
||||
* portal variants of those resources.
|
||||
*/
|
||||
class EstimateItemResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$item = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'description' => $item->description,
|
||||
'discount_type' => $item->discount_type,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_name' => $item->unit_name,
|
||||
'discount' => $item->discount,
|
||||
'discount_val' => $item->discount_val,
|
||||
'price' => $item->price,
|
||||
'tax' => $item->tax,
|
||||
'total' => $item->total,
|
||||
'item_id' => $item->item_id,
|
||||
'estimate_id' => $item->estimate_id,
|
||||
'company_id' => $item->company_id,
|
||||
'exchange_rate' => $item->exchange_rate,
|
||||
'base_discount_val' => $item->base_discount_val,
|
||||
'base_price' => $item->base_price,
|
||||
'base_tax' => $item->base_tax,
|
||||
'base_total' => $item->base_total,
|
||||
'taxes' => $this->when(
|
||||
$item->taxes()->exists(),
|
||||
fn () => TaxResource::collection($item->taxes)
|
||||
),
|
||||
'fields' => $this->when(
|
||||
$item->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($item->fields)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources\CustomerPortal;
|
||||
|
||||
use App\Domains\Accounts\Http\Resources\CustomerPortal\CompanyResource;
|
||||
use App\Domains\Contacts\Http\Resources\CustomerPortal\CustomerResource;
|
||||
use App\Domains\Metadata\Http\Resources\CustomerPortal\CustomFieldValueResource;
|
||||
use App\Domains\Money\Http\Resources\CustomerPortal\CurrencyResource;
|
||||
use App\Domains\Taxation\Http\Resources\CustomerPortal\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* An estimate as the customer portal publishes it.
|
||||
*
|
||||
* A narrower view than the admin one: no author, no sequence number, no
|
||||
* tax-inclusive flag and no sales-tax configuration -- only what the customer's
|
||||
* own copy of the offer shows, including both expiry and issue dates in the
|
||||
* company's format and the shareable PDF link.
|
||||
*
|
||||
* The notes are the raw stored value here, not the interpolated rendering the
|
||||
* admin payload publishes. Each related record is gated behind an existence
|
||||
* probe on its relation, and every nested resource is the portal variant.
|
||||
*/
|
||||
class EstimateResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$estimate = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $estimate->id,
|
||||
'estimate_date' => $estimate->estimate_date,
|
||||
'expiry_date' => $estimate->expiry_date,
|
||||
'estimate_number' => $estimate->estimate_number,
|
||||
'status' => $estimate->status,
|
||||
'reference_number' => $estimate->reference_number,
|
||||
'tax_per_item' => $estimate->tax_per_item,
|
||||
'discount_per_item' => $estimate->discount_per_item,
|
||||
'notes' => $estimate->notes,
|
||||
'discount' => $estimate->discount,
|
||||
'discount_type' => $estimate->discount_type,
|
||||
'discount_val' => $estimate->discount_val,
|
||||
'sub_total' => $estimate->sub_total,
|
||||
'total' => $estimate->total,
|
||||
'tax' => $estimate->tax,
|
||||
'unique_hash' => $estimate->unique_hash,
|
||||
'template_name' => $estimate->template_name,
|
||||
'customer_id' => $estimate->customer_id,
|
||||
'exchange_rate' => $estimate->exchange_rate,
|
||||
'base_discount_val' => $estimate->base_discount_val,
|
||||
'base_sub_total' => $estimate->base_sub_total,
|
||||
'base_total' => $estimate->base_total,
|
||||
'base_tax' => $estimate->base_tax,
|
||||
'currency_id' => $estimate->currency_id,
|
||||
'formatted_expiry_date' => $estimate->formattedExpiryDate,
|
||||
'formatted_estimate_date' => $estimate->formattedEstimateDate,
|
||||
'estimate_pdf_url' => $estimate->estimatePdfUrl,
|
||||
'items' => $this->when(
|
||||
$estimate->items()->exists(),
|
||||
fn () => EstimateItemResource::collection($estimate->items)
|
||||
),
|
||||
'customer' => $this->when(
|
||||
$estimate->customer()->exists(),
|
||||
fn () => new CustomerResource($estimate->customer)
|
||||
),
|
||||
'taxes' => $this->when(
|
||||
$estimate->taxes()->exists(),
|
||||
fn () => TaxResource::collection($estimate->taxes)
|
||||
),
|
||||
'fields' => $this->when(
|
||||
$estimate->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($estimate->fields)
|
||||
),
|
||||
'company' => $this->when(
|
||||
$estimate->company()->exists(),
|
||||
fn () => new CompanyResource($estimate->company)
|
||||
),
|
||||
'currency' => $this->when(
|
||||
$estimate->currency()->exists(),
|
||||
fn () => new CurrencyResource($estimate->currency)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources\CustomerPortal;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of invoices for the customer portal.
|
||||
*
|
||||
* Sits in the portal namespace so the member resource derived from this class
|
||||
* name is the portal InvoiceResource, not the admin one; the mapping and the
|
||||
* pagination envelope are left to the parent.
|
||||
*/
|
||||
class InvoiceCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources\CustomerPortal;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of invoice lines for the customer portal.
|
||||
*
|
||||
* Namespace and class name together select the portal InvoiceItemResource as
|
||||
* the member resource.
|
||||
*/
|
||||
class InvoiceItemCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources\CustomerPortal;
|
||||
|
||||
use App\Domains\Metadata\Http\Resources\CustomerPortal\CustomFieldValueResource;
|
||||
use App\Domains\Taxation\Http\Resources\CustomerPortal\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* One line of an invoice as the customer portal publishes it.
|
||||
*
|
||||
* The same line fields as the admin view -- the row is already a snapshot, so
|
||||
* there is nothing internal to hold back -- with the line's taxes and custom
|
||||
* field values published through the portal variants of those resources.
|
||||
*/
|
||||
class InvoiceItemResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$item = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'description' => $item->description,
|
||||
'discount_type' => $item->discount_type,
|
||||
'price' => $item->price,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_name' => $item->unit_name,
|
||||
'discount' => $item->discount,
|
||||
'discount_val' => $item->discount_val,
|
||||
'tax' => $item->tax,
|
||||
'total' => $item->total,
|
||||
'invoice_id' => $item->invoice_id,
|
||||
'item_id' => $item->item_id,
|
||||
'company_id' => $item->company_id,
|
||||
'base_price' => $item->base_price,
|
||||
'exchange_rate' => $item->exchange_rate,
|
||||
'base_discount_val' => $item->base_discount_val,
|
||||
'base_tax' => $item->base_tax,
|
||||
'base_total' => $item->base_total,
|
||||
'recurring_invoice_id' => $item->recurring_invoice_id,
|
||||
'taxes' => $this->when(
|
||||
$item->taxes()->exists(),
|
||||
fn () => TaxResource::collection($item->taxes)
|
||||
),
|
||||
'fields' => $this->when(
|
||||
$item->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($item->fields)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources\CustomerPortal;
|
||||
|
||||
use App\Domains\Accounts\Http\Resources\CustomerPortal\CompanyResource;
|
||||
use App\Domains\Contacts\Http\Resources\CustomerPortal\CustomerResource;
|
||||
use App\Domains\Metadata\Http\Resources\CustomerPortal\CustomFieldValueResource;
|
||||
use App\Domains\Money\Http\Resources\CustomerPortal\CurrencyResource;
|
||||
use App\Domains\Taxation\Http\Resources\CustomerPortal\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* An invoice as the customer portal publishes it.
|
||||
*
|
||||
* A narrower view than the admin one. Nothing about the internal handling of
|
||||
* the document travels: no document type or credit-note back-links, no author,
|
||||
* no editability flag, no crediting or allocation detail, and no sales-tax
|
||||
* configuration. What is left is what the customer's own copy of the invoice
|
||||
* shows -- the figures, the dates in the company's format, the shareable PDF
|
||||
* link and whether the document is overdue.
|
||||
*
|
||||
* The notes are published twice, in two different renderings: `notes` carries
|
||||
* the placeholders already interpolated, `formatted_notes` the model's own
|
||||
* formatting of the stored value. Both keys are consumed by the portal, so both
|
||||
* stay. Each related record is gated behind an existence probe on its relation.
|
||||
*/
|
||||
class InvoiceResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$invoice = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'invoice_date' => $invoice->invoice_date,
|
||||
'due_date' => $invoice->due_date,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'reference_number' => $invoice->reference_number,
|
||||
'status' => $invoice->status,
|
||||
'paid_status' => $invoice->paid_status,
|
||||
'tax_per_item' => $invoice->tax_per_item,
|
||||
'discount_per_item' => $invoice->discount_per_item,
|
||||
'notes' => $invoice->getNotes(),
|
||||
'discount_type' => $invoice->discount_type,
|
||||
'discount' => $invoice->discount,
|
||||
'discount_val' => $invoice->discount_val,
|
||||
'sub_total' => $invoice->sub_total,
|
||||
'total' => $invoice->total,
|
||||
'tax' => $invoice->tax,
|
||||
'due_amount' => $invoice->due_amount,
|
||||
'sent' => $invoice->sent,
|
||||
'viewed' => $invoice->viewed,
|
||||
'unique_hash' => $invoice->unique_hash,
|
||||
'template_name' => $invoice->template_name,
|
||||
'customer_id' => $invoice->customer_id,
|
||||
'recurring_invoice_id' => $invoice->recurring_invoice_id,
|
||||
'sequence_number' => $invoice->sequence_number,
|
||||
'base_discount_val' => $invoice->base_discount_val,
|
||||
'base_sub_total' => $invoice->base_sub_total,
|
||||
'base_total' => $invoice->base_total,
|
||||
'base_tax' => $invoice->base_tax,
|
||||
'base_due_amount' => $invoice->base_due_amount,
|
||||
'currency_id' => $invoice->currency_id,
|
||||
'formatted_created_at' => $invoice->formattedCreatedAt,
|
||||
'formatted_notes' => $invoice->formattedNotes,
|
||||
'invoice_pdf_url' => $invoice->invoicePdfUrl,
|
||||
'formatted_invoice_date' => $invoice->formattedInvoiceDate,
|
||||
'formatted_due_date' => $invoice->formattedDueDate,
|
||||
'payment_module_enabled' => $invoice->payment_module_enabled,
|
||||
'overdue' => $invoice->overdue,
|
||||
'items' => $this->when(
|
||||
$invoice->items()->exists(),
|
||||
fn () => InvoiceItemResource::collection($invoice->items)
|
||||
),
|
||||
'customer' => $this->when(
|
||||
$invoice->customer()->exists(),
|
||||
fn () => new CustomerResource($invoice->customer)
|
||||
),
|
||||
'taxes' => $this->when(
|
||||
$invoice->taxes()->exists(),
|
||||
fn () => TaxResource::collection($invoice->taxes)
|
||||
),
|
||||
'fields' => $this->when(
|
||||
$invoice->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($invoice->fields)
|
||||
),
|
||||
'company' => $this->when(
|
||||
$invoice->company()->exists(),
|
||||
fn () => new CompanyResource($invoice->company)
|
||||
),
|
||||
'currency' => $this->when(
|
||||
$invoice->currency()->exists(),
|
||||
fn () => new CurrencyResource($invoice->currency)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of estimates for the admin API.
|
||||
*
|
||||
* Named after its member resource, so rows are published through
|
||||
* EstimateResource and the pagination envelope comes from the framework.
|
||||
*/
|
||||
class EstimateCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of estimate lines for the admin API.
|
||||
*
|
||||
* Named after its member resource, so rows are published through
|
||||
* EstimateItemResource without the wrapper having to say so explicitly.
|
||||
*/
|
||||
class EstimateItemCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use App\Domains\Metadata\Http\Resources\CustomFieldValueResource;
|
||||
use App\Domains\Taxation\Http\Resources\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* One line of an estimate as the admin API publishes it.
|
||||
*
|
||||
* Same snapshot idea as the invoice line -- figures stored on the row, the
|
||||
* catalogue item only referenced by id, every amount paired with its `base_`
|
||||
* twin at the document's exchange rate -- but the ordering of the quantity and
|
||||
* price fields follows the estimate payload the SPA already expects, which is
|
||||
* not the ordering used by the invoice line.
|
||||
*/
|
||||
class EstimateItemResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$item = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'description' => $item->description,
|
||||
'discount_type' => $item->discount_type,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_name' => $item->unit_name,
|
||||
'discount' => $item->discount,
|
||||
'discount_val' => $item->discount_val,
|
||||
'price' => $item->price,
|
||||
'tax' => $item->tax,
|
||||
'total' => $item->total,
|
||||
'item_id' => $item->item_id,
|
||||
'estimate_id' => $item->estimate_id,
|
||||
'company_id' => $item->company_id,
|
||||
'exchange_rate' => $item->exchange_rate,
|
||||
'base_discount_val' => $item->base_discount_val,
|
||||
'base_price' => $item->base_price,
|
||||
'base_tax' => $item->base_tax,
|
||||
'base_total' => $item->base_total,
|
||||
'taxes' => $this->when(
|
||||
$item->taxes()->exists(),
|
||||
fn () => TaxResource::collection($item->taxes)
|
||||
),
|
||||
'fields' => $this->when(
|
||||
$item->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($item->fields)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use App\Domains\Accounts\Http\Resources\CompanyResource;
|
||||
use App\Domains\Accounts\Http\Resources\UserResource;
|
||||
use App\Domains\Contacts\Http\Resources\CustomerResource;
|
||||
use App\Domains\Metadata\Http\Resources\CustomFieldValueResource;
|
||||
use App\Domains\Money\Http\Resources\CurrencyResource;
|
||||
use App\Domains\Taxation\Http\Resources\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* An estimate as the admin API publishes it.
|
||||
*
|
||||
* Carries the stored columns, the dates already rendered in the company's date
|
||||
* format, the shareable PDF link, and the notes with their placeholders
|
||||
* interpolated rather than the raw template stored on the row.
|
||||
*
|
||||
* The related records -- lines, contact, author, document taxes, custom field
|
||||
* values, company and currency -- are each gated behind an existence probe on
|
||||
* the relation, so a missing one leaves its key out of the payload entirely.
|
||||
* Every probe is its own query, which is deliberate here: the estimate payload
|
||||
* has to answer correctly whether or not the caller eager-loaded anything.
|
||||
*/
|
||||
class EstimateResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$estimate = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $estimate->id,
|
||||
'estimate_date' => $estimate->estimate_date,
|
||||
'expiry_date' => $estimate->expiry_date,
|
||||
'estimate_number' => $estimate->estimate_number,
|
||||
'status' => $estimate->status,
|
||||
'reference_number' => $estimate->reference_number,
|
||||
'tax_per_item' => $estimate->tax_per_item,
|
||||
'tax_included' => $estimate->tax_included,
|
||||
'discount_per_item' => $estimate->discount_per_item,
|
||||
'notes' => $estimate->getNotes(),
|
||||
'discount' => $estimate->discount,
|
||||
'discount_type' => $estimate->discount_type,
|
||||
'discount_val' => $estimate->discount_val,
|
||||
'sub_total' => $estimate->sub_total,
|
||||
'total' => $estimate->total,
|
||||
'tax' => $estimate->tax,
|
||||
'unique_hash' => $estimate->unique_hash,
|
||||
'creator_id' => $estimate->creator_id,
|
||||
'template_name' => $estimate->template_name,
|
||||
'customer_id' => $estimate->customer_id,
|
||||
'exchange_rate' => $estimate->exchange_rate,
|
||||
'base_discount_val' => $estimate->base_discount_val,
|
||||
'base_sub_total' => $estimate->base_sub_total,
|
||||
'base_total' => $estimate->base_total,
|
||||
'base_tax' => $estimate->base_tax,
|
||||
'sequence_number' => $estimate->sequence_number,
|
||||
'currency_id' => $estimate->currency_id,
|
||||
'formatted_expiry_date' => $estimate->formattedExpiryDate,
|
||||
'formatted_estimate_date' => $estimate->formattedEstimateDate,
|
||||
'estimate_pdf_url' => $estimate->estimatePdfUrl,
|
||||
'sales_tax_type' => $estimate->sales_tax_type,
|
||||
'sales_tax_address_type' => $estimate->sales_tax_address_type,
|
||||
'items' => $this->when(
|
||||
$estimate->items()->exists(),
|
||||
fn () => EstimateItemResource::collection($estimate->items)
|
||||
),
|
||||
'customer' => $this->when(
|
||||
$estimate->customer()->exists(),
|
||||
fn () => new CustomerResource($estimate->customer)
|
||||
),
|
||||
'creator' => $this->when(
|
||||
$estimate->creator()->exists(),
|
||||
fn () => new UserResource($estimate->creator)
|
||||
),
|
||||
'taxes' => $this->when(
|
||||
$estimate->taxes()->exists(),
|
||||
fn () => TaxResource::collection($estimate->taxes)
|
||||
),
|
||||
'fields' => $this->when(
|
||||
$estimate->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($estimate->fields)
|
||||
),
|
||||
'company' => $this->when(
|
||||
$estimate->company()->exists(),
|
||||
fn () => new CompanyResource($estimate->company)
|
||||
),
|
||||
'currency' => $this->when(
|
||||
$estimate->currency()->exists(),
|
||||
fn () => new CurrencyResource($estimate->currency)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of invoices for the admin API.
|
||||
*
|
||||
* A named wrapper rather than an anonymous collection: the member resource is
|
||||
* derived from this class name, so every row is published through
|
||||
* InvoiceResource and the pagination envelope is added by the framework. The
|
||||
* mapping itself is left to the parent -- this type exists to name the payload.
|
||||
*/
|
||||
class InvoiceCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of invoice lines for the admin API.
|
||||
*
|
||||
* Named after its member resource, so rows are published through
|
||||
* InvoiceItemResource without the wrapper having to say so explicitly.
|
||||
*/
|
||||
class InvoiceItemCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use App\Domains\Metadata\Http\Resources\CustomFieldValueResource;
|
||||
use App\Domains\Taxation\Http\Resources\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* One line of an invoice as the admin API publishes it.
|
||||
*
|
||||
* The line is a snapshot: the catalogue item it came from is only referenced by
|
||||
* id, every figure is stored on the row itself, and each amount is paired with
|
||||
* its `base_` twin (the same money converted at the document's exchange rate).
|
||||
* Line taxes and custom field values ride along whenever the row has any.
|
||||
*/
|
||||
class InvoiceItemResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$item = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'description' => $item->description,
|
||||
'discount_type' => $item->discount_type,
|
||||
'price' => $item->price,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_name' => $item->unit_name,
|
||||
'discount' => $item->discount,
|
||||
'discount_val' => $item->discount_val,
|
||||
'tax' => $item->tax,
|
||||
'total' => $item->total,
|
||||
'invoice_id' => $item->invoice_id,
|
||||
'item_id' => $item->item_id,
|
||||
'company_id' => $item->company_id,
|
||||
'base_price' => $item->base_price,
|
||||
'exchange_rate' => $item->exchange_rate,
|
||||
'base_discount_val' => $item->base_discount_val,
|
||||
'base_tax' => $item->base_tax,
|
||||
'base_total' => $item->base_total,
|
||||
'recurring_invoice_id' => $item->recurring_invoice_id,
|
||||
'taxes' => $this->when(
|
||||
$item->taxes()->exists(),
|
||||
fn () => TaxResource::collection($item->taxes)
|
||||
),
|
||||
'fields' => $this->when(
|
||||
$item->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($item->fields)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use App\Domains\Accounts\Http\Resources\CompanyResource;
|
||||
use App\Domains\Accounts\Http\Resources\UserResource;
|
||||
use App\Domains\Contacts\Http\Resources\CustomerResource;
|
||||
use App\Domains\Metadata\Http\Resources\CustomFieldValueResource;
|
||||
use App\Domains\Money\Http\Resources\CurrencyResource;
|
||||
use App\Domains\Taxation\Http\Resources\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* An invoice as the admin API publishes it.
|
||||
*
|
||||
* The stored columns come first, then the derived reading aids: dates rendered
|
||||
* in the company's date format, the shareable PDF link, whether the document is
|
||||
* still editable and whether the payment module is switched on.
|
||||
*
|
||||
* Two different gating strategies live side by side here, and the difference is
|
||||
* intentional.
|
||||
*
|
||||
* The crediting and settlement blocks -- `credit_notes`, `credited_total`,
|
||||
* `credited_status`, `credited_quantities` and `payment_allocations` -- are
|
||||
* published only when the caller already eager-loaded the relation they read.
|
||||
* Probing those per row would cost extra queries on every line of a paginated
|
||||
* listing, so an index response simply omits them and the detail response,
|
||||
* which loads `creditNotes.items` and `allocations.payment`, carries them.
|
||||
*
|
||||
* The trailing associations -- lines, contact, author, taxes, custom field
|
||||
* values, company, currency -- instead probe the relation with an existence
|
||||
* query each time, so they are correct whether or not anything was eager
|
||||
* loaded. That is a query per association per row; it is the established shape
|
||||
* of this payload and is preserved deliberately.
|
||||
*/
|
||||
class InvoiceResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$invoice = $this->resource;
|
||||
$creditNotesLoaded = $invoice->relationLoaded('creditNotes');
|
||||
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'invoice_date' => $invoice->invoice_date,
|
||||
'due_date' => $invoice->due_date,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'reference_number' => $invoice->reference_number,
|
||||
'type' => $invoice->type,
|
||||
'related_invoice_id' => $invoice->related_invoice_id,
|
||||
'status' => $invoice->status,
|
||||
'paid_status' => $invoice->paid_status,
|
||||
'tax_per_item' => $invoice->tax_per_item,
|
||||
'tax_included' => $invoice->tax_included,
|
||||
'discount_per_item' => $invoice->discount_per_item,
|
||||
'notes' => $invoice->notes,
|
||||
'discount_type' => $invoice->discount_type,
|
||||
'discount' => $invoice->discount,
|
||||
'discount_val' => $invoice->discount_val,
|
||||
'sub_total' => $invoice->sub_total,
|
||||
'total' => $invoice->total,
|
||||
'tax' => $invoice->tax,
|
||||
'due_amount' => $invoice->due_amount,
|
||||
'sent' => $invoice->sent,
|
||||
'viewed' => $invoice->viewed,
|
||||
'unique_hash' => $invoice->unique_hash,
|
||||
'template_name' => $invoice->template_name,
|
||||
'customer_id' => $invoice->customer_id,
|
||||
'recurring_invoice_id' => $invoice->recurring_invoice_id,
|
||||
'sequence_number' => $invoice->sequence_number,
|
||||
'exchange_rate' => $invoice->exchange_rate,
|
||||
'base_discount_val' => $invoice->base_discount_val,
|
||||
'base_sub_total' => $invoice->base_sub_total,
|
||||
'base_total' => $invoice->base_total,
|
||||
'creator_id' => $invoice->creator_id,
|
||||
'base_tax' => $invoice->base_tax,
|
||||
'base_due_amount' => $invoice->base_due_amount,
|
||||
'currency_id' => $invoice->currency_id,
|
||||
'formatted_created_at' => $invoice->formattedCreatedAt,
|
||||
'invoice_pdf_url' => $invoice->invoicePdfUrl,
|
||||
'formatted_invoice_date' => $invoice->formattedInvoiceDate,
|
||||
'formatted_due_date' => $invoice->formattedDueDate,
|
||||
'allow_edit' => $invoice->allow_edit,
|
||||
'payment_module_enabled' => $invoice->payment_module_enabled,
|
||||
'sales_tax_type' => $invoice->sales_tax_type,
|
||||
'sales_tax_address_type' => $invoice->sales_tax_address_type,
|
||||
'overdue' => $invoice->overdue,
|
||||
|
||||
// Just enough of each reversing document for the UI to flag the
|
||||
// invoice as cancelled and link through to the storno. Suppressed
|
||||
// when there are none, so the key's presence is itself the signal.
|
||||
'credit_notes' => $this->when(
|
||||
$creditNotesLoaded && $invoice->creditNotes->isNotEmpty(),
|
||||
fn () => $this->creditNoteReferences()
|
||||
),
|
||||
|
||||
// Written by the crediting flow only; the invoice form never sets it.
|
||||
'credit_reason' => $invoice->credit_reason,
|
||||
|
||||
// How much has been credited off this invoice and whether that
|
||||
// covers the document in full. Both read the same already-loaded
|
||||
// relation the banner above uses, so neither costs a query.
|
||||
'credited_total' => $this->when(
|
||||
$creditNotesLoaded,
|
||||
fn () => $this->creditedTotal()
|
||||
),
|
||||
'credited_status' => $this->when(
|
||||
$creditNotesLoaded,
|
||||
fn () => $this->creditedStatus()
|
||||
),
|
||||
|
||||
// Credited quantity per line of THIS invoice, which is what a
|
||||
// partial-credit form needs in order to offer what is left. Needs
|
||||
// the reversing documents' own lines, so it waits for those too.
|
||||
'credited_quantities' => $this->when(
|
||||
$creditNotesLoaded
|
||||
&& $invoice->creditNotes->every(fn ($note) => $note->relationLoaded('items')),
|
||||
fn () => $this->creditedQuantities()
|
||||
),
|
||||
|
||||
// Settlement is reported through the allocation rows rather than a
|
||||
// payment relation on the invoice itself. Loaded for the detail
|
||||
// response only, so listings stay free of per-row payment queries.
|
||||
'payment_allocations' => $this->when(
|
||||
$invoice->relationLoaded('allocations'),
|
||||
fn () => $this->allocationSummaries()
|
||||
),
|
||||
|
||||
'items' => $this->when(
|
||||
$invoice->items()->exists(),
|
||||
fn () => InvoiceItemResource::collection($invoice->items)
|
||||
),
|
||||
'customer' => $this->when(
|
||||
$invoice->customer()->exists(),
|
||||
fn () => new CustomerResource($invoice->customer)
|
||||
),
|
||||
'creator' => $this->when(
|
||||
$invoice->creator()->exists(),
|
||||
fn () => new UserResource($invoice->creator)
|
||||
),
|
||||
'taxes' => $this->when(
|
||||
$invoice->taxes()->exists(),
|
||||
fn () => TaxResource::collection($invoice->taxes)
|
||||
),
|
||||
'fields' => $this->when(
|
||||
$invoice->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($invoice->fields)
|
||||
),
|
||||
'company' => $this->when(
|
||||
$invoice->company()->exists(),
|
||||
fn () => new CompanyResource($invoice->company)
|
||||
),
|
||||
'currency' => $this->when(
|
||||
$invoice->currency()->exists(),
|
||||
fn () => new CurrencyResource($invoice->currency)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything credited off this invoice, in cents, as a positive number.
|
||||
*
|
||||
* Credit notes store their amounts negated, so the loaded relation's sum is
|
||||
* flipped back on the way out.
|
||||
*/
|
||||
protected function creditedTotal(): int
|
||||
{
|
||||
return -(int) $this->creditNotes->sum('total');
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier and number of each document reversing this invoice.
|
||||
*
|
||||
* Reindexed, because the loaded relation's keys are positions in the parent
|
||||
* result set and would otherwise be published as object keys.
|
||||
*/
|
||||
private function creditNoteReferences(): Collection
|
||||
{
|
||||
return $this->creditNotes
|
||||
->map(fn ($note) => [
|
||||
'id' => $note->id,
|
||||
'invoice_number' => $note->invoice_number,
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the crediting has gone: none of it, all of it, or part of it.
|
||||
*/
|
||||
private function creditedStatus(): string
|
||||
{
|
||||
$credited = $this->creditedTotal();
|
||||
|
||||
return match (true) {
|
||||
$credited === 0 => 'NONE',
|
||||
$credited === (int) $this->total => 'FULL',
|
||||
default => 'PARTIAL',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Credited quantity per line of this invoice, keyed by the line's id.
|
||||
*
|
||||
* Reversing lines that do not point back at an original line contribute
|
||||
* nothing. The result is handed over as an object rather than an array: the
|
||||
* keys are line ids, and an all-numeric nested array would be reindexed
|
||||
* into a list by the resource filter, throwing those ids away.
|
||||
*/
|
||||
private function creditedQuantities(): object
|
||||
{
|
||||
$quantities = [];
|
||||
|
||||
foreach ($this->creditNotes as $note) {
|
||||
foreach ($note->items as $line) {
|
||||
$source = $line->source_invoice_item_id;
|
||||
|
||||
if (! $source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quantities[$source] = ($quantities[$source] ?? 0) + (float) $line->quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return (object) $quantities;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per payment allocated against this invoice.
|
||||
*
|
||||
* The paying document is nested only when it came along with the
|
||||
* allocation; otherwise the row still reports the allocated amounts and
|
||||
* leaves the payment null rather than fetching it.
|
||||
*/
|
||||
private function allocationSummaries(): Collection
|
||||
{
|
||||
return $this->allocations
|
||||
->map(fn ($allocation) => [
|
||||
'id' => $allocation->id,
|
||||
'payment_id' => $allocation->payment_id,
|
||||
'amount' => $allocation->amount,
|
||||
'base_amount' => $allocation->base_amount,
|
||||
'payment' => $this->allocatedPayment($allocation),
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* The paying document behind one allocation, when it is already loaded.
|
||||
*/
|
||||
private function allocatedPayment($allocation): ?array
|
||||
{
|
||||
if (! $allocation->relationLoaded('payment') || ! $allocation->payment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $allocation->payment->id,
|
||||
'payment_number' => $allocation->payment->payment_number,
|
||||
'formatted_payment_date' => $allocation->payment->formattedPaymentDate,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
/**
|
||||
* A page of recurring invoices for the admin API.
|
||||
*
|
||||
* Named after its member resource, so rows are published through
|
||||
* RecurringInvoiceResource and the pagination envelope comes from the framework.
|
||||
*/
|
||||
class RecurringInvoiceCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return parent::toArray($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Http\Resources;
|
||||
|
||||
use App\Domains\Accounts\Http\Resources\CompanyResource;
|
||||
use App\Domains\Accounts\Http\Resources\UserResource;
|
||||
use App\Domains\Contacts\Http\Resources\CustomerResource;
|
||||
use App\Domains\Metadata\Http\Resources\CustomFieldValueResource;
|
||||
use App\Domains\Money\Http\Resources\CurrencyResource;
|
||||
use App\Domains\Taxation\Http\Resources\TaxResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* A recurring invoice as the admin API publishes it.
|
||||
*
|
||||
* A recurring invoice is a schedule plus a template of a document, so the
|
||||
* payload opens with the scheduling half -- when it starts, when it fires next,
|
||||
* the cron frequency, how it is limited and whether generated invoices go out
|
||||
* on their own, each key date also rendered in the company's date format -- and
|
||||
* continues with the template half, the same amounts and flags an invoice
|
||||
* carries.
|
||||
*
|
||||
* The related records are each gated behind an existence probe on the relation,
|
||||
* including `invoices`, which publishes the documents this schedule has already
|
||||
* produced through the invoice resource itself.
|
||||
*/
|
||||
class RecurringInvoiceResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @param Request $request
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$recurring = $this->resource;
|
||||
|
||||
return [
|
||||
'id' => $recurring->id,
|
||||
'starts_at' => $recurring->starts_at,
|
||||
'formatted_starts_at' => $recurring->formattedStartsAt,
|
||||
'formatted_created_at' => $recurring->formattedCreatedAt,
|
||||
'formatted_next_invoice_at' => $recurring->formattedNextInvoiceAt,
|
||||
'formatted_limit_date' => $recurring->formattedLimitDate,
|
||||
'send_automatically' => $recurring->send_automatically,
|
||||
'customer_id' => $recurring->customer_id,
|
||||
'company_id' => $recurring->company_id,
|
||||
'creator_id' => $recurring->creator_id,
|
||||
'status' => $recurring->status,
|
||||
'next_invoice_at' => $recurring->next_invoice_at,
|
||||
'frequency' => $recurring->frequency,
|
||||
'limit_by' => $recurring->limit_by,
|
||||
'limit_count' => $recurring->limit_count,
|
||||
'limit_date' => $recurring->limit_date,
|
||||
'exchange_rate' => $recurring->exchange_rate,
|
||||
'tax_per_item' => $recurring->tax_per_item,
|
||||
'tax_included' => $recurring->tax_included,
|
||||
'discount_per_item' => $recurring->discount_per_item,
|
||||
'notes' => $recurring->notes,
|
||||
'discount_type' => $recurring->discount_type,
|
||||
'discount' => $recurring->discount,
|
||||
'discount_val' => $recurring->discount_val,
|
||||
'sub_total' => $recurring->sub_total,
|
||||
'total' => $recurring->total,
|
||||
'tax' => $recurring->tax,
|
||||
'due_amount' => $recurring->due_amount,
|
||||
'template_name' => $recurring->template_name,
|
||||
'sales_tax_type' => $recurring->sales_tax_type,
|
||||
'sales_tax_address_type' => $recurring->sales_tax_address_type,
|
||||
'fields' => $this->when(
|
||||
$recurring->fields()->exists(),
|
||||
fn () => CustomFieldValueResource::collection($recurring->fields)
|
||||
),
|
||||
'items' => $this->when(
|
||||
$recurring->items()->exists(),
|
||||
fn () => InvoiceItemResource::collection($recurring->items)
|
||||
),
|
||||
'customer' => $this->when(
|
||||
$recurring->customer()->exists(),
|
||||
fn () => new CustomerResource($recurring->customer)
|
||||
),
|
||||
'company' => $this->when(
|
||||
$recurring->company()->exists(),
|
||||
fn () => new CompanyResource($recurring->company)
|
||||
),
|
||||
'invoices' => $this->when(
|
||||
$recurring->invoices()->exists(),
|
||||
fn () => InvoiceResource::collection($recurring->invoices)
|
||||
),
|
||||
'taxes' => $this->when(
|
||||
$recurring->taxes()->exists(),
|
||||
fn () => TaxResource::collection($recurring->taxes)
|
||||
),
|
||||
'creator' => $this->when(
|
||||
$recurring->creator()->exists(),
|
||||
fn () => new UserResource($recurring->creator)
|
||||
),
|
||||
'currency' => $this->when(
|
||||
$recurring->currency()->exists(),
|
||||
fn () => new CurrencyResource($recurring->currency)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Jobs;
|
||||
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Renders an estimate's PDF off the request thread.
|
||||
*
|
||||
* The stored file is named after the document number, so anything that can
|
||||
* change that number — an edit, a re-render after a conversion — asks for the
|
||||
* previous file to be dropped first rather than leaving a stale twin behind.
|
||||
*/
|
||||
class GenerateEstimatePdfJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public $estimate;
|
||||
|
||||
public $deleteExistingFile;
|
||||
|
||||
/**
|
||||
* @param Estimate $estimate
|
||||
* @param bool $deleteExistingFile drop the previously stored file first
|
||||
*/
|
||||
public function __construct(
|
||||
$estimate,
|
||||
$deleteExistingFile = false
|
||||
) {
|
||||
$this->estimate = $estimate;
|
||||
$this->deleteExistingFile = $deleteExistingFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the work to the document itself and always reports success — the
|
||||
* return value is a leftover of the queue contract; nothing reads it.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$document = $this->estimate;
|
||||
|
||||
$document->generatePDF('estimate', $document->estimate_number, $this->deleteExistingFile);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Jobs;
|
||||
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Renders an invoice's PDF off the request thread.
|
||||
*
|
||||
* The stored file is named after the document number, so anything that can
|
||||
* change that number — an edit, a re-render after crediting — asks for the
|
||||
* previous file to be dropped first rather than leaving a stale twin behind.
|
||||
*/
|
||||
class GenerateInvoicePdfJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public $invoice;
|
||||
|
||||
public $deleteExistingFile;
|
||||
|
||||
/**
|
||||
* @param Invoice $invoice
|
||||
* @param bool $deleteExistingFile drop the previously stored file first
|
||||
*/
|
||||
public function __construct(
|
||||
$invoice,
|
||||
$deleteExistingFile = false
|
||||
) {
|
||||
$this->invoice = $invoice;
|
||||
$this->deleteExistingFile = $deleteExistingFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the work to the document itself and always reports success — the
|
||||
* return value is a leftover of the queue contract; nothing reads it.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$document = $this->invoice;
|
||||
|
||||
$document->generatePDF('invoice', $document->invoice_number, $this->deleteExistingFile);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Tells the company that one of its estimates has just been opened.
|
||||
*
|
||||
* This one travels inward, to the address the company nominated for view
|
||||
* notifications, so it goes out under the installation's own mail identity
|
||||
* rather than the address the estimate itself was sent from.
|
||||
*/
|
||||
class EstimateViewedMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public $data;
|
||||
|
||||
/**
|
||||
* @param array $data the estimate that was opened and the user it
|
||||
* belongs to, shaped as the view expects them
|
||||
*/
|
||||
public function __construct(
|
||||
$data
|
||||
) {
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function build()
|
||||
{
|
||||
return $this->subject(__('notification_view_estimate'))
|
||||
->from(config('mail.from.address'), config('mail.from.name'))
|
||||
->markdown('emails.viewed.estimate', [
|
||||
// Handed over as a list, not as a keyed array. The numeric
|
||||
// keys that produces are inert: the view reads $data, which
|
||||
// Laravel already supplies from the public property above.
|
||||
'data',
|
||||
$this->data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Tells the company that one of its invoices has just been opened.
|
||||
*
|
||||
* This one travels inward, to the address the company nominated for view
|
||||
* notifications, so it goes out under the installation's own mail identity
|
||||
* rather than the address the invoice itself was sent from.
|
||||
*/
|
||||
class InvoiceViewedMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public $data;
|
||||
|
||||
/**
|
||||
* @param array $data the invoice that was opened and the user it
|
||||
* belongs to, shaped as the view expects them
|
||||
*/
|
||||
public function __construct(
|
||||
$data
|
||||
) {
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function build()
|
||||
{
|
||||
return $this->subject(__('notification_view_invoice'))
|
||||
->from(config('mail.from.address'), config('mail.from.name'))
|
||||
->markdown('emails.viewed.invoice', [
|
||||
// Handed over as a list, not as a keyed array. The numeric
|
||||
// keys that produces are inert: the view reads $data, which
|
||||
// Laravel already supplies from the public property above.
|
||||
'data',
|
||||
$this->data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Mail;
|
||||
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use App\Facades\Hashids;
|
||||
use App\Platform\Mail\Models\EmailLog;
|
||||
use App\Platform\Persistence\ModelIdentityMap;
|
||||
use App\Support\Hashids\HashidConnection;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* The estimate as its customer receives it.
|
||||
*
|
||||
* Assembling the message has a side effect. Every send is first written to
|
||||
* the email log, and the hashid of that row is the token behind the
|
||||
* "view your estimate" link, so the row has to exist — and carry its token —
|
||||
* before the body reaches the markdown view.
|
||||
*
|
||||
* The PDF rides along only when the caller left a renderer in the payload;
|
||||
* whether it did is the company's attachment setting talking, decided
|
||||
* upstream rather than here.
|
||||
*/
|
||||
class SendEstimateMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public $data = [];
|
||||
|
||||
/**
|
||||
* @param array $data sender and recipients, the already-interpolated
|
||||
* subject and body, the estimate payload, and the
|
||||
* optional PDF renderer under `attach.data`
|
||||
*/
|
||||
public function __construct(
|
||||
$data
|
||||
) {
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function build()
|
||||
{
|
||||
$this->data['url'] = route('estimate', [
|
||||
'email_log' => $this->logDelivery(),
|
||||
]);
|
||||
|
||||
$payload = $this->data;
|
||||
|
||||
$message = $this->from($payload['from'], config('mail.from.name'))
|
||||
->subject($payload['subject'])
|
||||
->markdown('emails.send.estimate', [
|
||||
// Handed over as a list, not as a keyed array. The numeric
|
||||
// keys that produces are inert: the view reads $data, which
|
||||
// Laravel already supplies from the public property above.
|
||||
'data',
|
||||
$this->data,
|
||||
]);
|
||||
|
||||
$pdf = $payload['attach']['data'];
|
||||
|
||||
if ($pdf) {
|
||||
$message->attachData(
|
||||
$pdf->output(),
|
||||
$payload['estimate']['estimate_number'].'.pdf'
|
||||
);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the outgoing message and give back the token that identifies it
|
||||
* in a public link.
|
||||
*/
|
||||
private function logDelivery(): string
|
||||
{
|
||||
$payload = $this->data;
|
||||
$alias = ModelIdentityMap::aliasFor(Estimate::class);
|
||||
|
||||
$log = EmailLog::create([
|
||||
'from' => $payload['from'],
|
||||
'to' => $payload['to'],
|
||||
'cc' => $payload['cc'] ?? null,
|
||||
'bcc' => $payload['bcc'] ?? null,
|
||||
'subject' => $payload['subject'],
|
||||
'body' => $payload['body'],
|
||||
'mailable_type' => $alias,
|
||||
'mailable_id' => $payload['estimate']['id'],
|
||||
]);
|
||||
|
||||
$log->token = Hashids::connection(HashidConnection::EmailLog->value)
|
||||
->encode($log->id);
|
||||
|
||||
$log->save();
|
||||
|
||||
return $log->token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Mail;
|
||||
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Facades\Hashids;
|
||||
use App\Platform\Mail\Models\EmailLog;
|
||||
use App\Platform\Persistence\ModelIdentityMap;
|
||||
use App\Support\Hashids\HashidConnection;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* The invoice as its customer receives it.
|
||||
*
|
||||
* Assembling the message has a side effect. Every send is first written to
|
||||
* the email log, and the hashid of that row is the token behind the
|
||||
* "view your invoice" link, so the row has to exist — and carry its token —
|
||||
* before the body reaches the markdown view.
|
||||
*
|
||||
* The PDF rides along only when the caller left a renderer in the payload;
|
||||
* whether it did is the company's attachment setting talking, decided
|
||||
* upstream rather than here.
|
||||
*/
|
||||
class SendInvoiceMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public $data = [];
|
||||
|
||||
/**
|
||||
* @param array $data sender and recipients, the already-interpolated
|
||||
* subject and body, the invoice payload, and the
|
||||
* optional PDF renderer under `attach.data`
|
||||
*/
|
||||
public function __construct(
|
||||
$data
|
||||
) {
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function build()
|
||||
{
|
||||
$this->data['url'] = route('invoice', [
|
||||
'email_log' => $this->logDelivery(),
|
||||
]);
|
||||
|
||||
$payload = $this->data;
|
||||
|
||||
$message = $this->from($payload['from'], config('mail.from.name'))
|
||||
->subject($payload['subject'])
|
||||
->markdown('emails.send.invoice', [
|
||||
// Handed over as a list, not as a keyed array. The numeric
|
||||
// keys that produces are inert: the view reads $data, which
|
||||
// Laravel already supplies from the public property above.
|
||||
'data',
|
||||
$this->data,
|
||||
]);
|
||||
|
||||
$pdf = $payload['attach']['data'];
|
||||
|
||||
if ($pdf) {
|
||||
$message->attachData(
|
||||
$pdf->output(),
|
||||
$payload['invoice']['invoice_number'].'.pdf'
|
||||
);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the outgoing message and give back the token that identifies it
|
||||
* in a public link.
|
||||
*/
|
||||
private function logDelivery(): string
|
||||
{
|
||||
$payload = $this->data;
|
||||
$alias = ModelIdentityMap::aliasFor(Invoice::class);
|
||||
|
||||
$log = EmailLog::create([
|
||||
'from' => $payload['from'],
|
||||
'to' => $payload['to'],
|
||||
'cc' => $payload['cc'] ?? null,
|
||||
'bcc' => $payload['bcc'] ?? null,
|
||||
'subject' => $payload['subject'],
|
||||
'body' => $payload['body'],
|
||||
'mailable_type' => $alias,
|
||||
'mailable_id' => $payload['invoice']['id'],
|
||||
]);
|
||||
|
||||
$log->token = Hashids::connection(HashidConnection::EmailLog->value)
|
||||
->encode($log->id);
|
||||
|
||||
$log->save();
|
||||
|
||||
return $log->token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Models;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Metadata\Concerns\HasCustomFields;
|
||||
use App\Domains\Money\Models\Currency;
|
||||
use App\Domains\Sales\Contracts\EstimatePdfDataProvider;
|
||||
use App\Domains\Taxation\Models\Tax;
|
||||
use App\Platform\Mail\Models\EmailLog;
|
||||
use App\Platform\Pdf\Concerns\GeneratesPdf;
|
||||
use App\Platform\Pdf\Rendering\PdfHtmlSanitizer;
|
||||
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
|
||||
use App\Support\SafeOrderBy;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
/**
|
||||
* An offer made to a customer, convertible into an invoice.
|
||||
*
|
||||
* The record is a snapshot: it keeps its own copy of the amounts, the line
|
||||
* items and the taxes, so later edits to the catalog or to a tax type leave
|
||||
* already-issued offers untouched. It renders to a PDF and can be mailed, which
|
||||
* is why it carries the PDF concern and a media collection. The lifecycle runs
|
||||
* DRAFT to SENT to VIEWED, with ACCEPTED, REJECTED and EXPIRED as terminals;
|
||||
* the constants below are the whole vocabulary.
|
||||
*
|
||||
* Business logic lives in the Sales services — what is kept here is the shape
|
||||
* of the record: relations, casts, query scopes and the strings the PDF and
|
||||
* mail layers ask for.
|
||||
*/
|
||||
class Estimate extends Model implements HasMedia
|
||||
{
|
||||
use GeneratesPdf;
|
||||
use HasCustomFields;
|
||||
use HasFactory;
|
||||
use InteractsWithMedia;
|
||||
|
||||
public const STATUS_DRAFT = 'DRAFT';
|
||||
|
||||
public const STATUS_SENT = 'SENT';
|
||||
|
||||
public const STATUS_VIEWED = 'VIEWED';
|
||||
|
||||
public const STATUS_EXPIRED = 'EXPIRED';
|
||||
|
||||
public const STATUS_ACCEPTED = 'ACCEPTED';
|
||||
|
||||
public const STATUS_REJECTED = 'REJECTED';
|
||||
|
||||
protected $table = 'estimates';
|
||||
|
||||
/**
|
||||
* Everything but the primary key may be mass assigned.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $guarded = [
|
||||
'id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Columns holding a point in time.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dates = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'estimate_date',
|
||||
'expiry_date',
|
||||
];
|
||||
|
||||
/**
|
||||
* Computed attributes, listed in the order they are serialized.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $appends = [
|
||||
'formattedExpiryDate',
|
||||
'formattedEstimateDate',
|
||||
'estimatePdfUrl',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attribute casts.
|
||||
*
|
||||
* Money is held in integer minor units; the discount percentage and the
|
||||
* exchange rate are the only fractional values on the record.
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total' => 'integer',
|
||||
'tax' => 'integer',
|
||||
'sub_total' => 'integer',
|
||||
'discount' => 'float',
|
||||
'discount_val' => 'integer',
|
||||
'exchange_rate' => 'float',
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Line items making up the offer.
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(EstimateItem::class, 'estimate_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-level tax rows, as opposed to the per-item ones hanging off the
|
||||
* line items.
|
||||
*/
|
||||
public function taxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tax::class, 'estimate_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact the offer was made to.
|
||||
*/
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff account that raised the offer.
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Company the offer was issued under.
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'company_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency the stored amounts are denominated in.
|
||||
*/
|
||||
public function currency(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Currency::class, 'currency_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail sent out for this offer, recorded through the polymorphic log.
|
||||
*/
|
||||
public function emailLogs(): MorphMany
|
||||
{
|
||||
return $this->morphMany(EmailLog::class, 'mailable', 'mailable_type', 'mailable_id', 'id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shareable PDF address. Possession of the hash is the credential, so the
|
||||
* link carries no company or customer context of its own.
|
||||
*/
|
||||
public function getEstimatePdfUrlAttribute()
|
||||
{
|
||||
$path = '/estimates/pdf/'.$this->unique_hash;
|
||||
|
||||
return url($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expiry written in the company's configured date format and in the
|
||||
* language the application is running in.
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function getFormattedExpiryDateAttribute($value)
|
||||
{
|
||||
$format = CompanySetting::getSetting('carbon_date_format', $this->company_id);
|
||||
|
||||
return Carbon::parse($this->expiry_date)->translatedFormat($format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue date written the same way as the expiry above.
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function getFormattedEstimateDateAttribute($value)
|
||||
{
|
||||
$format = CompanySetting::getSetting('carbon_date_format', $this->company_id);
|
||||
|
||||
return Carbon::parse($this->estimate_date)->translatedFormat($format);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run every listed filter that carries a value.
|
||||
*
|
||||
* The order the filters are applied in is load-bearing: `estimate_id` is an
|
||||
* OR (see whereEstimate), so it widens whatever has been narrowed down to
|
||||
* that point and nothing added afterwards. Keep the sequence as it stands.
|
||||
*/
|
||||
public function scopeApplyFilters($query, array $filters)
|
||||
{
|
||||
$scopes = [
|
||||
'search' => 'whereSearch',
|
||||
'estimate_number' => 'whereEstimateNumber',
|
||||
'status' => 'whereStatus',
|
||||
'estimate_id' => 'whereEstimate',
|
||||
];
|
||||
|
||||
foreach ($scopes as $filter => $scope) {
|
||||
$value = $filters[$filter] ?? null;
|
||||
|
||||
if ($value) {
|
||||
$query->{$scope}($value);
|
||||
}
|
||||
}
|
||||
|
||||
$from = $filters['from_date'] ?? null;
|
||||
$to = $filters['to_date'] ?? null;
|
||||
|
||||
if ($from && $to) {
|
||||
$query->estimatesBetween(
|
||||
Carbon::createFromFormat('Y-m-d', $from),
|
||||
Carbon::createFromFormat('Y-m-d', $to)
|
||||
);
|
||||
}
|
||||
|
||||
$contact = $filters['customer_id'] ?? null;
|
||||
|
||||
if ($contact) {
|
||||
$query->whereCustomer($contact);
|
||||
}
|
||||
|
||||
$sortField = $filters['orderByField'] ?? null;
|
||||
$sortDirection = $filters['orderBy'] ?? null;
|
||||
|
||||
if ($sortField || $sortDirection) {
|
||||
$query->whereOrder($sortField ?: 'sequence_number', $sortDirection ?: 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict to offers issued inside the inclusive range.
|
||||
*/
|
||||
public function scopeEstimatesBetween($query, $start, $end)
|
||||
{
|
||||
$range = [$start->format('Y-m-d'), $end->format('Y-m-d')];
|
||||
|
||||
return $query->whereBetween($this->qualifyColumn('estimate_date'), $range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact match on the lifecycle status.
|
||||
*/
|
||||
public function scopeWhereStatus($query, $status)
|
||||
{
|
||||
return $query->where($this->qualifyColumn('status'), $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial match on the document number.
|
||||
*/
|
||||
public function scopeWhereEstimateNumber($query, $estimateNumber)
|
||||
{
|
||||
return $query->where($this->qualifyColumn('estimate_number'), 'LIKE', '%'.$estimateNumber.'%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull one specific offer back into the result set.
|
||||
*
|
||||
* This is an OR against an unqualified `id`, not a narrowing filter — it
|
||||
* adds the row to whatever the other filters matched rather than
|
||||
* intersecting with them. Preserved deliberately.
|
||||
*/
|
||||
public function scopeWhereEstimate($query, $estimate_id)
|
||||
{
|
||||
return $query->orWhere('id', $estimate_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only offers whose customer matches every whitespace-separated term,
|
||||
* a term counting as matched when it appears in the display name, the
|
||||
* contact person or the company name.
|
||||
*/
|
||||
public function scopeWhereSearch($query, $search)
|
||||
{
|
||||
$terms = explode(' ', $search);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$needle = '%'.$term.'%';
|
||||
|
||||
$query->whereHas('customer', function ($contact) use ($needle) {
|
||||
$contact->where('name', 'LIKE', $needle)
|
||||
->orWhere('contact_name', 'LIKE', $needle)
|
||||
->orWhere('company_name', 'LIKE', $needle);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by a caller-supplied column, sanitised before it reaches SQL.
|
||||
*/
|
||||
public function scopeWhereOrder($query, $orderByField, $orderBy)
|
||||
{
|
||||
return SafeOrderBy::apply($query, $orderByField, $orderBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to the company the current request is acting on.
|
||||
*/
|
||||
public function scopeWhereCompany($query)
|
||||
{
|
||||
$active = request()->header('company');
|
||||
|
||||
return $query->where($this->qualifyColumn('company_id'), $active);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to one contact's offers.
|
||||
*/
|
||||
public function scopeWhereCustomer($query, $customer_id)
|
||||
{
|
||||
return $query->where($this->qualifyColumn('customer_id'), $customer_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the whole result set for the sentinel limit "all", otherwise a
|
||||
* page of the requested size.
|
||||
*/
|
||||
public function scopePaginateData($query, $limit)
|
||||
{
|
||||
return $limit == 'all' ? $query->get() : $query->paginate($limit);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| PDF and mail
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* View data for the PDF template, assembled by the Sales domain.
|
||||
*/
|
||||
public function getPDFData(): mixed
|
||||
{
|
||||
$provider = app(EstimatePdfDataProvider::class);
|
||||
|
||||
return $provider->getPdfData($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issuer's postal address as the PDF wants it, or false when the company
|
||||
* has no address on file at all.
|
||||
*/
|
||||
public function getCompanyAddress(): string|false
|
||||
{
|
||||
if ($this->company && ! $this->company->address()->exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->renderAddress('estimate_company_address_format');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the goods would ship, or false when the contact keeps no shipping
|
||||
* address.
|
||||
*/
|
||||
public function getCustomerShippingAddress(): string|false
|
||||
{
|
||||
if ($this->customer && ! $this->customer->shippingAddress()->exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->renderAddress('estimate_shipping_address_format');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the offer would be billed, or false when the contact keeps no
|
||||
* billing address.
|
||||
*/
|
||||
public function getCustomerBillingAddress(): string|false
|
||||
{
|
||||
if ($this->customer && ! $this->customer->billingAddress()->exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->renderAddress('estimate_billing_address_format');
|
||||
}
|
||||
|
||||
/**
|
||||
* The notes field with its placeholders filled in and the resulting markup
|
||||
* scrubbed before it reaches the renderer.
|
||||
*/
|
||||
public function getNotes(): string
|
||||
{
|
||||
$rendered = $this->getFormattedString($this->notes);
|
||||
|
||||
return PdfHtmlSanitizer::sanitize($rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the PDF should ride along with the mail. Anything other than the
|
||||
* explicit opt-out means yes.
|
||||
*/
|
||||
public function getEmailAttachmentSetting(): bool
|
||||
{
|
||||
$setting = CompanySetting::getSetting('estimate_email_attachment', $this->company_id);
|
||||
|
||||
return $setting != 'NO';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the placeholders in a mail body, then drop any brace token that was
|
||||
* left standing because nothing answered to it.
|
||||
*/
|
||||
public function getEmailBody(string $body): string
|
||||
{
|
||||
$placeholders = array_merge($this->getFieldsArray(), $this->getExtraFields());
|
||||
|
||||
$filled = strtr($body, $placeholders);
|
||||
|
||||
return preg_replace('/{(.*?)}/', '', $filled);
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholders this document type contributes on top of the shared
|
||||
* company/contact set.
|
||||
*/
|
||||
public function getExtraFields(): array
|
||||
{
|
||||
$tokens = [
|
||||
'ESTIMATE_DATE' => $this->formattedEstimateDate,
|
||||
'ESTIMATE_EXPIRY_DATE' => $this->formattedExpiryDate,
|
||||
'ESTIMATE_NUMBER' => $this->estimate_number,
|
||||
'ESTIMATE_REF_NUMBER' => $this->reference_number,
|
||||
];
|
||||
|
||||
$fields = [];
|
||||
|
||||
foreach ($tokens as $token => $value) {
|
||||
$fields['{'.$token.'}'] = $value;
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* The invoice template that corresponds to this offer's own template.
|
||||
*
|
||||
* The two families are named in parallel, so the mapping is a word swap.
|
||||
* When the swapped name is not among the installed invoice templates the
|
||||
* first one takes over.
|
||||
*/
|
||||
public function getInvoiceTemplateName(): string
|
||||
{
|
||||
$mapped = Str::replace('estimate', 'invoice', $this->template_name);
|
||||
|
||||
// The second argument is the preview image format. Leaving it empty
|
||||
// stops the helper from rendering a base64 thumbnail of every template
|
||||
// when all that is wanted here are the names.
|
||||
$available = array_column(PdfTemplateUtils::getFormattedTemplates('invoice', ''), 'name');
|
||||
|
||||
return in_array($mapped, $available) ? $mapped : 'invoice1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply whatever the company wants done with an offer once it has been
|
||||
* turned into an invoice: drop it, or mark it as accepted. Any other
|
||||
* setting leaves the record alone.
|
||||
*/
|
||||
public function checkForEstimateConvertAction(): bool
|
||||
{
|
||||
$action = CompanySetting::getSetting('estimate_convert_action', $this->company_id);
|
||||
|
||||
if ($action === 'delete_estimate') {
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
if ($action === 'mark_estimate_as_accepted') {
|
||||
$this->fill(['status' => self::STATUS_ACCEPTED])->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one of the company's address layouts against this document.
|
||||
*/
|
||||
private function renderAddress(string $setting): string
|
||||
{
|
||||
$layout = CompanySetting::getSetting($setting, $this->company_id);
|
||||
|
||||
return $this->getFormattedString($layout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\Item;
|
||||
use App\Domains\Metadata\Concerns\HasCustomFields;
|
||||
use App\Domains\Taxation\Models\Tax;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* One line on an estimate.
|
||||
*
|
||||
* The row is a snapshot taken when the offer was raised: the name, the
|
||||
* description, the unit price and the computed total are stored here rather
|
||||
* than read back from the catalog, so editing an item later never rewrites an
|
||||
* offer that has already gone out. The link to the catalog entry is kept for
|
||||
* reporting only and may be absent on a free-text line. Amounts are integer
|
||||
* minor units; quantity and the discount percentage are the fractional ones.
|
||||
*/
|
||||
class EstimateItem extends Model
|
||||
{
|
||||
use HasCustomFields;
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'estimate_items';
|
||||
|
||||
/**
|
||||
* Everything but the primary key may be mass assigned.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $guarded = [
|
||||
'id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attribute casts.
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'integer',
|
||||
'total' => 'integer',
|
||||
'discount' => 'float',
|
||||
'quantity' => 'float',
|
||||
'discount_val' => 'integer',
|
||||
'tax' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer the line belongs to.
|
||||
*/
|
||||
public function estimate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Estimate::class, 'estimate_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog entry the line was built from, when there was one.
|
||||
*/
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Item::class, 'item_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Taxes charged on this line, used when the document is in per-item tax
|
||||
* mode.
|
||||
*/
|
||||
public function taxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tax::class, 'estimate_item_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to one company. The column is left unqualified, as the callers
|
||||
* pass a plain estimate-item query.
|
||||
*/
|
||||
public function scopeWhereCompany(Builder $query, int $company_id): void
|
||||
{
|
||||
$query->where('company_id', '=', $company_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Models;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Metadata\Concerns\HasCustomFields;
|
||||
use App\Domains\Money\Models\Currency;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Receivables\Models\PaymentAllocation;
|
||||
use App\Domains\Receivables\Models\Transaction;
|
||||
use App\Domains\Sales\Contracts\InvoicePdfDataProvider;
|
||||
use App\Domains\Taxation\Models\Tax;
|
||||
use App\Platform\Mail\Models\EmailLog;
|
||||
use App\Platform\Pdf\Concerns\GeneratesPdf;
|
||||
use App\Platform\Pdf\Rendering\PdfHtmlSanitizer;
|
||||
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
|
||||
use App\Support\SafeOrderBy;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Str;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
/**
|
||||
* A billing document raised against a contact.
|
||||
*
|
||||
* One table backs two kinds of document, told apart by the `type` column: an
|
||||
* ordinary invoice, and the credit note that reverses one and therefore carries
|
||||
* negative amounts and a pointer back at its original.
|
||||
*
|
||||
* Two independent axes describe where a document stands. `status` tracks how far
|
||||
* it has travelled towards the customer (draft, sent, viewed, completed) and
|
||||
* `paid_status` tracks the money (unpaid, partially paid, paid). The pair is
|
||||
* re-derived from the outstanding balance every time that balance moves, which
|
||||
* is why the two never have to be set by hand.
|
||||
*
|
||||
* Every monetary column holds integer minor units, and each has a `base_`
|
||||
* counterpart holding the same figure multiplied by the document's exchange
|
||||
* rate, so a company reporting in its own currency never has to re-convert.
|
||||
*/
|
||||
class Invoice extends Model implements HasMedia
|
||||
{
|
||||
use GeneratesPdf;
|
||||
use HasCustomFields;
|
||||
use HasFactory;
|
||||
use InteractsWithMedia;
|
||||
|
||||
/**
|
||||
* Raised but not yet handed to the customer.
|
||||
*/
|
||||
public const STATUS_DRAFT = 'DRAFT';
|
||||
|
||||
/**
|
||||
* Delivered to the customer.
|
||||
*/
|
||||
public const STATUS_SENT = 'SENT';
|
||||
|
||||
/**
|
||||
* Opened by the customer through a shared link.
|
||||
*/
|
||||
public const STATUS_VIEWED = 'VIEWED';
|
||||
|
||||
/**
|
||||
* Settled in full and closed.
|
||||
*/
|
||||
public const STATUS_COMPLETED = 'COMPLETED';
|
||||
|
||||
/**
|
||||
* Nothing has been collected yet.
|
||||
*/
|
||||
public const STATUS_UNPAID = 'UNPAID';
|
||||
|
||||
/**
|
||||
* Some of the balance has been collected.
|
||||
*/
|
||||
public const STATUS_PARTIALLY_PAID = 'PARTIALLY_PAID';
|
||||
|
||||
/**
|
||||
* The whole balance has been collected.
|
||||
*/
|
||||
public const STATUS_PAID = 'PAID';
|
||||
|
||||
/**
|
||||
* An ordinary, positively signed document.
|
||||
*/
|
||||
public const TYPE_INVOICE = 'INVOICE';
|
||||
|
||||
/**
|
||||
* A reversal of an earlier document, carrying negative amounts.
|
||||
*/
|
||||
public const TYPE_CREDIT_NOTE = 'CREDIT_NOTE';
|
||||
|
||||
protected $table = 'invoices';
|
||||
|
||||
/**
|
||||
* Everything but the primary key may be mass assigned.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $guarded = [
|
||||
'id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Columns the pre-cast date handling used to hydrate as instances.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dates = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'invoice_date',
|
||||
'due_date',
|
||||
];
|
||||
|
||||
/**
|
||||
* Computed attributes, listed in the order they are serialized.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $appends = [
|
||||
'formattedCreatedAt',
|
||||
'formattedInvoiceDate',
|
||||
'formattedDueDate',
|
||||
'formattedDueAmount',
|
||||
'invoicePdfUrl',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attribute casts.
|
||||
*
|
||||
* Amounts are whole minor units. The two figures that are genuinely
|
||||
* fractional, the percentage discount and the exchange rate, are floats.
|
||||
* The outstanding balance is deliberately absent: it is written by the
|
||||
* balance helpers below and left in whatever shape the driver hands back.
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total' => 'integer',
|
||||
'tax' => 'integer',
|
||||
'sub_total' => 'integer',
|
||||
'discount' => 'float',
|
||||
'discount_val' => 'integer',
|
||||
'exchange_rate' => 'float',
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ledger entries written when the document is settled.
|
||||
*/
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail sent about this document.
|
||||
*/
|
||||
public function emailLogs(): MorphMany
|
||||
{
|
||||
return $this->morphMany(EmailLog::class, 'mailable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Line items, snapshotted from the catalog at the time of writing.
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvoiceItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-level applied taxes.
|
||||
*/
|
||||
public function taxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tax::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual slices of payments booked against this document.
|
||||
*/
|
||||
public function allocations(): HasMany
|
||||
{
|
||||
return $this->hasMany(PaymentAllocation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Payments touching this document, with the allocated amounts carried on
|
||||
* the pivot.
|
||||
*/
|
||||
public function payments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Payment::class, 'payment_allocations')
|
||||
->withPivot(['amount', 'base_amount'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency the document was issued in.
|
||||
*/
|
||||
public function currency(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Currency::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Company the document was raised under.
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact the document was raised for.
|
||||
*/
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule that generated this document, when it was not raised by hand.
|
||||
*/
|
||||
public function recurringInvoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RecurringInvoice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff account that raised the document.
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The document this one reverses, null on anything but a credit note.
|
||||
*/
|
||||
public function relatedInvoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Invoice::class, 'related_invoice_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reversals raised against this document.
|
||||
*/
|
||||
public function creditNotes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class, 'related_invoice_id')
|
||||
->where('type', self::TYPE_CREDIT_NOTE);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether this document reverses another one.
|
||||
*/
|
||||
public function isCreditNote(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_CREDIT_NOTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shareable link to the rendered PDF. Possession of the hash is the only
|
||||
* credential the link needs.
|
||||
*/
|
||||
public function getInvoicePdfUrlAttribute()
|
||||
{
|
||||
return url('/invoices/pdf/'.$this->unique_hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the optional payments module is installed and switched on.
|
||||
*/
|
||||
public function getPaymentModuleEnabledAttribute()
|
||||
{
|
||||
return Module::has('Payments') ? Module::isEnabled('Payments') : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the document may still be altered.
|
||||
*
|
||||
* A credited invoice is immutable: its line item ids anchor the lines of
|
||||
* every credit note that reverses it. Past that, the company's
|
||||
* retrospective-edits setting decides, tightening in three steps from
|
||||
* "sent and part paid" through "part paid" to "paid".
|
||||
*/
|
||||
public function getAllowEditAttribute()
|
||||
{
|
||||
if ($this->hasCreditNotes()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mode = CompanySetting::getSetting('retrospective_edits', $this->company_id);
|
||||
|
||||
$collected = $this->paid_status === self::STATUS_PARTIALLY_PAID
|
||||
|| $this->paid_status === self::STATUS_PAID;
|
||||
|
||||
$undelivered = [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_SENT,
|
||||
self::STATUS_VIEWED,
|
||||
self::STATUS_COMPLETED,
|
||||
];
|
||||
|
||||
if ($mode == 'disable_on_invoice_sent') {
|
||||
return ! (in_array($this->status, $undelivered) && $collected);
|
||||
}
|
||||
|
||||
if ($mode == 'disable_on_invoice_partial_paid') {
|
||||
return ! $collected;
|
||||
}
|
||||
|
||||
if ($mode == 'disable_on_invoice_paid') {
|
||||
return $this->paid_status !== self::STATUS_PAID;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The delivery status to fall back on when a document stops being complete:
|
||||
* as far along as it had already travelled, and no further.
|
||||
*/
|
||||
public function getPreviousStatus(): string
|
||||
{
|
||||
if ($this->viewed) {
|
||||
return self::STATUS_VIEWED;
|
||||
}
|
||||
|
||||
if ($this->sent) {
|
||||
return self::STATUS_SENT;
|
||||
}
|
||||
|
||||
return self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
/**
|
||||
* The note field with its placeholders resolved and its markup sanitised.
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function getFormattedNotesAttribute($value)
|
||||
{
|
||||
return $this->getNotes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation timestamp in the company's configured date format.
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function getFormattedCreatedAtAttribute($value)
|
||||
{
|
||||
return Carbon::parse($this->created_at)->format($this->documentDateFormat());
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment deadline in the company's configured date format, written in the
|
||||
* language the application is running in.
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function getFormattedDueDateAttribute($value)
|
||||
{
|
||||
return Carbon::parse($this->due_date)->translatedFormat($this->documentDateFormat());
|
||||
}
|
||||
|
||||
/**
|
||||
* Outstanding balance rendered for print, in the document's currency, or
|
||||
* in the company's for a document that never got one.
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function getFormattedDueAmountAttribute($value)
|
||||
{
|
||||
$currency = $this->currency ?: Currency::findOrFail(
|
||||
CompanySetting::getSetting('currency', $this->company_id)
|
||||
);
|
||||
|
||||
return format_money_pdf($this->due_amount, $currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue date in the company's configured date format, written in the
|
||||
* language the application is running in and carrying the time of day when
|
||||
* the company asked for invoices to be timestamped.
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function getFormattedInvoiceDateAttribute($value)
|
||||
{
|
||||
$format = $this->documentDateFormat();
|
||||
|
||||
if (CompanySetting::getSetting('invoice_use_time', $this->company_id) === 'YES') {
|
||||
$format .= ' '.CompanySetting::getSetting('carbon_time_format', $this->company_id);
|
||||
}
|
||||
|
||||
return Carbon::parse($this->invoice_date)->translatedFormat($format);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Query scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Narrow to one delivery status.
|
||||
*/
|
||||
public function scopeWhereStatus($query, $status)
|
||||
{
|
||||
return $query->where($this->qualifyColumn('status'), $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to one collection status.
|
||||
*/
|
||||
public function scopeWherePaidStatus($query, $status)
|
||||
{
|
||||
return $query->where($this->qualifyColumn('paid_status'), $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to documents with money still outstanding.
|
||||
*
|
||||
* The status argument is accepted for call-site symmetry with the other
|
||||
* status scopes and is deliberately unused: "due" is a fixed pair of
|
||||
* collection statuses, not a value to match.
|
||||
*/
|
||||
public function scopeWhereDueStatus($query, $status)
|
||||
{
|
||||
return $query->whereIn($this->qualifyColumn('paid_status'), [
|
||||
self::STATUS_UNPAID,
|
||||
self::STATUS_PARTIALLY_PAID,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial match on the document number.
|
||||
*/
|
||||
public function scopeWhereInvoiceNumber($query, $invoiceNumber)
|
||||
{
|
||||
return $query->where($this->qualifyColumn('invoice_number'), 'LIKE', '%'.$invoiceNumber.'%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict to documents issued inside the inclusive range.
|
||||
*/
|
||||
public function scopeInvoicesBetween($query, $start, $end)
|
||||
{
|
||||
return $query->whereBetween($this->qualifyColumn('invoice_date'), [
|
||||
$start->format('Y-m-d'),
|
||||
$end->format('Y-m-d'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only documents whose contact matches every whitespace-separated
|
||||
* term, a term counting as matched when it turns up in the display name,
|
||||
* the contact person or the company name.
|
||||
*/
|
||||
public function scopeWhereSearch($query, $search)
|
||||
{
|
||||
$terms = explode(' ', $search);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$query->whereHas('customer', function ($contact) use ($term) {
|
||||
$needle = '%'.$term.'%';
|
||||
|
||||
$contact->where('name', 'LIKE', $needle)
|
||||
->orWhere('contact_name', 'LIKE', $needle)
|
||||
->orWhere('company_name', 'LIKE', $needle);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by a caller-supplied column, sanitised before it reaches SQL.
|
||||
*/
|
||||
public function scopeWhereOrder($query, $orderByField, $orderBy)
|
||||
{
|
||||
SafeOrderBy::apply($query, $orderByField, $orderBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every listed filter that carries a value.
|
||||
*
|
||||
* Falsy entries are dropped up front, so a filter sent as an empty string,
|
||||
* a zero or a null is the same as one that was never sent at all. Order is
|
||||
* load-bearing: the clauses land in the query in the order written here,
|
||||
* and the document-id filter contributes an OR, which makes everything
|
||||
* queued before it part of that alternative.
|
||||
*/
|
||||
public function scopeApplyFilters($query, array $filters)
|
||||
{
|
||||
$filters = array_filter($filters);
|
||||
|
||||
$clauses = [
|
||||
'search' => fn ($value) => $query->whereSearch($value),
|
||||
'status' => fn ($value) => match ($value) {
|
||||
self::STATUS_UNPAID, self::STATUS_PARTIALLY_PAID, self::STATUS_PAID => $query->wherePaidStatus($value),
|
||||
'DUE' => $query->whereDueStatus($value),
|
||||
default => $query->whereStatus($value),
|
||||
},
|
||||
'paid_status' => fn ($value) => $query->wherePaidStatus($value),
|
||||
'invoice_id' => fn ($value) => $query->whereInvoice($value),
|
||||
'invoice_number' => fn ($value) => $query->whereInvoiceNumber($value),
|
||||
];
|
||||
|
||||
foreach ($clauses as $filter => $clause) {
|
||||
$value = $filters[$filter] ?? null;
|
||||
|
||||
if ($value) {
|
||||
$clause($value);
|
||||
}
|
||||
}
|
||||
|
||||
$from = $filters['from_date'] ?? null;
|
||||
$to = $filters['to_date'] ?? null;
|
||||
|
||||
if ($from && $to) {
|
||||
$query->invoicesBetween(Carbon::parse($from), Carbon::parse($to));
|
||||
}
|
||||
|
||||
$contact = $filters['customer_id'] ?? null;
|
||||
|
||||
if ($contact) {
|
||||
$query->where('customer_id', $contact);
|
||||
}
|
||||
|
||||
$sortField = $filters['orderByField'] ?? null;
|
||||
|
||||
if (! $sortField) {
|
||||
return $query->orderBy('sequence_number', 'desc');
|
||||
}
|
||||
|
||||
return SafeOrderBy::apply($query, $sortField, $filters['orderBy'] ?? 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Widen a listing to also take in one specific document.
|
||||
*/
|
||||
public function scopeWhereInvoice($query, $invoice_id)
|
||||
{
|
||||
$query->orWhere('id', $invoice_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to the company the current request is acting on.
|
||||
*/
|
||||
public function scopeWhereCompany($query)
|
||||
{
|
||||
$query->where($this->qualifyColumn('company_id'), request()->header('company'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to one company.
|
||||
*/
|
||||
public function scopeWhereCompanyId($query, $company)
|
||||
{
|
||||
$query->where($this->qualifyColumn('company_id'), $company);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to one contact.
|
||||
*/
|
||||
public function scopeWhereCustomer($query, $customer_id)
|
||||
{
|
||||
$query->where($this->qualifyColumn('customer_id'), $customer_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the whole result set for the sentinel limit "all", otherwise a
|
||||
* page of the requested size.
|
||||
*/
|
||||
public function scopePaginateData($query, $limit)
|
||||
{
|
||||
if ($limit == 'all') {
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
return $query->paginate($limit);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rendering and correspondence
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* The estimate template matching this document's invoice template, falling
|
||||
* back to the first estimate template when there is no counterpart.
|
||||
*/
|
||||
public function getEstimateTemplateName(): string
|
||||
{
|
||||
$counterpart = Str::replace('invoice', 'estimate', $this->template_name);
|
||||
|
||||
// The blank image format is what keeps this cheap: asked for the
|
||||
// default one, the lister renders a base64 thumbnail of every single
|
||||
// template just to hand back a list of names.
|
||||
$available = array_column(PdfTemplateUtils::getFormattedTemplates('estimate', ''), 'name');
|
||||
|
||||
return in_array($counterpart, $available) ? $counterpart : 'estimate1';
|
||||
}
|
||||
|
||||
/**
|
||||
* View data for the PDF renderer.
|
||||
*/
|
||||
public function getPDFData(): mixed
|
||||
{
|
||||
return app(InvoicePdfDataProvider::class)->getPdfData($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether outgoing mail should carry the PDF. Anything other than an
|
||||
* explicit refusal counts as consent.
|
||||
*/
|
||||
public function getEmailAttachmentSetting(): bool
|
||||
{
|
||||
return CompanySetting::getSetting('invoice_email_attachment', $this->company_id) != 'NO';
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's address block for print, or false when the company has no
|
||||
* address on file.
|
||||
*/
|
||||
public function getCompanyAddress(): string|false
|
||||
{
|
||||
return $this->addressBlock(
|
||||
$this->company && (! $this->company->address()->exists()),
|
||||
'invoice_company_address_format'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The contact's delivery address block for print, or false when the
|
||||
* contact has no shipping address on file.
|
||||
*/
|
||||
public function getCustomerShippingAddress(): string|false
|
||||
{
|
||||
return $this->addressBlock(
|
||||
$this->customer && (! $this->customer->shippingAddress()->exists()),
|
||||
'invoice_shipping_address_format'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The contact's billing address block for print, or false when the contact
|
||||
* has no billing address on file.
|
||||
*/
|
||||
public function getCustomerBillingAddress(): string|false
|
||||
{
|
||||
return $this->addressBlock(
|
||||
$this->customer && (! $this->customer->billingAddress()->exists()),
|
||||
'invoice_billing_address_format'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The note field with its placeholders resolved and its markup sanitised.
|
||||
*/
|
||||
public function getNotes(): string
|
||||
{
|
||||
return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the placeholders in a mail body, dropping any that named
|
||||
* something this document cannot supply.
|
||||
*/
|
||||
public function getEmailString(string $body): string
|
||||
{
|
||||
$placeholders = array_merge($this->getFieldsArray(), $this->getExtraFields());
|
||||
|
||||
return preg_replace('/{(.*?)}/', '', strtr($body, $placeholders));
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholders this document contributes on top of the shared contact
|
||||
* and company set.
|
||||
*/
|
||||
public function getExtraFields(): array
|
||||
{
|
||||
return [
|
||||
'{INVOICE_DATE}' => $this->formattedInvoiceDate,
|
||||
'{INVOICE_DUE_DATE}' => $this->formattedDueDate,
|
||||
'{INVOICE_NUMBER}' => $this->invoice_number,
|
||||
'{INVOICE_REF_NUMBER}' => $this->reference_number,
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Balance and status
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Grow the outstanding balance, restate it in the company's currency and
|
||||
* re-derive both statuses from where it lands.
|
||||
*
|
||||
* Growing the balance is what unwinding a collection looks like from the
|
||||
* document's side, which is why the amount is added rather than taken off.
|
||||
*/
|
||||
public function addInvoicePayment(int $amount): void
|
||||
{
|
||||
$this->restateBalance($this->due_amount + $amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrink the outstanding balance by a collected amount, restating it and
|
||||
* re-deriving both statuses the same way.
|
||||
*/
|
||||
public function subtractInvoicePayment(int $amount): void
|
||||
{
|
||||
$this->restateBalance($this->due_amount - $amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out the pair of statuses that describes a given outstanding balance.
|
||||
*
|
||||
* Nothing outstanding closes the document and clears the overdue flag; a
|
||||
* balance still standing at the full document total means not a penny has
|
||||
* arrived; anything in between is a part payment. A negative balance is
|
||||
* refused outright, and the empty array says so.
|
||||
*/
|
||||
public function getInvoiceStatusByAmount(int $amount): array
|
||||
{
|
||||
if ($amount < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($amount == 0) {
|
||||
return [
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'paid_status' => self::STATUS_PAID,
|
||||
'overdue' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $this->getPreviousStatus(),
|
||||
'paid_status' => $amount == $this->total
|
||||
? self::STATUS_UNPAID
|
||||
: self::STATUS_PARTIALLY_PAID,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the statuses a given outstanding balance implies and write the row
|
||||
* back straight away. A balance the derivation refuses leaves the document
|
||||
* untouched.
|
||||
*/
|
||||
public function changeInvoiceStatus(int $amount): void
|
||||
{
|
||||
$changes = $this->getInvoiceStatusByAmount($amount);
|
||||
|
||||
if (empty($changes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($changes as $attribute => $value) {
|
||||
$this->setAttribute($attribute, $value);
|
||||
}
|
||||
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Internals
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether any credit note reverses this invoice, answered from the loaded
|
||||
* relation when there is one so that an eager-loaded listing does not fire
|
||||
* a query per row.
|
||||
*/
|
||||
private function hasCreditNotes(): bool
|
||||
{
|
||||
if ($this->relationLoaded('creditNotes')) {
|
||||
return $this->creditNotes->isNotEmpty();
|
||||
}
|
||||
|
||||
return $this->creditNotes()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one of the company's stored address formats, or hand back false
|
||||
* when the party it describes is present but has no address on file.
|
||||
*/
|
||||
private function addressBlock(bool $missing, string $setting): string|false
|
||||
{
|
||||
if ($missing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getFormattedString(CompanySetting::getSetting($setting, $this->company_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the outstanding balance to a new figure, carry the company-currency
|
||||
* copy along with it, and let the statuses follow.
|
||||
*/
|
||||
private function restateBalance(int|float $outstanding): void
|
||||
{
|
||||
$this->due_amount = $outstanding;
|
||||
$this->base_due_amount = $outstanding * $this->exchange_rate;
|
||||
|
||||
$this->changeInvoiceStatus($outstanding);
|
||||
}
|
||||
|
||||
/**
|
||||
* The date format configured by the company that owns this document.
|
||||
*/
|
||||
private function documentDateFormat(): mixed
|
||||
{
|
||||
return CompanySetting::getSetting('carbon_date_format', $this->company_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\Item;
|
||||
use App\Domains\Metadata\Concerns\HasCustomFields;
|
||||
use App\Domains\Taxation\Models\Tax;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* One priced line of an invoice.
|
||||
*
|
||||
* The row is a snapshot rather than a reference: the name, description and
|
||||
* price are copied off the catalog entry when the line is written, so later
|
||||
* edits to the catalog never rewrite history on a document already issued. The
|
||||
* link back to the catalog entry survives for reporting only.
|
||||
*
|
||||
* Recurring invoice templates keep their lines in this same table, which is why
|
||||
* a line can belong to a schedule instead of to a document.
|
||||
*/
|
||||
class InvoiceItem extends Model
|
||||
{
|
||||
use HasCustomFields;
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'invoice_items';
|
||||
|
||||
/**
|
||||
* Everything but the primary key may be mass assigned.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $guarded = [
|
||||
'id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attribute casts.
|
||||
*
|
||||
* Money is whole minor units. Quantity is fractional so that partial units
|
||||
* (hours, kilos, a half day) can be billed, and the percentage discount is
|
||||
* fractional for the same reason.
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'integer',
|
||||
'total' => 'integer',
|
||||
'discount' => 'float',
|
||||
'quantity' => 'float',
|
||||
'discount_val' => 'integer',
|
||||
'tax' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Document this line was billed on.
|
||||
*/
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Invoice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog entry the line was copied from, kept for reporting.
|
||||
*/
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Taxes applied to this line, in per-item tax mode.
|
||||
*/
|
||||
public function taxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tax::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule this line belongs to, when it is part of a recurring template
|
||||
* rather than of an issued document.
|
||||
*/
|
||||
public function recurringInvoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RecurringInvoice::class);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Query scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Narrow to one company.
|
||||
*/
|
||||
public function scopeWhereCompany(Builder $query, int $company_id): void
|
||||
{
|
||||
$query->where('company_id', $company_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict to lines billed on a document issued inside the inclusive range.
|
||||
*/
|
||||
public function scopeInvoicesBetween(Builder $query, Carbon $start, Carbon $end): void
|
||||
{
|
||||
$range = [$start->format('Y-m-d'), $end->format('Y-m-d')];
|
||||
|
||||
$query->whereHas('invoice', function ($invoice) use ($range) {
|
||||
$invoice->whereBetween('invoice_date', $range);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the date range, which counts only when the caller supplied both
|
||||
* ends of it.
|
||||
*/
|
||||
public function scopeApplyInvoiceFilters(Builder $query, array $filters): void
|
||||
{
|
||||
$from = $filters['from_date'] ?? null;
|
||||
$to = $filters['to_date'] ?? null;
|
||||
|
||||
if ($from && $to) {
|
||||
$query->invoicesBetween(
|
||||
Carbon::createFromFormat('Y-m-d', $from),
|
||||
Carbon::createFromFormat('Y-m-d', $to)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll the lines up by product name, totalling quantity sold and the
|
||||
* revenue it brought in, in the company's own currency.
|
||||
*/
|
||||
public function scopeItemAttributes(Builder $query): void
|
||||
{
|
||||
$columns = [
|
||||
'sum(quantity) as total_quantity',
|
||||
'sum(base_total) as total_amount',
|
||||
'invoice_items.name',
|
||||
];
|
||||
|
||||
$query->select(DB::raw(implode(', ', $columns)))
|
||||
->groupBy('invoice_items.name');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Models;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Metadata\Concerns\HasCustomFields;
|
||||
use App\Domains\Money\Models\Currency;
|
||||
use App\Domains\Taxation\Models\Tax;
|
||||
use App\Support\SafeOrderBy;
|
||||
use Carbon\Carbon;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* A standing order that mints invoices on a timetable.
|
||||
*
|
||||
* The row carries a whole invoice in template form — amounts, items and taxes,
|
||||
* kept in the same shape a real document uses — together with the cron
|
||||
* expression that decides when the next copy falls due and the limit, if any,
|
||||
* that eventually retires the schedule.
|
||||
*/
|
||||
class RecurringInvoice extends Model
|
||||
{
|
||||
use HasCustomFields;
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Limit mode: keep issuing invoices indefinitely.
|
||||
*/
|
||||
public const NONE = 'NONE';
|
||||
|
||||
/**
|
||||
* Limit mode: stop after a set number of invoices has been issued.
|
||||
*/
|
||||
public const COUNT = 'COUNT';
|
||||
|
||||
/**
|
||||
* Limit mode: stop once the schedule passes its end date.
|
||||
*/
|
||||
public const DATE = 'DATE';
|
||||
|
||||
/**
|
||||
* The schedule has run its course and issues nothing further.
|
||||
*/
|
||||
public const COMPLETED = 'COMPLETED';
|
||||
|
||||
/**
|
||||
* The schedule is paused by hand.
|
||||
*/
|
||||
public const ON_HOLD = 'ON_HOLD';
|
||||
|
||||
/**
|
||||
* The schedule is live and due to fire on its cron expression.
|
||||
*/
|
||||
public const ACTIVE = 'ACTIVE';
|
||||
|
||||
protected $table = 'recurring_invoices';
|
||||
|
||||
protected $guarded = [
|
||||
'id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Kept from an older Eloquent generation, which read this list to cast the
|
||||
* named columns to dates. Current Eloquent ignores the property, so
|
||||
* starts_at is handed around as the plain string the driver returns.
|
||||
*/
|
||||
protected $dates = [
|
||||
'starts_at',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'formattedCreatedAt',
|
||||
'formattedStartsAt',
|
||||
'formattedNextInvoiceAt',
|
||||
'formattedLimitDate',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attribute casts.
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'exchange_rate' => 'float',
|
||||
'send_automatically' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoices this schedule has produced so far.
|
||||
*/
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class, 'recurring_invoice_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Taxes carried by the template, at document level.
|
||||
*/
|
||||
public function taxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tax::class, 'recurring_invoice_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Line items the generated invoices are built from.
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvoiceItem::class, 'recurring_invoice_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact every generated invoice is billed to.
|
||||
*/
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Company the schedule was set up under.
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'company_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Author of the schedule, linked through the creator_id column.
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency the template amounts are stated in.
|
||||
*/
|
||||
public function currency(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Currency::class, 'currency_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start of the schedule, written in the company's date format and in the
|
||||
* language the application is running in.
|
||||
*/
|
||||
public function getFormattedStartsAtAttribute()
|
||||
{
|
||||
return Carbon::parse($this->starts_at)->translatedFormat($this->companyDateFormat());
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment the next invoice is due, written in the company's date format
|
||||
* and in the language the application is running in.
|
||||
*/
|
||||
public function getFormattedNextInvoiceAtAttribute()
|
||||
{
|
||||
return Carbon::parse($this->next_invoice_at)->translatedFormat($this->companyDateFormat());
|
||||
}
|
||||
|
||||
/**
|
||||
* End date of a date-limited schedule, written in the company's date
|
||||
* format. Unlike the two above it is not translated.
|
||||
*/
|
||||
public function getFormattedLimitDateAttribute()
|
||||
{
|
||||
return Carbon::parse($this->limit_date)->format($this->companyDateFormat());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation date, written in the company's date format and untranslated.
|
||||
*/
|
||||
public function getFormattedCreatedAtAttribute()
|
||||
{
|
||||
return Carbon::parse($this->created_at)->format($this->companyDateFormat());
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow to the company the current request is acting on.
|
||||
*/
|
||||
public function scopeWhereCompany($query)
|
||||
{
|
||||
$company = request()->header('company');
|
||||
|
||||
return $query->where($this->qualifyColumn('company_id'), $company);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the whole result set for the sentinel limit "all", otherwise a
|
||||
* page of the requested size.
|
||||
*/
|
||||
public function scopePaginateData($query, $limit)
|
||||
{
|
||||
return $limit == 'all' ? $query->get() : $query->paginate($limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by a caller-supplied column, sanitised before it reaches SQL.
|
||||
*/
|
||||
public function scopeWhereOrder($query, $orderByField, $orderBy)
|
||||
{
|
||||
return SafeOrderBy::apply($query, $orderByField, $orderBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only schedules sitting in one lifecycle state.
|
||||
*/
|
||||
public function scopeWhereStatus($query, $status)
|
||||
{
|
||||
return $query->where($this->qualifyColumn('status'), $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the schedules billed to one contact.
|
||||
*/
|
||||
public function scopeWhereCustomer($query, $customer_id)
|
||||
{
|
||||
return $query->where('customer_id', $customer_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only schedules whose start date falls inside the inclusive range.
|
||||
*/
|
||||
public function scopeRecurringInvoicesStartBetween($query, $start, $end)
|
||||
{
|
||||
return $query->whereBetween('starts_at', [
|
||||
$start->format('Y-m-d'),
|
||||
$end->format('Y-m-d'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only schedules whose contact matches every whitespace-separated
|
||||
* term, a term counting as matched when it appears in the contact's name,
|
||||
* the contact person or the company name.
|
||||
*/
|
||||
public function scopeWhereSearch($query, $search)
|
||||
{
|
||||
$terms = explode(' ', $search);
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$query->whereHas('customer', function ($customer) use ($term) {
|
||||
$needle = '%'.$term.'%';
|
||||
|
||||
$customer->where('name', 'LIKE', $needle)
|
||||
->orWhere('contact_name', 'LIKE', $needle)
|
||||
->orWhere('company_name', 'LIKE', $needle);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every listed filter that carries a value.
|
||||
*/
|
||||
public function scopeApplyFilters($query, array $filters)
|
||||
{
|
||||
$status = $filters['status'] ?? null;
|
||||
$search = $filters['search'] ?? null;
|
||||
$from = $filters['from_date'] ?? null;
|
||||
$to = $filters['to_date'] ?? null;
|
||||
$customer = $filters['customer_id'] ?? null;
|
||||
|
||||
if ($status && $status !== 'ALL') {
|
||||
$query->whereStatus($status);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->whereSearch($search);
|
||||
}
|
||||
|
||||
if ($from && $to) {
|
||||
$query->recurringInvoicesStartBetween(
|
||||
Carbon::createFromFormat('Y-m-d', $from),
|
||||
Carbon::createFromFormat('Y-m-d', $to)
|
||||
);
|
||||
}
|
||||
|
||||
if ($customer) {
|
||||
$query->whereCustomer($customer);
|
||||
}
|
||||
|
||||
$sortField = $filters['orderByField'] ?? null;
|
||||
$sortDirection = $filters['orderBy'] ?? null;
|
||||
|
||||
if ($sortField || $sortDirection) {
|
||||
$query->whereOrder($sortField ?: 'created_at', $sortDirection ?: 'asc');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire the schedule, so that no further invoice is generated from it.
|
||||
*/
|
||||
public function markStatusAsCompleted(): void
|
||||
{
|
||||
$this->status = static::COMPLETED;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a cron expression next fires, counted from the given start
|
||||
* date rather than from now, in the application's own time zone.
|
||||
*/
|
||||
public static function getNextInvoiceDate(string $frequency, string $starts_at): string
|
||||
{
|
||||
$schedule = new CronExpression($frequency);
|
||||
$zone = config('app.timezone', 'UTC');
|
||||
|
||||
return $schedule->getNextRunDate($starts_at, 0, false, $zone)->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute and store the date the next invoice falls due.
|
||||
*/
|
||||
public function updateNextInvoiceDate(): void
|
||||
{
|
||||
$this->next_invoice_at = self::getNextInvoiceDate($this->frequency, $this->starts_at);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* The date format the owning company writes dates in.
|
||||
*/
|
||||
private function companyDateFormat()
|
||||
{
|
||||
return CompanySetting::getSetting('carbon_date_format', $this->company_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Domains\Sales\Models\Estimate;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
/**
|
||||
* Who may work with estimates.
|
||||
*
|
||||
* Every decision has two halves: the Bouncer ability, and — for anything
|
||||
* aimed at an existing offer — membership of the company that offer belongs
|
||||
* to, so an ability held in one company never reaches another company's data.
|
||||
*
|
||||
* Bouncer answers for the user it currently has scoped, not for the $user
|
||||
* handed in; that argument only feeds the membership half.
|
||||
*
|
||||
* Unlike an invoice, an estimate carries no editing window: nothing is
|
||||
* allocated against it, so the ability and the membership are the whole test.
|
||||
*/
|
||||
class EstimatePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return BouncerFacade::can('view-estimate', Estimate::class);
|
||||
}
|
||||
|
||||
public function view(User $user, Estimate $estimate): bool
|
||||
{
|
||||
return BouncerFacade::can('view-estimate', $estimate) && $this->sameCompany($user, $estimate);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return BouncerFacade::can('create-estimate', Estimate::class);
|
||||
}
|
||||
|
||||
public function update(User $user, Estimate $estimate): bool
|
||||
{
|
||||
return BouncerFacade::can('edit-estimate', $estimate) && $this->sameCompany($user, $estimate);
|
||||
}
|
||||
|
||||
public function delete(User $user, Estimate $estimate): bool
|
||||
{
|
||||
return $this->mayRemove($user, $estimate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restoring and erasing answer to the delete ability as well; estimates
|
||||
* are not soft-deleted, so neither is reachable in practice.
|
||||
*/
|
||||
public function restore(User $user, Estimate $estimate): bool
|
||||
{
|
||||
return $this->mayRemove($user, $estimate);
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Estimate $estimate): bool
|
||||
{
|
||||
return $this->mayRemove($user, $estimate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailing the offer to its customer. Left without a return type, as it has
|
||||
* always been.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function send(User $user, Estimate $estimate)
|
||||
{
|
||||
return BouncerFacade::can('send-estimate', $estimate) && $this->sameCompany($user, $estimate);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bulk-delete gate. It is handed no offer, so only the ability half
|
||||
* applies and nothing here confines it to one company — the endpoint does
|
||||
* that itself when it resolves the ids.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function deleteMultiple(User $user)
|
||||
{
|
||||
return BouncerFacade::can('delete-estimate', Estimate::class);
|
||||
}
|
||||
|
||||
private function mayRemove(User $user, Estimate $estimate): bool
|
||||
{
|
||||
return BouncerFacade::can('delete-estimate', $estimate) && $this->sameCompany($user, $estimate);
|
||||
}
|
||||
|
||||
private function sameCompany(User $user, Estimate $estimate): bool
|
||||
{
|
||||
return $user->hasCompany($estimate->company_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
/**
|
||||
* Who may work with invoices — credit notes included, since those are invoice
|
||||
* rows and ride on the same abilities.
|
||||
*
|
||||
* Every decision has two halves: the Bouncer ability, and — for anything
|
||||
* aimed at an existing document — membership of the company that document
|
||||
* belongs to, so an ability held in one company never reaches another
|
||||
* company's data.
|
||||
*
|
||||
* Bouncer answers for the user it currently has scoped, not for the $user
|
||||
* handed in; that argument only feeds the membership half.
|
||||
*/
|
||||
class InvoicePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return BouncerFacade::can('view-invoice', Invoice::class);
|
||||
}
|
||||
|
||||
public function view(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return BouncerFacade::can('view-invoice', $invoice) && $this->sameCompany($user, $invoice);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return BouncerFacade::can('create-invoice', Invoice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing answers to a third half on top of the usual two: the document
|
||||
* has to still be open to it.
|
||||
*
|
||||
* A credit note never is. It is a reversal, immutable once minted, because
|
||||
* saving it back through the invoice form would recompute its totals
|
||||
* positive. For everything else the model's own accessor decides, which is
|
||||
* where the company's retrospective-edits setting is read.
|
||||
*/
|
||||
public function update(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return ! $invoice->isCreditNote()
|
||||
&& BouncerFacade::can('edit-invoice', $invoice)
|
||||
&& $this->sameCompany($user, $invoice)
|
||||
&& $invoice->allow_edit;
|
||||
}
|
||||
|
||||
public function delete(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $this->mayRemove($user, $invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restoring and erasing answer to the delete ability as well; invoices are
|
||||
* not soft-deleted, so neither is reachable in practice.
|
||||
*/
|
||||
public function restore(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $this->mayRemove($user, $invoice);
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $this->mayRemove($user, $invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailing the document to its customer. Left without a return type, as it
|
||||
* has always been.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function send(User $user, Invoice $invoice)
|
||||
{
|
||||
return BouncerFacade::can('send-invoice', $invoice) && $this->sameCompany($user, $invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bulk-delete gate. It is handed no document, so only the ability half
|
||||
* applies and nothing here confines it to one company — the endpoint does
|
||||
* that itself when it resolves the ids.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function deleteMultiple(User $user)
|
||||
{
|
||||
return BouncerFacade::can('delete-invoice', Invoice::class);
|
||||
}
|
||||
|
||||
private function mayRemove(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return BouncerFacade::can('delete-invoice', $invoice) && $this->sameCompany($user, $invoice);
|
||||
}
|
||||
|
||||
private function sameCompany(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $user->hasCompany($invoice->company_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sales\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Domains\Sales\Models\RecurringInvoice;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
/**
|
||||
* Who may work with recurring-invoice templates.
|
||||
*
|
||||
* Every decision has two halves: the Bouncer ability, and — for anything
|
||||
* aimed at an existing template — membership of the company that template
|
||||
* belongs to, so an ability held in one company never reaches another
|
||||
* company's data.
|
||||
*
|
||||
* Bouncer answers for the user it currently has scoped, not for the $user
|
||||
* handed in; that argument only feeds the membership half.
|
||||
*
|
||||
* There is no sending here: a template is never mailed, only the invoices it
|
||||
* generates are.
|
||||
*/
|
||||
class RecurringInvoicePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return BouncerFacade::can('view-recurring-invoice', RecurringInvoice::class);
|
||||
}
|
||||
|
||||
public function view(User $user, RecurringInvoice $recurringInvoice): bool
|
||||
{
|
||||
return BouncerFacade::can('view-recurring-invoice', $recurringInvoice)
|
||||
&& $this->sameCompany($user, $recurringInvoice);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return BouncerFacade::can('create-recurring-invoice', RecurringInvoice::class);
|
||||
}
|
||||
|
||||
public function update(User $user, RecurringInvoice $recurringInvoice): bool
|
||||
{
|
||||
return BouncerFacade::can('edit-recurring-invoice', $recurringInvoice)
|
||||
&& $this->sameCompany($user, $recurringInvoice);
|
||||
}
|
||||
|
||||
public function delete(User $user, RecurringInvoice $recurringInvoice): bool
|
||||
{
|
||||
return $this->mayRemove($user, $recurringInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restoring and erasing answer to the delete ability as well; templates
|
||||
* are not soft-deleted, so neither is reachable in practice.
|
||||
*/
|
||||
public function restore(User $user, RecurringInvoice $recurringInvoice): bool
|
||||
{
|
||||
return $this->mayRemove($user, $recurringInvoice);
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, RecurringInvoice $recurringInvoice): bool
|
||||
{
|
||||
return $this->mayRemove($user, $recurringInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bulk-delete gate. It is handed no template, so only the ability half
|
||||
* applies and nothing here confines it to one company — the endpoint does
|
||||
* that itself when it resolves the ids.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function deleteMultiple(User $user)
|
||||
{
|
||||
return BouncerFacade::can('delete-recurring-invoice', RecurringInvoice::class);
|
||||
}
|
||||
|
||||
private function mayRemove(User $user, RecurringInvoice $recurringInvoice): bool
|
||||
{
|
||||
return BouncerFacade::can('delete-recurring-invoice', $recurringInvoice)
|
||||
&& $this->sameCompany($user, $recurringInvoice);
|
||||
}
|
||||
|
||||
private function sameCompany(User $user, RecurringInvoice $recurringInvoice): bool
|
||||
{
|
||||
return $user->hasCompany($recurringInvoice->company_id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user