[ 'label_key' => 'sale.sale', 'payment_account' => ['Sales', 'Sales of Product Income', 'Uncategorised Income'], 'deposit_to' => ['Accounts Receivable (A/R)'], 'payment_primary_type' => 'income', 'deposit_primary_type' => 'asset', ], 'sell_payment' => [ 'label_key' => 'accounting::lang.sales_payments', 'payment_account' => ['Accounts Receivable (A/R)'], 'deposit_to' => ['Cash and cash equivalents', 'Undeposited Funds', 'Bank'], 'payment_primary_type' => 'asset', 'deposit_primary_type' => 'asset', ], 'purchases' => [ 'label_key' => 'purchase.purchases', 'payment_account' => ['Accounts Payable (A/P)'], 'deposit_to' => ['Inventory Asset', 'Inventory', 'Uncategorised Asset'], 'payment_primary_type' => 'liability', 'deposit_primary_type' => 'asset', ], 'purchase_payment' => [ 'label_key' => 'accounting::lang.purchase_payments', 'payment_account' => ['Accounts Payable (A/P)'], 'deposit_to' => ['Cash and cash equivalents', 'Undeposited Funds', 'Bank'], 'payment_primary_type' => 'liability', 'deposit_primary_type' => 'asset', ], 'expense' => [ 'label_key' => 'accounting::lang.expenses', 'payment_account' => ['Uncategorised Expense', 'Utilities', 'Wage expenses'], 'deposit_to' => ['Cash and cash equivalents', 'Accounts Payable (A/P)', 'Undeposited Funds'], 'payment_primary_type' => 'expenses', 'deposit_primary_type' => 'asset', ], ]; public function assess(int $businessId): array { $locations = BusinessLocation::where('business_id', $businessId)->get(['id', 'name', 'accounting_default_map']); $expenseCategories = ExpenseCategory::where('business_id', $businessId)->get(['id', 'name']); $locationRows = []; $summary = []; foreach ($locations as $location) { $map = $this->decodeMap($location->accounting_default_map); $scenarios = []; foreach ($this->scenarioDefinitions($expenseCategories) as $key => $definition) { $missing = $this->missingFields($map, $key); $complete = empty($missing); $scenarios[] = [ 'key' => $key, 'label' => __($definition['label_key']), 'complete' => $complete, 'missing' => $missing, ]; if (! isset($summary[$key])) { $summary[$key] = ['total' => 0, 'complete' => 0]; } $summary[$key]['total']++; if ($complete) { $summary[$key]['complete']++; } } $missingCount = collect($scenarios)->where('complete', false)->count(); $locationRows[] = [ 'location_id' => (int) $location->id, 'location_name' => (string) $location->name, 'scenarios' => $scenarios, 'missing_count' => $missingCount, 'is_complete' => $missingCount === 0, ]; } $completeLocations = collect($locationRows)->where('is_complete', true)->count(); return [ 'total_locations' => count($locationRows), 'complete_locations' => $completeLocations, 'locations' => $locationRows, 'summary' => $summary, ]; } public function sync(int $businessId, bool $overwrite = false): array { $accounts = $this->loadAccounts($businessId); $locations = BusinessLocation::where('business_id', $businessId)->get(['id', 'name', 'accounting_default_map']); $expenseCategories = ExpenseCategory::where('business_id', $businessId)->get(['id', 'name']); $updatedLocations = 0; $filledFields = 0; $skippedFields = 0; $details = []; foreach ($locations as $location) { $map = $this->decodeMap($location->accounting_default_map); $locationChanged = false; foreach ($this->scenarioDefinitions($expenseCategories) as $key => $definition) { foreach (['payment_account', 'deposit_to'] as $field) { $current = data_get($map, "{$key}.{$field}"); if (! $overwrite && ! empty($current)) { $skippedFields++; continue; } $resolved = $this->resolveAccountId( $accounts, $definition[$field] ?? [], $definition["{$field}_primary_type"] ?? null ); if (! $resolved) { $skippedFields++; continue; } if ((int) $current !== (int) $resolved) { data_set($map, "{$key}.{$field}", $resolved); $filledFields++; $locationChanged = true; $details[] = [ 'location_id' => (int) $location->id, 'location_name' => (string) $location->name, 'scenario' => $key, 'field' => $field, 'account_id' => $resolved, ]; } else { $skippedFields++; } } } if ($locationChanged) { BusinessLocation::where('id', $location->id)->update([ 'accounting_default_map' => json_encode($map), ]); $updatedLocations++; } } $paymentLinks = $this->syncPaymentAccountGlLinks($businessId, $accounts, $overwrite); return [ 'updated_locations' => $updatedLocations, 'filled_fields' => $filledFields, 'skipped_fields' => $skippedFields, 'payment_accounts_linked' => $paymentLinks['linked'], 'details' => $details, ]; } public function syncPaymentAccountGlLinks(int $businessId, ?Collection $accounts = null, bool $overwrite = false): array { $accounts = $accounts ?? $this->loadAccounts($businessId); $bankGlIds = $accounts ->filter(fn ($account) => $this->matchesPreferredName($account->name, ['Cash and cash equivalents', 'Undeposited Funds', 'Bank'])) ->pluck('id') ->all(); if (empty($bankGlIds)) { return ['linked' => 0]; } $defaultBankGlId = (int) $bankGlIds[0]; $linked = 0; $paymentAccounts = Account::where('business_id', $businessId) ->where('is_closed', 0) ->get(['id', 'name', 'accounting_account_id']); foreach ($paymentAccounts as $paymentAccount) { if (! $overwrite && ! empty($paymentAccount->accounting_account_id)) { continue; } $matchedGl = $accounts->first(function ($gl) use ($paymentAccount) { return $this->namesAreSimilar($paymentAccount->name, $gl->name); }); $glId = $matchedGl?->id ?? $defaultBankGlId; DB::table('accounts') ->where('id', $paymentAccount->id) ->where('business_id', $businessId) ->update(['accounting_account_id' => $glId]); $linked++; } return ['linked' => $linked]; } protected function scenarioDefinitions(Collection $expenseCategories): array { $definitions = self::CORE_SCENARIOS; foreach ($expenseCategories as $category) { $key = 'expense_'.$category->id; $definitions[$key] = [ 'label_key' => 'accounting::lang.expenses', 'label_suffix' => $category->name, 'payment_account' => ['Uncategorised Expense', 'Utilities', 'Wage expenses'], 'deposit_to' => ['Cash and cash equivalents', 'Accounts Payable (A/P)', 'Undeposited Funds'], 'payment_primary_type' => 'expenses', 'deposit_primary_type' => 'asset', ]; } return $definitions; } protected function missingFields(array $map, string $scenarioKey): array { $missing = []; foreach (['payment_account', 'deposit_to'] as $field) { if (empty(data_get($map, "{$scenarioKey}.{$field}"))) { $missing[] = $field; } } return $missing; } protected function decodeMap(?string $raw): array { $decoded = json_decode((string) $raw, true); return is_array($decoded) ? $decoded : []; } protected function loadAccounts(int $businessId): Collection { return AccountingAccount::query() ->where('business_id', $businessId) ->where('status', 'active') ->get(['id', 'name', 'account_primary_type']); } protected function resolveAccountId(Collection $accounts, array $preferredNames, ?string $primaryType): ?int { foreach ($preferredNames as $name) { $match = $accounts->first(fn ($account) => strcasecmp($account->name, $name) === 0); if ($match) { return (int) $match->id; } } foreach ($preferredNames as $name) { $match = $accounts->first(fn ($account) => $this->matchesPreferredName($account->name, [$name])); if ($match) { return (int) $match->id; } } if ($primaryType) { $types = $primaryType === 'expenses' ? ['expenses', 'expense'] : [$primaryType]; $match = $accounts->first(fn ($account) => in_array($account->account_primary_type, $types, true)); if ($match) { return (int) $match->id; } } return null; } protected function matchesPreferredName(string $accountName, array $preferredNames): bool { $normalized = strtolower($accountName); foreach ($preferredNames as $preferred) { if (str_contains($normalized, strtolower($preferred))) { return true; } } return false; } protected function namesAreSimilar(string $left, string $right): bool { $left = strtolower(trim($left)); $right = strtolower(trim($right)); return $left === $right || str_contains($left, $right) || str_contains($right, $left); } }