Fixes for throttle middleware

This commit is contained in:
David Bomba
2026-01-26 10:00:10 +11:00
parent 33bdd69801
commit 0b286debca
7 changed files with 222 additions and 13 deletions

View File

@@ -21,18 +21,34 @@ class QuickbooksSyncMap
{ {
public SyncDirection $direction = SyncDirection::BIDIRECTIONAL; public SyncDirection $direction = SyncDirection::BIDIRECTIONAL;
// Push event settings (for PUSH direction)
public bool $push_on_create = false; // Push when entity is created (e.g., new client)
public bool $push_on_update = false; // Push when entity is updated (e.g., updated client)
public array $push_on_statuses = []; // Push when entity status matches (e.g., invoice statuses: ['draft', 'sent', 'paid', 'deleted'])
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
{ {
$this->direction = isset($attributes['direction']) $this->direction = isset($attributes['direction'])
? SyncDirection::from($attributes['direction']) ? SyncDirection::from($attributes['direction'])
: SyncDirection::BIDIRECTIONAL; : SyncDirection::BIDIRECTIONAL;
$this->push_on_create = $attributes['push_on_create'] ?? false;
$this->push_on_update = $attributes['push_on_update'] ?? false;
$this->push_on_statuses = $attributes['push_on_statuses'] ?? [];
} }
public function toArray(): array public function toArray(): array
{ {
// Ensure direction is always returned as a string value, not the enum object
$directionValue = $this->direction instanceof \App\Enum\SyncDirection
? $this->direction->value
: (string) $this->direction;
return [ return [
'direction' => $this->direction->value, 'direction' => $directionValue,
'push_on_create' => $this->push_on_create,
'push_on_update' => $this->push_on_update,
'push_on_statuses' => $this->push_on_statuses,
]; ];
} }
} }

View File

@@ -1062,4 +1062,60 @@ class Company extends BaseModel
return $this->getSetting('e_invoice_type') == 'VERIFACTU'; return $this->getSetting('e_invoice_type') == 'VERIFACTU';
}); });
} }
/**
* Check if QuickBooks push should be triggered for an entity/action.
*
* Uses efficient checks to avoid overhead for companies not using QuickBooks.
* Uses once() to cache the result for the request lifecycle.
*
* This method is designed to be called from model observers to efficiently
* determine if a push job should be dispatched, with zero overhead for
* companies that don't use QuickBooks.
*
* @param string $entity Entity type: 'client', 'invoice', etc.
* @param string $action Action type: 'create', 'update', 'status'
* @param string|null $status Optional status for status-based pushes (e.g., invoice status: 'draft', 'sent', 'paid', 'deleted')
* @return bool
*/
public function shouldPushToQuickbooks(string $entity, string $action, ?string $status = null): bool
{
// FASTEST CHECK: Raw database column (no object instantiation, no JSON decode)
// This is the cheapest possible check - just a null comparison
// For companies without QuickBooks, this returns immediately with ~0.001ms overhead
if (is_null($this->getRawOriginal('quickbooks'))) {
return false;
}
// Cache the detailed check for this request lifecycle
// This prevents re-checking if called multiple times in the same request
// Note: once() caches per closure, so we need separate closures for different entity/action combinations
return once(function () use ($entity, $action, $status) {
// Check if QuickBooks is actually configured (has token)
if (!$this->quickbooks->isConfigured()) {
return false;
}
// Verify entity exists in settings
if (!isset($this->quickbooks->settings->{$entity})) {
return false;
}
$entitySettings = $this->quickbooks->settings->{$entity};
$direction = $entitySettings->direction->value;
// Check if sync direction allows push
if ($direction !== 'push' && $direction !== 'bidirectional') {
return false;
}
// Check action-specific settings
return match($action) {
'create' => $entitySettings->push_on_create ?? false,
'update' => $entitySettings->push_on_update ?? false,
'status' => $status && in_array($status, $entitySettings->push_on_statuses ?? []),
default => false,
};
}, $cacheKey);
}
} }

View File

@@ -77,6 +77,16 @@ class ClientObserver
if ($subscriptions) { if ($subscriptions) {
WebhookHandler::dispatch(Webhook::EVENT_CREATE_CLIENT, $client, $client->company)->delay(0); WebhookHandler::dispatch(Webhook::EVENT_CREATE_CLIENT, $client, $client->company)->delay(0);
} }
// QuickBooks push - efficient check in observer (zero overhead if not configured)
if ($client->company->shouldPushToQuickbooks('client', 'create')) {
\App\Jobs\Quickbooks\PushClientToQuickbooks::dispatch(
$client->id,
$client->company->id,
$client->company->db,
'create'
);
}
} }
/** /**
@@ -115,6 +125,16 @@ class ClientObserver
if ($subscriptions) { if ($subscriptions) {
WebhookHandler::dispatch($event, $client, $client->company, 'client')->delay(0); WebhookHandler::dispatch($event, $client, $client->company, 'client')->delay(0);
} }
// QuickBooks push - efficient check in observer (zero overhead if not configured)
if ($client->company->shouldPushToQuickbooks('client', 'update')) {
\App\Jobs\Quickbooks\PushClientToQuickbooks::dispatch(
$client->id,
$client->company->id,
$client->company->db,
'update'
);
}
} }
/** /**

View File

@@ -35,6 +35,16 @@ class InvoiceObserver
if ($subscriptions) { if ($subscriptions) {
WebhookHandler::dispatch(Webhook::EVENT_CREATE_INVOICE, $invoice, $invoice->company, 'client')->delay(0); WebhookHandler::dispatch(Webhook::EVENT_CREATE_INVOICE, $invoice, $invoice->company, 'client')->delay(0);
} }
// QuickBooks push - efficient check in observer (zero overhead if not configured)
if ($invoice->company->shouldPushToQuickbooks('invoice', 'create')) {
\App\Jobs\Quickbooks\PushInvoiceToQuickbooks::dispatch(
$invoice->id,
$invoice->company->id,
$invoice->company->db,
'create'
);
}
} }
/** /**
@@ -63,6 +73,43 @@ class InvoiceObserver
if ($subscriptions) { if ($subscriptions) {
WebhookHandler::dispatch($event, $invoice, $invoice->company, 'client')->delay(0); WebhookHandler::dispatch($event, $invoice, $invoice->company, 'client')->delay(0);
} }
// QuickBooks push - check push_on_update OR push_on_statuses
// Map invoice status to string for status-based push check
$invoiceStatus = $this->mapInvoiceStatusToString($invoice->status_id, $invoice->is_deleted);
$shouldPush = $invoice->company->shouldPushToQuickbooks('invoice', 'update') ||
$invoice->company->shouldPushToQuickbooks('invoice', 'status', $invoiceStatus);
if ($shouldPush) {
\App\Jobs\Quickbooks\PushInvoiceToQuickbooks::dispatch(
$invoice->id,
$invoice->company->id,
$invoice->company->db,
'update'
);
}
}
/**
* Map invoice status_id and is_deleted to status string for QuickBooks push.
*
* @param int $statusId
* @param bool $isDeleted
* @return string
*/
private function mapInvoiceStatusToString(int $statusId, bool $isDeleted): string
{
if ($isDeleted) {
return 'deleted';
}
return match($statusId) {
\App\Models\Invoice::STATUS_DRAFT => 'draft',
\App\Models\Invoice::STATUS_SENT => 'sent',
\App\Models\Invoice::STATUS_PAID => 'paid',
default => 'unknown',
};
} }
/** /**

View File

@@ -201,4 +201,81 @@ class QuickbooksService
return isset($this->settings->{$entity}->direction) && ($this->settings->{$entity}->direction === $direction || $this->settings->{$entity}->direction === \App\Enum\SyncDirection::BIDIRECTIONAL); return isset($this->settings->{$entity}->direction) && ($this->settings->{$entity}->direction === $direction || $this->settings->{$entity}->direction === \App\Enum\SyncDirection::BIDIRECTIONAL);
} }
/**
* Fetch income accounts from QuickBooks.
*
* @return array Array of account objects with 'Id', 'Name', 'AccountType', etc.
*/
public function fetchIncomeAccounts(): array
{
try {
if (!$this->sdk) {
return [];
}
$query = "SELECT * FROM Account WHERE AccountType = 'Income' AND Active = true";
$accounts = $this->sdk->Query($query);
return is_array($accounts) ? $accounts : [];
} catch (\Exception $e) {
nlog("Error fetching income accounts: {$e->getMessage()}");
return [];
}
}
/**
* Fetch expense accounts from QuickBooks.
*
* @return array Array of account objects with 'Id', 'Name', 'AccountType', etc.
*/
public function fetchExpenseAccounts(): array
{
try {
if (!$this->sdk) {
return [];
}
$query = "SELECT * FROM Account WHERE AccountType IN ('Expense', 'Cost of Goods Sold') AND Active = true";
$accounts = $this->sdk->Query($query);
return is_array($accounts) ? $accounts : [];
} catch (\Exception $e) {
nlog("Error fetching expense accounts: {$e->getMessage()}");
return [];
}
}
/**
* Format accounts for UI dropdown consumption.
*
* @param array $accounts Raw account objects from QuickBooks API
* @return array Formatted array with 'value' (ID) and 'label' (Name) for each account
*/
public function formatAccountsForDropdown(array $accounts): array
{
$formatted = [];
foreach ($accounts as $account) {
$id = is_object($account) && isset($account->Id)
? (string) $account->Id
: (is_array($account) && isset($account['Id']) ? (string) $account['Id'] : null);
$name = is_object($account) && isset($account->Name)
? (string) $account->Name
: (is_array($account) && isset($account['Name']) ? (string) $account['Name'] : '');
if ($id && $name) {
$formatted[] = [
'value' => $id,
'label' => $name,
'account_type' => is_object($account) && isset($account->AccountType)
? (string) $account->AccountType
: (is_array($account) && isset($account['AccountType']) ? (string) $account['AccountType'] : ''),
];
}
}
return $formatted;
}
} }

View File

@@ -224,16 +224,9 @@ class CompanyTransformer extends EntityTransformer
'smtp_verify_peer' => (bool) $company->smtp_verify_peer, 'smtp_verify_peer' => (bool) $company->smtp_verify_peer,
'e_invoice' => $company->e_invoice ?: new \stdClass(), 'e_invoice' => $company->e_invoice ?: new \stdClass(),
'legal_entity_id' => $company->legal_entity_id ? (int) $company->legal_entity_id : null, 'legal_entity_id' => $company->legal_entity_id ? (int) $company->legal_entity_id : null,
'quickbooks' => $company->getRawOriginal('quickbooks') ? $company->quickbooks->toArray() : null,
]; ];
// Only include QuickBooks flags if column is not null AND has actual connection data
// This prevents API bloat for users who don't use QuickBooks
$quickbooksRaw = $company->getRawOriginal('quickbooks');
if (!is_null($quickbooksRaw) && $company->quickbooks->isConfigured()) {
$data['has_quickbooks_token'] = true;
$data['is_quickbooks_token_active'] = (bool) $company->quickbooks->accessTokenKey;
}
return $data; return $data;
} }

View File

@@ -36,12 +36,12 @@ Route::post('password/reset', [ResetPasswordController::class, 'reset'])->middle
Route::get('auth/{provider}', [LoginController::class, 'redirectToProvider']); Route::get('auth/{provider}', [LoginController::class, 'redirectToProvider']);
Route::middleware(['url_db'])->group(function () { Route::middleware(['url_db'])->group(function () {
Route::get('/user/confirm/{confirmation_code}', [UserController::class, 'confirm'])->throttle(10, 1); Route::get('/user/confirm/{confirmation_code}', [UserController::class, 'confirm'])->middleware('throttle:10,1');
Route::post('/user/confirm/{confirmation_code}', [UserController::class, 'confirmWithPassword'])->throttle(10, 1); Route::post('/user/confirm/{confirmation_code}', [UserController::class, 'confirmWithPassword'])->middleware('throttle:10,1');
}); });
Route::get('stripe/signup/{token}', [StripeConnectController::class, 'initialize'])->throttle(10, 1)->name('stripe_connect.initialization'); Route::get('stripe/signup/{token}', [StripeConnectController::class, 'initialize'])->middleware('throttle:10,1')->name('stripe_connect.initialization');
Route::get('stripe/completed', [StripeConnectController::class, 'completed'])->throttle(10, 1)->name('stripe_connect.return'); Route::get('stripe/completed', [StripeConnectController::class, 'completed'])->middleware('throttle:10,1')->name('stripe_connect.return');
Route::get('yodlee/onboard/{token}', [YodleeController::class, 'auth'])->name('yodlee.auth'); Route::get('yodlee/onboard/{token}', [YodleeController::class, 'auth'])->name('yodlee.auth');