Merge pull request #11468 from turbo124/v5-develop

v5.12.38
This commit is contained in:
David Bomba
2025-12-04 14:48:58 +11:00
committed by GitHub
44 changed files with 740 additions and 223 deletions

View File

@@ -1 +1 @@
5.12.37
5.12.38

View File

@@ -184,7 +184,7 @@ class CheckData extends Command
private function checkTaskTimeLogs()
{
\App\Models\Task::query()->cursor()->each(function ($task) {
$time_log = json_decode($task->time_log, true) ?? [];
$time_log = json_decode(($task->time_log ?? ''), true) ?? [];
foreach($time_log as &$log){
if(count($log) > 4){
@@ -521,7 +521,8 @@ class CheckData extends Command
$this->logMessage("Add invitation for {$entity_key} - {$entity->id}");
}
} else {
$this->logMessage("No contact present, so cannot add invitation for {$entity_key} - {$entity->id}");
$this->logMessage("No contact present ( ? vendor not present ?? ), so cannot add invitation for {$entity_key} - {$entity->id}");
try {
$entity->service()->createInvitations()->save();

View File

@@ -58,7 +58,7 @@ class ChartController extends BaseController
$user = auth()->user();
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions, $request->input('include_drafts', false));
return response()->json($cs->totals($request->input('start_date'), $request->input('end_date')), 200);
}

View File

@@ -26,7 +26,7 @@ class ShowChartRequest extends Request
*/
public function authorize(): bool
{
/**@var \App\Models\User auth()->user */
/** @var \App\Models\User auth()->user */
$user = auth()->user();
return $user->isAdmin() || $user->hasPermission('view_dashboard');
@@ -38,6 +38,7 @@ class ShowChartRequest extends Request
'date_range' => 'bail|sometimes|string|in:last7_days,last30_days,last365_days,this_month,last_month,this_quarter,last_quarter,this_year,last_year,all_time,custom',
'start_date' => 'bail|sometimes|date',
'end_date' => 'bail|sometimes|date',
'include_drafts' => 'bail|sometimes|boolean',
];
}

View File

@@ -157,7 +157,7 @@ class UpdateClientRequest extends Request
}
if (array_key_exists('name', $input)) {
$input['name'] = strip_tags($input['name']);
$input['name'] = strip_tags($input['name'] ?? '');
}
// allow setting country_id by iso code
@@ -225,6 +225,9 @@ class UpdateClientRequest extends Request
{
$account = $this->client->company->account;
// Do not allow a user to force pdf variables on the client settings.
unset($settings['pdf_variables']);
if (! $account->isFreeHostedClient()) {
return $settings;
}

View File

@@ -96,6 +96,8 @@ class StoreGroupSettingRequest extends Request
/** @var \App\Models\User $user */
$user = auth()->user();
unset($settings->pdf_variables);
$settings_data = new SettingsData();
$settings = $settings_data->cast($settings)->toObject();

View File

@@ -86,6 +86,10 @@ class UpdateGroupSettingRequest extends Request
$settings_data = new SettingsData();
$settings = $settings_data->cast($settings)->toObject();
// Do not allow a user to force pdf variables on the client settings.
unset($settings->pdf_variables);
if (! $user->account->isFreeHostedClient()) {
return (array)$settings;
}

View File

@@ -71,7 +71,7 @@ class StorePaymentRequest extends Request
public function withValidator($validator)
{
$validator->after(function ($validator) {
$invoices = $this->input('invoices', []);
$invoices = $this->input('invoices') ?? [];
$clientId = $this->input('client_id');
$invCollection = Invoice::withTrashed()
->whereIn('id', array_column($invoices, 'invoice_id'))
@@ -84,6 +84,11 @@ class StorePaymentRequest extends Request
continue;
}
if (!array_key_exists('invoice_id', $invoice)) {
$validator->errors()->add("invoices.{$index}.invoice_id", ctrans('texts.invoice_id') . ' required');
continue;
}
// Find invoice
$inv = $invCollection->firstWhere('id', $invoice['invoice_id']);

View File

@@ -22,6 +22,7 @@ class BlackListRule implements ValidationRule
{
/** Bad domains +/- disposable email domains */
private array $blacklist = [
"edu.pk",
"bablace.com",
"moonfee.com",
"edus2.us",

View File

@@ -81,7 +81,7 @@ class BulkInvoiceJob implements ShouldQueue
$invoice->service()->markSent()->save();
if($invoice->verifactuEnabled() && !$invoice->hasSentAeat()) {
if($invoice->company->verifactuEnabled() && !$invoice->hasSentAeat()) {
$invoice->invitations()->update(['email_error' => 'primed']); // Flag the invitations as primed for AEAT submission
$invoice->service()->sendVerifactu();
return false;

View File

@@ -324,7 +324,7 @@ class NinjaMailerJob implements ShouldQueue
*/
private function entityEmailFailed($message): void
{
$class = get_class($this->nmo->entity);
$class = $this->nmo->entity ? get_class($this->nmo->entity) : false;
switch ($class) {
case Invoice::class:

View File

@@ -179,7 +179,7 @@ class InvoiceTransactionEventEntry
$tax_detail = [
'tax_name' => $tax['name'],
'tax_rate' => $tax['tax_rate'],
'line_total' => $tax['base_amount'],
'line_total' => ($tax['base_amount'] ?? $calc->getNetSubtotal()),
'total_tax' => $tax['total'],
'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) - ($previousLine->line_total ?? 0),
'tax_amount' => $tax['total'] - ($previousLine->total_tax ?? 0),
@@ -234,7 +234,7 @@ class InvoiceTransactionEventEntry
'tax_rate' => $tax['tax_rate'],
'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio * -1,
'tax_amount' => ($tax['total'] * $this->paid_ratio * -1),
'line_total' => $tax['base_amount'] * $this->paid_ratio * -1,
'line_total' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio * -1,
'total_tax' => $tax['total'] * $this->paid_ratio * -1,
'postal_code' => $postal_code,
@@ -280,7 +280,7 @@ class InvoiceTransactionEventEntry
'tax_rate' => $tax['tax_rate'],
'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio,
'tax_amount' => ($tax['total'] * $this->paid_ratio),
'line_total' => $tax['base_amount'] * $this->paid_ratio,
'line_total' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio,
'total_tax' => $tax['total'] * $this->paid_ratio,
'postal_code' => $postal_code,
];
@@ -323,7 +323,7 @@ class InvoiceTransactionEventEntry
'tax_rate' => $tax['tax_rate'],
'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * -1,
'tax_amount' => $tax['total'] * -1,
'line_total' => $tax['base_amount'] * -1,
'line_total' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * -1,
'total_tax' => $tax['total'] * -1,
'postal_code' => $postal_code,
];

View File

@@ -113,8 +113,10 @@ class PaymentNotification implements ShouldQueue
//new check, IF the payment is on a recurring invoice AND the user had notifications disabled for recurring invoices. then we disable the notification for this payment.
$disabled_recurring_invoice_notifications = $this->findUserEntityNotificationType($payment, $company_user,['disable_recurring_payment_notification']);
if ($payment->invoices->first()->recurring_id && (array_search('mail', $disabled_recurring_invoice_notifications) !== false)) {
continue;
$invoice = $payment->invoices->first();
if ($invoice && $invoice->recurring_id && (array_search('mail', $disabled_recurring_invoice_notifications) !== false)) {
continue;
}
$nmo = new NinjaMailerObject();

View File

@@ -269,7 +269,7 @@ class BillingPortalPurchase extends Component
$data = [
'name' => '',
'contacts' => [
['email' => $this->email],
['email' => $this->email, 'is_primary' => true],
],
'client_hash' => Str::random(40),
'settings' => ClientSettings::defaults(),

View File

@@ -76,6 +76,8 @@ class BillingPortalPurchasev2 extends Component
*/
public $data = [];
public $price;
/**
* List of payment methods fetched from client.
*

View File

@@ -95,9 +95,9 @@ class InvoicePay extends Component
public $required_fields = false;
#[On('update.context')]
public function handleContext(string $property, $value): self
public function handleContext(string $key, string $property, $value): self
{
$this->setContext(property: $property, value: $value);
$this->setContext($key, $property, $value);
return $this;
}
@@ -116,7 +116,7 @@ class InvoicePay extends Component
$invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id);
$invite->signature_base64 = $base64;
$invite->signature_date = now()->addSeconds($invite->contact->client->timezone_offset());
$this->setContext('signature', $base64); // $this->context['signature'] = $base64;
$this->setContext($invite->key, 'signature', $base64); // $this->context['signature'] = $base64;
$invite->save();
}
@@ -124,15 +124,17 @@ class InvoicePay extends Component
#[On('payable-amount')]
public function payableAmount($payable_amount)
{
$this->setContext('amount', $payable_amount);
$invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id);
$this->setContext($invite->key, 'amount', $payable_amount);
$this->under_over_payment = false;
}
#[On('payment-method-selected')]
public function paymentMethodSelected($company_gateway_id, $gateway_type_id, $amount)
{
$invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id);
$this->bulkSetContext([
$this->bulkSetContext($invite->key, [
'company_gateway_id' => $company_gateway_id,
'gateway_type_id' => $gateway_type_id,
'amount' => $amount,
@@ -160,14 +162,14 @@ class InvoicePay extends Component
private function checkRequiredFields(CompanyGateway $company_gateway)
{
$invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id);
/** @var \App\Models\ClientContact $contact */
$contact = $this->getContext()['contact'];
$contact = $this->getContext($invite->key)['contact'];
$fields = $company_gateway->driver($contact->client)->getClientRequiredFields();
$this->setContext('fields', $fields); // $this->context['fields'] = $fields;
$this->setContext($invite->key,'fields', $fields); // $this->context['fields'] = $fields;
foreach ($fields as $index => $field) {
$_field = $this->mappings[$field['name']];
@@ -233,7 +235,7 @@ class InvoicePay extends Component
public function mount()
{
$this->resetContext();
// $this->resetContext();
MultiDB::setDb($this->db);
@@ -243,7 +245,7 @@ class InvoicePay extends Component
$client = $invite->contact->client;
$settings = $client->getMergedSettings();
$this->bulkSetContext([
$this->bulkSetContext($invite->key, [
'contact' => $invite->contact,
'settings' => $settings,
'db' => $this->db,
@@ -286,7 +288,7 @@ class InvoicePay extends Component
];
})->toArray();
$this->bulkSetContext([
$this->bulkSetContext($invite->key, [
'variables' => $this->variables,
'invoices' => $invoices,
'settings' => $settings,
@@ -300,7 +302,10 @@ class InvoicePay extends Component
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return render('flow2.invoice-pay');
//@phpstan-ignore-next-line
$invite = \App\Models\InvoiceInvitation::with('contact.client', 'company')->withTrashed()->find($this->invitation_id);
return render('flow2.invoice-pay', ['_key' => $invite->key]);
}
public function exception($e, $stopPropagation)

View File

@@ -30,6 +30,8 @@ class InvoiceSummary extends Component
public $isReady = false;
public $_key;
#[On(self::CONTEXT_READY)]
public function onContextReady(): void
{
@@ -39,7 +41,7 @@ class InvoiceSummary extends Component
public function mount()
{
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
if (!empty($_context)) {
$this->isReady = true;
@@ -49,7 +51,7 @@ class InvoiceSummary extends Component
private function loadContextData(): void
{
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
if (empty($_context)) {
return;
@@ -76,7 +78,7 @@ class InvoiceSummary extends Component
public function downloadDocument($invoice_hashed_id)
{
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
$invitation_id = $_context['invitation_id'];
@@ -98,7 +100,7 @@ class InvoiceSummary extends Component
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
$contact = $_context['contact'] ?? auth()->guard('contact')->user();

View File

@@ -30,6 +30,7 @@ class PaymentMethod extends Component
public $amount = 0;
public $_key;
public function placeholder()
{
return <<<'HTML'
@@ -56,7 +57,7 @@ class PaymentMethod extends Component
public function mount()
{
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
$this->variables = $_context['variables'];
$this->amount = array_sum(array_column($_context['payable_invoices'], 'amount'));

View File

@@ -12,12 +12,12 @@
namespace App\Livewire\Flow2;
use App\Exceptions\PaymentFailed;
use App\Utils\Traits\WithSecureContext;
use Livewire\Component;
use App\Libraries\MultiDB;
use App\Models\CompanyGateway;
use App\Exceptions\PaymentFailed;
use App\Models\InvoiceInvitation;
use App\Utils\Traits\WithSecureContext;
use App\Services\ClientPortal\LivewireInstantPayment;
class ProcessPayment extends Component
@@ -29,15 +29,15 @@ class ProcessPayment extends Component
private array $payment_data_payload = [];
public $isLoading = true;
public $_key;
public function mount()
{
MultiDB::setDb($this->getContext()['db']);
MultiDB::setDb($this->getContext($this->_key)['db']);
$invitation = InvoiceInvitation::find($this->getContext()['invitation_id']);
$invitation = InvoiceInvitation::find($this->getContext($this->_key)['invitation_id']);
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
$data = [
'company_gateway_id' => $_context['company_gateway_id'],
@@ -65,7 +65,7 @@ class ProcessPayment extends Component
$gateway_fee = data_get($responder_data, 'payload.total.fee_total', false);
$amount = data_get($responder_data, 'payload.total.amount_with_fee', 0);
$this->bulkSetContext([
$this->bulkSetContext($this->_key, [
'amount' => $amount,
'gateway_fee' => $gateway_fee,
]);
@@ -116,7 +116,7 @@ class ProcessPayment extends Component
$bag->add('gateway_error', $e->getMessage());
session()->put('errors', $errors->put('default', $bag));
$invoice_id = $this->getContext()['payable_invoices'][0]['invoice_id'];
$invoice_id = $this->getContext($this->_key)['payable_invoices'][0]['invoice_id'];
$this->redirectRoute('client.invoice.show', ['invoice' => $invoice_id]);
$stopPropagation();

View File

@@ -53,10 +53,10 @@ class RequiredFields extends Component
public bool $is_loading = true;
public array $errors = [];
public $_key;
public function mount(): void
{
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
MultiDB::setDB(
$_context['db'],
@@ -114,7 +114,7 @@ class RequiredFields extends Component
$rff = new RFFService(
fields: $this->fields,
database: $this->getContext()['db'],
database: $this->getContext($this->_key)['db'],
company_gateway_id: (string)$this->company_gateway->id,
);
@@ -134,7 +134,7 @@ class RequiredFields extends Component
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return render('flow2.required-fields', [
'contact' => $this->getContext()['contact'],
'contact' => $this->getContext($this->_key)['contact'],
]);
}

View File

@@ -21,16 +21,16 @@ class Terms extends Component
use WithSecureContext;
public $variables;
public $_key;
public function mount()
{
$this->variables = $this->getContext()['variables'];
$this->variables = $this->getContext($this->_key)['variables'];
}
#[Computed()]
public function invoice()
{
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
$invitation_id = $_context['invitation_id'];

View File

@@ -30,10 +30,12 @@ class UnderOverPayment extends Component
public $payableInvoices = [];
public $_key;
public function mount()
{
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
$contact = $_context['contact'] ?? auth()->guard('contact')->user();
@@ -45,7 +47,7 @@ class UnderOverPayment extends Component
public function checkValue(array $payableInvoices)
{
$this->errors = '';
$_context = $this->getContext();
$_context = $this->getContext($this->_key);
$settings = $_context['settings'];
$contact = $_context['contact'] ?? auth()->guard('contact')->user();
@@ -71,7 +73,7 @@ class UnderOverPayment extends Component
}
if (!$this->errors) {
$this->setContext('payable_invoices', $payableInvoices);
$this->setContext($this->_key, 'payable_invoices', $payableInvoices);
$this->dispatch('payable-amount', payable_amount: $input_amount);
}
}

View File

@@ -404,7 +404,7 @@ class PaymentEmailEngine extends BaseEmailEngine
$invoice_list = '';
foreach ($this->payment->invoices as $invoice) {
if (strlen($invoice->po_number) > 1) {
if (strlen($invoice->po_number ?? '') > 1) {
$invoice_list .= ctrans('texts.po_number')." {$invoice->po_number} <br>";
}
@@ -433,7 +433,7 @@ class PaymentEmailEngine extends BaseEmailEngine
$invoice_list = '<br><br>';
foreach ($this->payment->invoices as $invoice) {
if (strlen($invoice->po_number) > 1) {
if (strlen($invoice->po_number ?? '') > 1) {
$invoice_list .= ctrans('texts.po_number')." {$invoice->po_number} <br>";
}

View File

@@ -288,14 +288,13 @@ class Activity extends StaticModel
public const PURGE_CLIENT = 153;
public const VERIFACTU_INVOICE_SENT = 154;
public const VERIFACTU_INVOICE_SENT = 150;
public const VERIFACTU_INVOICE_SENT_FAILURE = 155;
public const VERIFACTU_INVOICE_SENT_FAILURE = 151;
public const VERIFACTU_CANCELLATION_SENT = 156;
public const VERIFACTU_CANCELLATION_SENT = 152;
public const VERIFACTU_CANCELLATION_SENT_FAILURE = 153;
public const VERIFACTU_CANCELLATION_SENT_FAILURE = 157;
protected $casts = [
'is_system' => 'boolean',

View File

@@ -322,7 +322,8 @@ trait ChartQueries
{
$user_filter = $this->is_admin ? '' : 'AND clients.user_id = '.$this->user->id;
// AND invoices.balance > 0
$status_filter = $this->include_drafts ? 'AND invoices.status_id IN (1,2,3)' : 'AND invoices.status_id IN (2,3)';
return DB::select("
SELECT
@@ -332,7 +333,7 @@ trait ChartQueries
FROM clients
JOIN invoices
on invoices.client_id = clients.id
WHERE invoices.status_id IN (2,3)
{$status_filter}
AND invoices.company_id = :company_id
AND clients.is_deleted = 0
{$user_filter}
@@ -347,6 +348,7 @@ trait ChartQueries
{
$user_filter = $this->is_admin ? '' : 'AND clients.user_id = '.$this->user->id;
$status_filter = $this->include_drafts ? 'AND invoices.status_id IN (1,2,3)' : 'AND invoices.status_id IN (2,3)';
//AND invoices.balance > 0
return DB::select("
SELECT
@@ -355,7 +357,7 @@ trait ChartQueries
FROM clients
JOIN invoices
on invoices.client_id = clients.id
WHERE invoices.status_id IN (2,3)
{$status_filter}
AND invoices.company_id = :company_id
AND clients.is_deleted = 0
{$user_filter}
@@ -416,13 +418,14 @@ trait ChartQueries
$user_filter = $this->is_admin ? '' : 'AND clients.user_id = '.$this->user->id;
//AND invoices.amount > 0 @2024-12-03 - allow negative invoices to be included
$status_filter = $this->include_drafts ? 'AND invoices.status_id IN (1,2,3,4)' : 'AND invoices.status_id IN (2,3,4)';
return DB::select("
SELECT
SUM(invoices.amount / COALESCE(NULLIF(invoices.exchange_rate, 0), 1)) as invoiced_amount
FROM clients
JOIN invoices ON invoices.client_id = clients.id
WHERE invoices.status_id IN (2,3,4)
{$status_filter}
AND invoices.company_id = :company_id
{$user_filter}
@@ -441,9 +444,8 @@ trait ChartQueries
public function getInvoicesQuery($start_date, $end_date)
{
$user_filter = $this->is_admin ? '' : 'AND clients.user_id = '.$this->user->id;
// $user_filter = $this->is_admin ? '' : 'AND (clients.user_id = '.$this->user->id.' OR clients.assigned_user_id = '.$this->user->id.')';
//AND invoices.amount > 0 @2024-12-03 - allow negative invoices to be included
$status_filter = $this->include_drafts ? 'AND invoices.status_id IN (1,2,3,4)' : 'AND invoices.status_id IN (2,3,4)';
return DB::select("
SELECT
@@ -452,7 +454,7 @@ trait ChartQueries
FROM clients
JOIN invoices
on invoices.client_id = clients.id
WHERE invoices.status_id IN (2,3,4)
{$status_filter}
AND invoices.company_id = :company_id
{$user_filter}
@@ -467,6 +469,8 @@ trait ChartQueries
{
$user_filter = $this->is_admin ? '' : 'AND clients.user_id = '.$this->user->id;
$status_filter = $this->include_drafts ? 'AND invoices.status_id IN (1,2,3,4)' : 'AND invoices.status_id IN (2,3,4)';
return DB::select("
SELECT
SUM(invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1)) as total,
@@ -474,7 +478,7 @@ trait ChartQueries
FROM clients
JOIN invoices
on invoices.client_id = clients.id
WHERE invoices.status_id IN (2,3,4)
{$status_filter}
AND invoices.company_id = :company_id
AND clients.is_deleted = 0
AND invoices.is_deleted = 0
@@ -492,6 +496,8 @@ trait ChartQueries
{
$user_filter = $this->is_admin ? '' : 'AND clients.user_id = '.$this->user->id;
$status_filter = $this->include_drafts ? 'AND invoices.status_id IN (1,2,3,4)' : 'AND invoices.status_id IN (2,3,4)';
return DB::select("
SELECT
@@ -500,7 +506,7 @@ trait ChartQueries
FROM clients
JOIN invoices
on invoices.client_id = clients.id
WHERE invoices.status_id IN (2,3,4)
{$status_filter}
AND invoices.company_id = :company_id
AND clients.is_deleted = 0
AND invoices.is_deleted = 0
@@ -523,6 +529,8 @@ trait ChartQueries
{
$user_filter = $this->is_admin ? '' : 'AND clients.user_id = '.$this->user->id;
$status_filter = $this->include_drafts ? 'AND invoices.status_id IN (1,2,3,4)' : 'AND invoices.status_id IN (2,3,4)';
return DB::select("
SELECT
SUM(invoices.amount / COALESCE(NULLIF(invoices.exchange_rate, 0), 1)) as total,
@@ -534,7 +542,7 @@ trait ChartQueries
AND clients.is_deleted = 0
AND invoices.is_deleted = 0
{$user_filter}
AND invoices.status_id IN (2,3,4)
{$status_filter}
AND (invoices.date BETWEEN :start_date AND :end_date)
GROUP BY invoices.date
", [
@@ -548,10 +556,12 @@ trait ChartQueries
{
$user_filter = $this->is_admin ? '' : 'AND clients.user_id = '.$this->user->id;
$status_filter = $this->include_drafts ? 'AND invoices.status_id IN (1,2,3,4)' : 'AND invoices.status_id IN (2,3,4)';
return DB::select("
SELECT
sum(invoices.amount) as total,
invoices.date
invoices.date
FROM clients
JOIN invoices
on invoices.client_id = clients.id
@@ -559,7 +569,7 @@ trait ChartQueries
AND clients.is_deleted = 0
AND invoices.is_deleted = 0
{$user_filter}
AND invoices.status_id IN (2,3,4)
{$status_filter}
AND (invoices.date BETWEEN :start_date AND :end_date)
AND IFNULL(CAST(JSON_UNQUOTE(JSON_EXTRACT(clients.settings, '$.currency_id')) AS SIGNED), :company_currency) = :currency_id
GROUP BY invoices.date

View File

@@ -27,7 +27,7 @@ class ChartService
use ChartQueries;
use ChartCalculations;
public function __construct(public Company $company, private User $user, private bool $is_admin)
public function __construct(public Company $company, private User $user, private bool $is_admin, private bool $include_drafts = false)
{
}

View File

@@ -13,8 +13,10 @@
namespace App\Services\Credit;
use App\Utils\Ninja;
use App\Utils\BcMath;
use App\Models\Credit;
use App\Models\Payment;
use App\Models\Paymentable;
use App\Models\PaymentType;
use App\Factory\PaymentFactory;
use App\Utils\Traits\MakesHash;
@@ -35,9 +37,9 @@ class CreditService
$this->credit = $credit;
}
public function location(): array
public function location(bool $set_countries = true): array
{
return (new LocationData($this->credit))->run();
return (new LocationData($this->credit))->run($set_countries);
}
public function getCreditPdf($invitation)
@@ -260,14 +262,60 @@ class CreditService
public function deleteCredit()
{
$paid_to_date = $this->credit->invoice_id ? $this->credit->balance : 0;
/** 2025-09-24 - On invoice reversal => credit => delete - we reassign the payment to the credit - so no need to update the paid to date! */
$this->credit
->client
->service()
// ->updatePaidToDate($paid_to_date)
->adjustCreditBalance($this->credit->balance * -1)
->save();
/** 2025-12-03 - On credit deletion => credit => delete - if this credit was linked to a previous invoice
* reversal, we cannot leave the payment dangling. This has the same net effect as a payment refund.
*
* At this point, we will assign whatever balance remains as a refund on the payment.
*
* Need to ensure the refund is never > than payment amount &&
* The refund amount should be no more than the invoice amount.
* or any remainder on the credit if it has been subsequently used elsewhere.
*
* Once this credit has been deleted, it cannot be restored?
*/
if($this->credit->invoice_id && $this->credit->balance > 0){
$this->credit->invoice
->payments()
->where('is_deleted', 0)
->cursor()
->each(function ($payment){
$balance = $this->credit->balance;
$pivot = $payment->pivot;
$refundable_amount = min($balance, $pivot->amount);
$paymentable = new Paymentable();
$paymentable->payment_id = $payment->id;
$paymentable->paymentable_id = $this->credit->id;
$paymentable->paymentable_type = Credit::class;
$paymentable->refunded = $refundable_amount;
$paymentable->save();
$payment->refunded += $refundable_amount;
if(BcMath::comp($payment->amount, $refundable_amount) == 0) {
$payment->status_id = Payment::STATUS_REFUNDED;
}
else {
$payment->status_id = Payment::STATUS_PARTIALLY_REFUNDED;
}
$payment->save();
});
}
return $this;
}
@@ -284,6 +332,54 @@ class CreditService
->adjustCreditBalance($this->credit->balance)
->save();
/**
* If we have previously deleted a reversed credit / invoice
* we would have applied an adjustment to the payments for the
* credit balance remaining. This section reverses the adjustment.
*/
if($this->credit->invoice_id && $this->credit->balance > 0){
$this->credit->invoice
->payments()
->where('is_deleted', 0)
->whereHas('paymentables', function ($q){
$q->where('paymentable_type', Credit::class)
->where('paymentable_id', $this->credit->id)
->where('refunded', '>', 0);
})
->cursor()
->each(function ($payment) {
$refund_reversal = 0;
$payment->paymentables->where('paymentable_type', Credit::class)
->where('paymentable_id', $this->credit->id)
->each(function ($paymentable) use (&$refund_reversal){
$refund_reversal += $paymentable->refunded;
$paymentable->forceDelete();
});
$payment->refunded -= $refund_reversal;
$payment->save();
if(BcMath::comp($payment->refunded, 0) == 0) {
$payment->status_id = Payment::STATUS_COMPLETED;
}
elseif(BcMath::comp($payment->amount, $payment->refunded) == 0) {
$payment->status_id = Payment::STATUS_REFUNDED;
}
else {
$payment->status_id = Payment::STATUS_PARTIALLY_REFUNDED;
}
$payment->save();
});
}
return $this;
}

View File

@@ -69,9 +69,19 @@ class ApplyNumber extends AbstractService
{
$x = 1;
/** Peppol Credit Numbers should be labelled as such. */
$peppol_enabled =$this->client->peppolSendingEnabled();
do {
try {
$this->invoice->number = $this->getNextInvoiceNumber($this->client, $this->invoice, $this->invoice->recurring_id);
if($peppol_enabled && strlen(trim($this->client->getSetting('credit_number_pattern'))) > 0) {
$this->invoice->number = $this->getPeppolCreditNumber($this->client, $this->invoice);
}
else {
$this->invoice->number = $this->getNextInvoiceNumber($this->client, $this->invoice, $this->invoice->recurring_id);
}
$this->invoice->saveQuietly();
$this->completed = false;

View File

@@ -622,11 +622,11 @@ class InvoiceService
return $this;
}
public function location(): array
public function location(bool $set_countries = true): array
{
return (new LocationData($this->invoice))->run();
return (new LocationData($this->invoice))->run($set_countries);
}
public function workFlow()
{
if ($this->invoice->status_id == Invoice::STATUS_PAID && $this->invoice->client->getSetting('auto_archive_invoice')) {
@@ -720,13 +720,7 @@ class InvoiceService
*
*/
/** New Invoice - F1 Type */
// if (empty($this->invoice->client->vat_number) || !in_array($this->invoice->client->country->iso_3166_2, (new \App\DataMapper\Tax\BaseRule())->eu_country_codes)) {
// $this->invoice->backup->guid = 'exempt';
// $this->invoice->saveQuietly();
// return $this;
// } else
if ($new_model && $this->invoice->amount >= 0) {
$this->invoice->backup->document_type = 'F1';
$this->invoice->backup->adjustable_amount = (new \App\Services\EDocument\Standards\Verifactu($this->invoice))->run()->registro_alta->calc->getTotal();

View File

@@ -37,9 +37,9 @@ class QuoteService
$this->quote = $quote;
}
public function location(): array
public function location(bool $set_countries = true): array
{
return (new LocationData($this->quote))->run();
return (new LocationData($this->quote))->run($set_countries);
}
public function createInvitations()

View File

@@ -180,9 +180,9 @@ class RecurringService
}
public function location(): array
public function location(bool $set_countries = true): array
{
return (new LocationData($this->recurring_entity))->run();
return (new LocationData($this->recurring_entity))->run($set_countries);
}
public function save()

View File

@@ -208,8 +208,15 @@ class HtmlEngine
$data['$location.custom3'] = &$data['$location3'];
$data['$location.custom4'] = &$data['$location4'];
if ($this->entity_string == 'invoice' || $this->entity_string == 'recurring_invoice') {
$data['$entity'] = ['value' => ctrans('texts.invoice'), 'label' => ctrans('texts.invoice')];
if ($this->entity_string == 'invoice' || $this->entity_string == 'recurring_invoice') {
if($this->client->peppolSendingEnabled() && $this->entity->amount < 0) {
$data['$entity'] = ['value' => ctrans('texts.credit'), 'label' => ctrans('texts.credit')];
}
else {
$data['$entity'] = ['value' => ctrans('texts.invoice'), 'label' => ctrans('texts.invoice')];
}
$data['$number'] = ['value' => $this->entity->number ?: ' ', 'label' => ctrans('texts.invoice_number')];
$data['$invoice'] = ['value' => $this->entity->number ?: ' ', 'label' => ctrans('texts.invoice_number')];
$data['$number_short'] = ['value' => $this->entity->number ?: ' ', 'label' => ctrans('texts.invoice_number_short')];

View File

@@ -40,6 +40,13 @@ trait GeneratesCounter
//todo in the form validation, we need to ensure that if a prefix and pattern is set we throw a validation error,
//only one type is allow else this will cause confusion to the end user
public function getPeppolCreditNumber(Client $client, Invoice $invoice)
{
$entity_number = $this->getNextEntityNumber(Credit::class, $client);
return $this->replaceUserVars($invoice, $entity_number);
}
private function getNextEntityNumber($entity, Client $client, $is_recurring = false)
{
$prefix = '';

View File

@@ -19,7 +19,7 @@ trait ActionsInvoice
public function invoicePayable($invoice): bool
{
if($invoice->verifactuEnabled() && $invoice->amount < 0) {
if($invoice->company->verifactuEnabled() && $invoice->amount < 0) {
return false;
}
return $invoice->isPayable();
@@ -28,10 +28,10 @@ trait ActionsInvoice
public function invoiceDeletable($invoice): bool
{
//Cancelled invoices are not deletable if verifactu is enabled
if($invoice->verifactuEnabled() && $invoice->status_id == Invoice::STATUS_DRAFT && $invoice->is_deleted == false) {
if($invoice->company->verifactuEnabled() && $invoice->status_id == Invoice::STATUS_DRAFT && $invoice->is_deleted == false) {
return true;
}
elseif($invoice->verifactuEnabled()) {
elseif($invoice->company->verifactuEnabled()) {
return false;
}
@@ -43,10 +43,10 @@ trait ActionsInvoice
public function invoiceRestorable($invoice): bool
{
if($invoice->verifactuEnabled() && !$invoice->is_deleted && $invoice->deleted_at) {
if($invoice->company->verifactuEnabled() && !$invoice->is_deleted && $invoice->deleted_at) {
return true;
}
elseif($invoice->verifactuEnabled()) {
elseif($invoice->company->verifactuEnabled()) {
return false;
}
@@ -56,7 +56,7 @@ trait ActionsInvoice
public function invoiceCancellable($invoice): bool
{
if($invoice->verifactuEnabled() &&
if($invoice->company->verifactuEnabled() &&
$invoice->backup->document_type === 'F1' &&
$invoice->backup->child_invoice_ids->count() == 0 &&
in_array($invoice->status_id, [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL]) &&
@@ -64,7 +64,7 @@ trait ActionsInvoice
{
return true;
}
elseif($invoice->verifactuEnabled()){
elseif($invoice->company->verifactuEnabled()){
return false;
}
@@ -79,7 +79,7 @@ trait ActionsInvoice
public function invoiceReversable($invoice): bool
{
if($invoice->verifactuEnabled()){
if($invoice->company->verifactuEnabled()){
return false;
}

View File

@@ -23,33 +23,33 @@ trait WithSecureContext
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function getContext(): mixed
public function getContext(string $key): mixed
{
$context = \Illuminate\Support\Facades\Cache::get(session()->getId()) ?? [];
$context = \Illuminate\Support\Facades\Cache::get($key) ?? [];
return $context;
}
public function setContext(string $property, $value): array
public function setContext(string $key, string $property, $value): array
{
$clone = $this->getContext();
$clone = $this->getContext($key);
data_set($clone, $property, $value);
\Illuminate\Support\Facades\Cache::put(session()->getId(), $clone, now()->addHour());
\Illuminate\Support\Facades\Cache::put($key, $clone, now()->addHour());
$this->dispatch(self::CONTEXT_UPDATE);
return $clone;
}
public function bulkSetContext(array $data): array
public function bulkSetContext(string $key, array $data): array
{
$clone = $this->getContext();
$clone = $this->getContext($key);
$clone = array_merge($clone, $data);
\Illuminate\Support\Facades\Cache::put(session()->getId(), $clone, now()->addHour());
\Illuminate\Support\Facades\Cache::put($key, $clone, now()->addHour());
$this->dispatch(self::CONTEXT_UPDATE);
@@ -57,8 +57,8 @@ trait WithSecureContext
}
public function resetContext(): void
{
\Illuminate\Support\Facades\Cache::forget(session()->getId());
}
// public function resetContext(): void
// {
// \Illuminate\Support\Facades\Cache::forget(session()->getId());
// }
}

View File

@@ -17,8 +17,8 @@ return [
'require_https' => env('REQUIRE_HTTPS', true),
'app_url' => rtrim(env('APP_URL', ''), '/'),
'app_domain' => env('APP_DOMAIN', 'invoicing.co'),
'app_version' => env('APP_VERSION', '5.12.37'),
'app_tag' => env('APP_TAG', '5.12.37'),
'app_version' => env('APP_VERSION', '5.12.38'),
'app_tag' => env('APP_TAG', '5.12.38'),
'minimum_client_version' => '5.0.16',
'terms_version' => '1.0.1',
'api_secret' => env('API_SECRET', false),

109
public/build/assets/app-aa93be80.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,7 @@
"file": "assets/wait-8f4ae121.js"
},
"resources/js/app.js": {
"file": "assets/app-eee19136.js",
"file": "assets/app-aa93be80.js",
"imports": [
"_index-08e160a7.js",
"__commonjsHelpers-725317a4.js"

View File

@@ -1,6 +1,6 @@
<div class="grid grid-cols-1 md:grid-cols-2">
<div class="p-2">
@livewire('flow2.invoice-summary')
@livewire('flow2.invoice-summary', ['_key' => $_key])
</div>
<div class="p-2">
@@ -18,6 +18,6 @@
session()->forget('errors');
@endphp
@livewire($this->component, [], key($this->componentUniqueId()))
@livewire($this->component, ['_key' => $_key], key($this->componentUniqueId()))
</div>
</div>

View File

@@ -52,6 +52,30 @@ class ClientApiTest extends TestCase
Model::reguard();
}
public function testPdfVariablesUnset()
{
$data = [
'name' => 'name of client',
'settings' => [
'pdf_variables' => 'xx',
'currency_id' => '2'
],
];
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->putJson("/api/v1/clients/".$this->client->hashed_id, $data)
->assertStatus(200);
$arr = $response->json();
$this->assertEquals("2", $arr['data']['settings']['currency_id']);
$this->assertArrayNotHasKey('pdf_variables', $arr['data']['settings']);
}
public function testBulkUpdates()
{
Client::factory()->count(3)->create([

View File

@@ -41,6 +41,300 @@ class CreditTest extends TestCase
}
public function testCreditDeletionAfterInvoiceReversalAndPaymentRefund()
{
$c = Client::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'balance' => 0,
'paid_to_date' => 0,
]);
$ii = new InvoiceItem();
$ii->cost = 100;
$ii->quantity = 1;
$ii->product_key = 'xx';
$ii->notes = 'yy';
$i = \App\Models\Invoice::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'client_id' => $c->id,
'tax_name1' => '',
'tax_name2' => '',
'tax_name3' => '',
'tax_rate1' => 0,
'tax_rate2' => 0,
'tax_rate3' => 0,
'discount' => 0,
'line_items' => [
$ii
],
'status_id' => 1,
]);
$repo = new InvoiceRepository();
$repo->save([], $i);
$i = $i->calc()->getInvoice();
$i = $i->service()->markPaid()->save(); //paid
$payment = $i->payments()->first();
$this->assertNotNull($payment);
$this->assertEquals(0, $i->balance);
$this->assertEquals(100, $i->amount);
$credit_array = $i->withoutRelations()->toArray();
$credit_array['invoice_id'] = $i->hashed_id;
$credit_array['client_id'] = $c->hashed_id;
unset($credit_array['backup']);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->post('/api/v1/credits', $credit_array);
$response->assertStatus(200); //reversal - credit created.
$arr = $response->json();
$credit = \App\Models\Credit::find($this->decodePrimaryKey($arr['data']['id']));
$this->assertNotNull($credit);
$payment = $payment->fresh();
$i = $i->fresh();
$this->assertEquals(\App\Models\Invoice::STATUS_REVERSED, $i->status_id);
$client = $i->client;
$this->assertEquals(100, $client->credit_balance);
$this->assertEquals(0, $client->paid_to_date);
$this->assertEquals(0, $client->balance);
//delete the credit!!
$data = [
'ids' => [$credit->hashed_id],
'action' => 'delete',
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/credits/bulk', $data);
$response->assertStatus(200);
$payment = $payment->fresh();
$this->assertEquals($payment->amount, $payment->refunded);
$this->assertEquals(\App\Models\Payment::STATUS_REFUNDED, $payment->status_id);
$this->assertTrue($payment->paymentables()->where('paymentable_type', Credit::class)->where('paymentable_id', $credit->id)->exists());
//lets restore the credit!!
$data = [
'ids' => [$credit->hashed_id],
'action' => 'restore',
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/credits/bulk', $data);
$response->assertStatus(200);
$payment = $payment->fresh();
$this->assertEquals(0, $payment->refunded);
$this->assertEquals(\App\Models\Payment::STATUS_COMPLETED, $payment->status_id);
$this->assertFalse($payment->paymentables()->where('paymentable_type', Credit::class)->where('paymentable_id', $credit->id)->exists());
}
public function testInvoiceWithMultiplePaymentsAndSingleCreditDeletionPostInvoiceReversal()
{
$c = Client::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'balance' => 0,
'paid_to_date' => 0,
]);
$ii = new InvoiceItem();
$ii->cost = 100;
$ii->quantity = 1;
$ii->product_key = 'xx';
$ii->notes = 'yy';
$i = \App\Models\Invoice::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'client_id' => $c->id,
'tax_name1' => '',
'tax_name2' => '',
'tax_name3' => '',
'tax_rate1' => 0,
'tax_rate2' => 0,
'tax_rate3' => 0,
'discount' => 0,
'line_items' => [
$ii
],
'status_id' => 1,
]);
$repo = new InvoiceRepository();
$repo->save([], $i);
$i = $i->calc()->getInvoice();
$data =[
'date' => now()->format('Y-m-d'),
'client_id' => $c->hashed_id,
'invoices' => [
[
'invoice_id' => $i->hashed_id,
'amount' => 10,
],
]
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/payments', $data);
$response->assertStatus(200);
sleep(1);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/payments', $data);
sleep(1);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/payments', $data);
sleep(1);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/payments', $data);
sleep(1);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/payments', $data);
sleep(1);
$response->assertStatus(200);
// At this stage we have 5 payments for half of the invoice amount.
//create the reversal invoice for half of the invoice.
$credit_array = $i->withoutRelations()->toArray();
$credit_array['invoice_id'] = $i->hashed_id;
$credit_array['client_id'] = $c->hashed_id;
$ii = new InvoiceItem();
$ii->cost = 50;
$ii->quantity = 1;
$ii->product_key = 'xx';
$ii->notes = 'yy';
$credit_array['line_items'] = [$ii];
unset($credit_array['backup']);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->post('/api/v1/credits', $credit_array);
$response->assertStatus(200); //reversal - credit created.
$arr = $response->json();
$this->assertEquals(50, $arr['data']['balance']);
$this->assertEquals(50, $arr['data']['amount']);
$this->assertEquals($i->hashed_id, $arr['data']['invoice_id']);
$this->assertEquals(Credit::STATUS_SENT, $arr['data']['status_id']);
$credit = Credit::withTrashed()->find($this->decodePrimaryKey($arr['data']['id']));
//delete the credit!!
$data = [
'ids' => [$arr['data']['id']],
'action' => 'delete',
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/credits/bulk', $data);
$response->assertStatus(200);
$invoice = $i->fresh();
$invoice->payments()->each(function ($payment) use ($credit){
$this->assertEquals($payment->amount, $payment->refunded);
$this->assertEquals(\App\Models\Payment::STATUS_REFUNDED, $payment->status_id);
$this->assertTrue($payment->paymentables()->where('paymentable_type', Credit::class)->where('paymentable_id', $credit->id)->exists());
});
$client = $invoice->fresh()->client->fresh();
$this->assertEquals(0, $client->credit_balance);
$this->assertEquals(0, $client->paid_to_date);
$this->assertEquals(0, $client->balance);
//restore the credit!!
$data = [
'ids' => [$arr['data']['id']],
'action' => 'restore',
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/credits/bulk', $data);
$response->assertStatus(200);
$invoice = $i->fresh();
$invoice->payments()->each(function ($payment) use ($credit){
$this->assertEquals(0, $payment->refunded);
$this->assertEquals(\App\Models\Payment::STATUS_COMPLETED, $payment->status_id);
$this->assertFalse($payment->paymentables()->where('paymentable_type', Credit::class)->where('paymentable_id', $credit->id)->exists());
});
$client = $invoice->fresh()->client->fresh();
$this->assertEquals(50, $client->credit_balance);
$this->assertEquals(0, $client->paid_to_date);
$this->assertEquals(0, $client->balance);
}
public function testClientPaidToDateStateAfterCreditCreatedForPaidInvoice()
{
$c = Client::factory()->create([

View File

@@ -33,6 +33,46 @@ class GroupSettingTest extends TestCase
$this->makeTestData();
}
public function testPdfVariablesUnset()
{
$settings = new \stdClass();
$settings->pdf_variables = 'xx';
$data = [
'name' => 'testX',
'settings' => $settings,
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/group_settings', $data);
$response->assertStatus(200);
$arr = $response->json();
$this->assertArrayNotHasKey('pdf_variables', $arr['data']['settings']);
$data = [
'name' => 'testX',
'settings' => $settings,
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/group_settings/'.$arr['data']['id'], $data);
$response->assertStatus(200);
$arr = $response->json();
$this->assertArrayNotHasKey('pdf_variables', $arr['data']['settings']);
}
public function testCastingMagic()
{

View File

@@ -424,7 +424,7 @@ class TemplateTest extends TestCase
$invoices = Invoice::orderBy('id', 'desc')->where('client_id', $this->client->id)->take(10)->get()->map(function ($c) {
return $c->service()->markSent()->applyNumber()->save();
})->map(function ($i) {
return ['invoice_id' => $i->hashed_id, 'amount' => rand(0, $i->balance)];
return ['invoice_id' => $i->hashed_id, 'amount' => rand(1, $i->balance)];
})->toArray();
Credit::factory()->count(2)->create([
@@ -439,7 +439,7 @@ class TemplateTest extends TestCase
$credits = Credit::orderBy('id', 'desc')->where('client_id', $this->client->id)->take(2)->get()->map(function ($c) {
return $c->service()->markSent()->applyNumber()->save();
})->map(function ($i) {
return ['credit_id' => $i->hashed_id, 'amount' => rand(0, $i->balance)];
return ['credit_id' => $i->hashed_id, 'amount' => rand(1, $i->balance)];
})->toArray();
$data = [