refactor: centralize duplicated provider & locale keys into enums (#543)

## What

Unifies keys that were repeated across the codebase into two PHP enums
as the single source of truth. Two independent commits:

### `refactor(banking)` — `BankingProvider` enum
- New `App\Enums\BankingProvider` (`indexacapital`, `binance`,
`bitpanda`, `coinbase`, `wise`, `enablebanking`).
- `BankingConnection`'s `provider` column is cast to the enum → magic
strings gone from the model, controllers (`match`), requests, jobs,
commands, factory and `where('provider', ...)`.
- Logic that was duplicated, now on the enum:
  - `usesApiKey()` — API-key auth (everything except EnableBanking).
- `defaultAccountType()` — provider → account type (removes the
duplicated ternary in `CreatesAccountsFromPending` and
`AccountMappingController`).

### `refactor(i18n)` — `Locale` enum
- New `App\Enums\Locale` (`en`/`es`/`fr`).
- Unifies the `['en','es','fr']` list (validation in `SetLocale` and
`ProfileUpdateRequest` via `Rule::enum`) and the `Accept-Language`
detection (`Locale::detectFromHeader`), previously duplicated in
`SetLocale` and `CreateNewUser`.

## Interaction with Wise (PR #525)

Rebasing onto Wise surfaced two issues this PR fixes:
1. **`isWise()` was broken** once `provider` is cast (it compared `===
'wise'` against an enum → always `false`).
2. **Account type**: Wise uses an API key (✓ it gets the auth-failed
email) but is a **checking** account, not an investment one. Account
type now flows through `defaultAccountType()` (Wise/EnableBanking →
Checking; indexa/binance/bitpanda/coinbase → Investment).

## Behavior change (minor)

`CreateNewUser` now detects `fr` at registration (previously only
`es`/`en`), consistent with the existing French support.

## Out of scope

- Frontend (`['indexacapital','binance',...]`, `provider: string`):
syncing TS with the PHP enums would need typegen. It still receives the
string via `->value`.
- `TransactionSource::Wise/EnableBanking`: a separate enum (transaction
origin), not a duplicated check.

## Tests

- New: `tests/Unit/Enums/BankingProviderTest.php`,
`tests/Unit/Enums/LocaleTest.php`.
- `vendor/bin/pint --test` ✓ · Larastan ✓ · full suite (excluding
Browser) **1615/1616** ✓.
This commit is contained in:
Víctor Falcón 2026-06-16 15:43:14 +02:00 committed by GitHub
parent 1c5a76a3a4
commit da0c8c58fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 193 additions and 105 deletions

View File

@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use App\Enums\Locale;
use App\Models\User;
use App\Services\LandingAuthOverrideService;
use Illuminate\Support\Facades\Validator;
@ -42,7 +43,7 @@ class CreateNewUser implements CreatesNewUsers
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
'locale' => $this->detectLocaleFromRequest(),
'locale' => Locale::detectFromHeader(request()->header('Accept-Language'))->value,
'timezone' => $this->normalizeTimezone($input['timezone'] ?? null),
]);
@ -69,19 +70,4 @@ class CreateNewUser implements CreatesNewUsers
return $timezone;
}
/**
* Detect locale from Accept-Language header.
*/
protected function detectLocaleFromRequest(): string
{
$acceptLanguage = request()->header('Accept-Language', '');
// Check if Spanish is preferred
if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') {
return 'es';
}
return 'en';
}
}

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingProvider;
use App\Models\Account;
use App\Models\User;
use Illuminate\Console\Command;
@ -32,7 +33,7 @@ class BackfillAccountIbans extends Command
->whereNull('iban')
->whereNotNull('external_account_id')
->whereNotNull('banking_connection_id')
->whereHas('bankingConnection', fn ($q) => $q->where('provider', 'enablebanking'));
->whereHas('bankingConnection', fn ($q) => $q->where('provider', BankingProvider::EnableBanking));
if ($connectionId) {
$query->where('banking_connection_id', $connectionId);

View File

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Mail\EnableBankingConnectionsCancelledEmail;
use App\Models\BankingConnection;
use Illuminate\Console\Command;
@ -22,7 +23,7 @@ class CancelFreeEnableBankingConnectionsCommand extends Command
$connections = BankingConnection::query()
->with(['user', 'accounts'])
->whereHas('user')
->where('provider', 'enablebanking')
->where('provider', BankingProvider::EnableBanking)
->where('status', '!=', BankingConnectionStatus::Revoked)
->where('created_at', '<=', $cutoff)
->get();

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingProvider;
use App\Models\User;
use Illuminate\Console\Command;
use Laravel\Cashier\Subscription;
@ -53,7 +54,7 @@ class DeleteUserCommand extends Command
$subscription = $this->activeSubscription($user);
$enableBankingConnections = $user->bankingConnections()
->with('accounts')
->where('provider', 'enablebanking')
->where('provider', BankingProvider::EnableBanking)
->get();
if ($subscription && ! $this->confirm("User '{$user->email}' has an active Stripe subscription. Cancel it before deleting the user?")) {

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Jobs\SyncAllBankingConnectionsJob;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
@ -55,7 +56,7 @@ class SyncBankingConnections extends Command
->orWhere('valid_until', '>', now());
});
})->orWhere(function ($query) {
$query->where('provider', 'enablebanking')
$query->where('provider', BankingProvider::EnableBanking)
->where('status', BankingConnectionStatus::Active)
->whereNotNull('valid_until')
->where('valid_until', '<=', now());
@ -92,9 +93,9 @@ class SyncBankingConnections extends Command
$connections->each(function (BankingConnection $connection) use ($sync, $fullSync) {
if ($sync) {
$this->info("Syncing {$connection->provider} connection {$connection->id}...");
$this->info("Syncing {$connection->provider->value} connection {$connection->id}...");
SyncBankingConnectionJob::dispatchSync($connection, $fullSync);
$this->info("Finished syncing {$connection->provider} connection {$connection->id}.");
$this->info("Finished syncing {$connection->provider->value} connection {$connection->id}.");
} else {
SyncBankingConnectionJob::dispatch($connection, $fullSync);
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Enums;
enum BankingProvider: string
{
case IndexaCapital = 'indexacapital';
case Binance = 'binance';
case Bitpanda = 'bitpanda';
case Coinbase = 'coinbase';
case Wise = 'wise';
case EnableBanking = 'enablebanking';
/**
* Whether the provider authenticates with user-supplied API keys
* rather than EnableBanking's hosted OAuth flow.
*/
public function usesApiKey(): bool
{
return $this !== self::EnableBanking;
}
/**
* The account type that this provider's pending accounts default to.
*/
public function defaultAccountType(): AccountType
{
return match ($this) {
self::IndexaCapital, self::Binance, self::Bitpanda, self::Coinbase => AccountType::Investment,
self::Wise, self::EnableBanking => AccountType::Checking,
};
}
}

27
app/Enums/Locale.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
enum Locale: string
{
case English = 'en';
case Spanish = 'es';
case French = 'fr';
/**
* Detect the best-matching locale from an Accept-Language header,
* falling back to English.
*/
public static function detectFromHeader(?string $acceptLanguage): self
{
$acceptLanguage ??= '';
foreach ([self::Spanish, self::French] as $locale) {
if ($acceptLanguage === $locale->value || preg_match('/^'.$locale->value.'(-|,|;)/i', $acceptLanguage) === 1) {
return $locale;
}
}
return self::English;
}
}

View File

@ -2,7 +2,6 @@
namespace App\Http\Controllers\OpenBanking;
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
@ -77,9 +76,7 @@ class AccountMappingController extends Controller
$pendingAccounts = collect($connection->pending_accounts_data)
->keyBy('uid');
$accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda() || $connection->isCoinbase())
? AccountType::Investment
: AccountType::Checking;
$accountType = $connection->provider->defaultAccountType();
foreach ($mappings as $mapping) {
$uid = $mapping['bank_account_uid'];

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\OpenBanking;
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
@ -50,7 +51,7 @@ class AuthorizationController extends Controller
);
$connection = $user->bankingConnections()->create([
'provider' => 'enablebanking',
'provider' => BankingProvider::EnableBanking,
'authorization_id' => $result['authorization_id'],
'state_token' => $stateToken,
'aspsp_name' => $validated['aspsp_name'],

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
@ -49,7 +50,7 @@ class BinanceController extends Controller
);
$connection = $user->bankingConnections()->create([
'provider' => 'binance',
'provider' => BankingProvider::Binance,
'api_token' => $validated['api_key'],
'api_secret' => $validated['api_secret'],
'aspsp_name' => 'Binance',

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
@ -49,7 +50,7 @@ class BitpandaController extends Controller
);
$connection = $user->bankingConnections()->create([
'provider' => 'bitpanda',
'provider' => BankingProvider::Bitpanda,
'api_token' => $validated['api_key'],
'aspsp_name' => 'Bitpanda',
'aspsp_country' => $validated['country'],

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
@ -49,7 +50,7 @@ class CoinbaseController extends Controller
);
$connection = $user->bankingConnections()->create([
'provider' => 'coinbase',
'provider' => BankingProvider::Coinbase,
'api_token' => $validated['api_key_name'],
'api_secret' => $validated['private_key'],
'aspsp_name' => 'Coinbase',

View File

@ -2,7 +2,6 @@
namespace App\Http\Controllers\OpenBanking\Concerns;
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Models\Bank;
use App\Models\BankingConnection;
@ -29,9 +28,7 @@ trait CreatesAccountsFromPending
$bank->update(['logo' => $connection->aspsp_logo]);
}
$accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda() || $connection->isCoinbase())
? AccountType::Investment
: AccountType::Checking;
$accountType = $connection->provider->defaultAccountType();
foreach ($connection->pending_accounts_data ?? [] as $accountData) {
$uid = $accountData['uid'] ?? null;

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\OpenBanking;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\DestroyConnectionRequest;
@ -94,10 +95,10 @@ class ConnectionController extends Controller
}
$updateData = match ($connection->provider) {
'indexacapital' => ['api_token' => $validated['api_token']],
'binance' => ['api_token' => $validated['api_key'], 'api_secret' => $validated['api_secret']],
'bitpanda' => ['api_token' => $validated['api_key']],
'coinbase' => ['api_token' => $validated['api_key_name'], 'api_secret' => $validated['private_key']],
BankingProvider::IndexaCapital => ['api_token' => $validated['api_token']],
BankingProvider::Binance => ['api_token' => $validated['api_key'], 'api_secret' => $validated['api_secret']],
BankingProvider::Bitpanda => ['api_token' => $validated['api_key']],
BankingProvider::Coinbase => ['api_token' => $validated['api_key_name'], 'api_secret' => $validated['private_key']],
default => [],
};
@ -120,10 +121,10 @@ class ConnectionController extends Controller
{
try {
match ($connection->provider) {
'indexacapital' => (new IndexaCapitalClient($validated['api_token']))->getUser(),
'binance' => (new BinanceClient($validated['api_key'], $validated['api_secret']))->getAccount(),
'bitpanda' => (new BitpandaClient($validated['api_key']))->getCryptoWallets(),
'coinbase' => (new CoinbaseClient($validated['api_key_name'], $validated['private_key']))->getAccounts(limit: 1),
BankingProvider::IndexaCapital => (new IndexaCapitalClient($validated['api_token']))->getUser(),
BankingProvider::Binance => (new BinanceClient($validated['api_key'], $validated['api_secret']))->getAccount(),
BankingProvider::Bitpanda => (new BitpandaClient($validated['api_key']))->getCryptoWallets(),
BankingProvider::Coinbase => (new CoinbaseClient($validated['api_key_name'], $validated['private_key']))->getAccounts(limit: 1),
default => throw new \InvalidArgumentException('Unsupported provider for credential update.'),
};
} catch (\InvalidArgumentException $e) {
@ -131,7 +132,7 @@ class ConnectionController extends Controller
} catch (\Throwable $e) {
Log::warning('Credential validation failed during update', [
'connection_id' => $connection->id,
'provider' => $connection->provider,
'provider' => $connection->provider->value,
'error' => $e->getMessage(),
]);

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
@ -49,7 +50,7 @@ class IndexaCapitalController extends Controller
);
$connection = $user->bankingConnections()->create([
'provider' => 'indexacapital',
'provider' => BankingProvider::IndexaCapital,
'api_token' => $validated['api_token'],
'aspsp_name' => 'Indexa Capital',
'aspsp_country' => 'ES',

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
@ -58,7 +59,7 @@ class WiseController extends Controller
);
$connection = $user->bankingConnections()->create([
'provider' => 'wise',
'provider' => BankingProvider::Wise,
'api_token' => $request->api_token,
'api_secret' => null,
'aspsp_name' => 'Wise',

View File

@ -3,6 +3,7 @@
namespace App\Http\Middleware;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Features\CalculateBalancesOnImport;
use App\Features\TransactionAnalysis;
use App\Models\BankingConnection;
@ -103,7 +104,7 @@ class HandleInertiaRequests extends Middleware
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'features' => $this->resolveFeatureFlags(),
'expiredBankingConnections' => fn () => $user ? $user->bankingConnections()
->where('provider', 'enablebanking')
->where('provider', BankingProvider::EnableBanking)
->where(function ($query) {
$query->where('status', BankingConnectionStatus::Expired)
->orWhere(function ($query) {
@ -117,7 +118,7 @@ class HandleInertiaRequests extends Middleware
->map(fn (BankingConnection $connection): array => [
'id' => $connection->id,
'aspsp_name' => $connection->aspsp_name,
'provider' => $connection->provider,
'provider' => $connection->provider->value,
'valid_until' => $connection->valid_until?->toIso8601String(),
'reconnect_url' => route('open-banking.reconnect', $connection),
]) : [],

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Enums\Locale;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
@ -29,12 +30,13 @@ class SetLocale
protected function determineLocale(Request $request): string
{
// Priority 1: Check for lang query parameter (user override on welcome page)
if ($request->has('lang') && in_array($request->get('lang'), ['en', 'es', 'fr'])) {
$locale = $request->get('lang');
// Store in session so subsequent requests remember this choice
$request->session()->put('locale', $locale);
$lang = $request->get('lang');
return $locale;
if (is_string($lang) && Locale::tryFrom($lang) !== null) {
// Store in session so subsequent requests remember this choice
$request->session()->put('locale', $lang);
return $lang;
}
// Priority 2: Check authenticated user's locale preference
@ -44,11 +46,11 @@ class SetLocale
// Priority 2b: Authenticated user without locale — detect and persist
if ($request->user()) {
$detected = $this->detectLocaleFromHeader($request);
$sessionLocale = $request->session()->get('locale');
if (in_array($request->session()->get('locale'), ['en', 'es', 'fr'])) {
$detected = $request->session()->get('locale');
}
$detected = is_string($sessionLocale) && Locale::tryFrom($sessionLocale) !== null
? $sessionLocale
: Locale::detectFromHeader($request->header('Accept-Language'))->value;
$request->user()->update(['locale' => $detected]);
@ -61,31 +63,11 @@ class SetLocale
}
// Priority 4: Detect from Accept-Language header
$detected = $this->detectLocaleFromHeader($request);
$detected = Locale::detectFromHeader($request->header('Accept-Language'))->value;
// Store in session for subsequent requests
$request->session()->put('locale', $detected);
return $detected;
}
/**
* Detect locale from Accept-Language header.
*/
protected function detectLocaleFromHeader(Request $request): string
{
$acceptLanguage = $request->header('Accept-Language', '');
// Check if Spanish is preferred
if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') {
return 'es';
}
// Check if French is preferred
if (preg_match('/^fr(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'fr') {
return 'fr';
}
return 'en';
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\OpenBanking;
use App\Enums\BankingProvider;
use App\Models\BankingConnection;
use Illuminate\Foundation\Http\FormRequest;
@ -27,17 +28,17 @@ class UpdateConnectionCredentialsRequest extends FormRequest
}
return match ($connection->provider) {
'indexacapital' => [
BankingProvider::IndexaCapital => [
'api_token' => ['required', 'string', 'min:10'],
],
'binance' => [
BankingProvider::Binance => [
'api_key' => ['required', 'string', 'min:10'],
'api_secret' => ['required', 'string', 'min:10'],
],
'bitpanda' => [
BankingProvider::Bitpanda => [
'api_key' => ['required', 'string', 'min:10'],
],
'coinbase' => [
BankingProvider::Coinbase => [
'api_key_name' => ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i'],
'private_key' => ['required', 'string', 'min:40'],
],

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Settings;
use App\Enums\Locale;
use App\Models\User;
use App\Services\CurrencyOptions;
use Illuminate\Contracts\Validation\ValidationRule;
@ -31,7 +32,7 @@ class ProfileUpdateRequest extends FormRequest
Rule::unique(User::class)->ignore($this->user()->id),
],
'currency_code' => ['required', 'string', 'max:3', Rule::in($currencyOptions->primaryCodes())],
'locale' => ['nullable', 'string', Rule::in(['en', 'es', 'fr'])],
'locale' => ['nullable', 'string', Rule::enum(Locale::class)],
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Models\BankingConnection;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -35,7 +36,7 @@ class SyncAllBankingConnectionsJob implements ShouldQueue
->orWhere('valid_until', '>', now());
});
})->orWhere(function ($query) {
$query->where('provider', 'enablebanking')
$query->where('provider', BankingProvider::EnableBanking)
->where('status', BankingConnectionStatus::Active)
->whereNotNull('valid_until')
->where('valid_until', '<=', now());

View File

@ -221,7 +221,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
'consecutive_sync_failures' => self::MAX_SCHEDULED_RETRIES + 1,
]);
if ($this->isApiKeyProvider($connection) && $connection->user?->canReceiveEmails()) {
if ($connection->usesApiKey() && $connection->user?->canReceiveEmails()) {
Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail(
$connection->user,
$connection,
@ -269,7 +269,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
$scope->setTag('banking_connection_id', (string) $connection->id);
$scope->setContext('banking_connection', [
'id' => $connection->id,
'provider' => $connection->provider,
'provider' => $connection->provider->value,
'status' => $connection->status->value,
]);
@ -510,12 +510,4 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
return $e instanceof RequestException
&& in_array($e->response->status(), [401, 403]);
}
private function isApiKeyProvider(BankingConnection $connection): bool
{
return $connection->isIndexaCapital()
|| $connection->isBinance()
|| $connection->isBitpanda()
|| $connection->isCoinbase();
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use Carbon\Carbon;
use Database\Factories\BankingConnectionFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -15,6 +16,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property bool $has_pending_accounts
* @property BankingProvider $provider
* @property BankingConnectionStatus $status
* @property Carbon|null $valid_until
* @property Carbon|null $last_synced_at
@ -61,6 +63,7 @@ class BankingConnection extends Model
protected function casts(): array
{
return [
'provider' => BankingProvider::class,
'status' => BankingConnectionStatus::class,
'valid_until' => 'datetime',
'last_synced_at' => 'datetime',
@ -103,32 +106,37 @@ class BankingConnection extends Model
public function isIndexaCapital(): bool
{
return $this->provider === 'indexacapital';
return $this->provider === BankingProvider::IndexaCapital;
}
public function isBinance(): bool
{
return $this->provider === 'binance';
return $this->provider === BankingProvider::Binance;
}
public function isBitpanda(): bool
{
return $this->provider === 'bitpanda';
return $this->provider === BankingProvider::Bitpanda;
}
public function isCoinbase(): bool
{
return $this->provider === 'coinbase';
return $this->provider === BankingProvider::Coinbase;
}
public function isEnableBanking(): bool
{
return $this->provider === 'enablebanking';
return $this->provider === BankingProvider::EnableBanking;
}
public function usesApiKey(): bool
{
return $this->provider->usesApiKey();
}
public function isWise(): bool
{
return $this->provider === 'wise';
return $this->provider === BankingProvider::Wise;
}
public function hasPendingAccounts(): bool

View File

@ -3,6 +3,7 @@
namespace Database\Factories;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -21,7 +22,7 @@ class BankingConnectionFactory extends Factory
{
return [
'user_id' => User::factory(),
'provider' => 'enablebanking',
'provider' => BankingProvider::EnableBanking,
'authorization_id' => fake()->uuid(),
'session_id' => fake()->uuid(),
'aspsp_name' => fake()->company(),
@ -76,7 +77,7 @@ class BankingConnectionFactory extends Factory
public function indexaCapital(): static
{
return $this->state(fn (array $attributes) => [
'provider' => 'indexacapital',
'provider' => BankingProvider::IndexaCapital,
'authorization_id' => null,
'session_id' => null,
'api_token' => 'test-indexa-token-'.fake()->uuid(),
@ -90,7 +91,7 @@ class BankingConnectionFactory extends Factory
public function binance(): static
{
return $this->state(fn (array $attributes) => [
'provider' => 'binance',
'provider' => BankingProvider::Binance,
'authorization_id' => null,
'session_id' => null,
'api_token' => 'test-binance-api-key-'.fake()->uuid(),
@ -105,7 +106,7 @@ class BankingConnectionFactory extends Factory
public function bitpanda(): static
{
return $this->state(fn (array $attributes) => [
'provider' => 'bitpanda',
'provider' => BankingProvider::Bitpanda,
'authorization_id' => null,
'session_id' => null,
'api_token' => 'test-bitpanda-api-key-'.fake()->uuid(),
@ -120,7 +121,7 @@ class BankingConnectionFactory extends Factory
public function coinbase(): static
{
return $this->state(fn (array $attributes) => [
'provider' => 'coinbase',
'provider' => BankingProvider::Coinbase,
'authorization_id' => null,
'session_id' => null,
'api_token' => 'organizations/org-'.fake()->uuid().'/apiKeys/key-'.fake()->uuid(),
@ -135,7 +136,7 @@ class BankingConnectionFactory extends Factory
public function wise(): static
{
return $this->state(fn (array $attributes) => [
'provider' => 'wise',
'provider' => BankingProvider::Wise,
'authorization_id' => null,
'session_id' => null,
'api_token' => 'test-wise-api-token-'.fake()->uuid(),

View File

@ -0,0 +1,34 @@
<?php
use App\Enums\AccountType;
use App\Enums\BankingProvider;
it('uses an API key for non-EnableBanking providers', function (BankingProvider $provider) {
expect($provider->usesApiKey())->toBeTrue();
})->with([
'indexa capital' => BankingProvider::IndexaCapital,
'binance' => BankingProvider::Binance,
'bitpanda' => BankingProvider::Bitpanda,
'coinbase' => BankingProvider::Coinbase,
'wise' => BankingProvider::Wise,
]);
it('does not use an API key for EnableBanking', function () {
expect(BankingProvider::EnableBanking->usesApiKey())->toBeFalse();
});
it('defaults investment providers to an investment account', function (BankingProvider $provider) {
expect($provider->defaultAccountType())->toBe(AccountType::Investment);
})->with([
'indexa capital' => BankingProvider::IndexaCapital,
'binance' => BankingProvider::Binance,
'bitpanda' => BankingProvider::Bitpanda,
'coinbase' => BankingProvider::Coinbase,
]);
it('defaults cash providers to a checking account', function (BankingProvider $provider) {
expect($provider->defaultAccountType())->toBe(AccountType::Checking);
})->with([
'wise' => BankingProvider::Wise,
'enable banking' => BankingProvider::EnableBanking,
]);

View File

@ -0,0 +1,16 @@
<?php
use App\Enums\Locale;
it('detects the locale from an Accept-Language header', function (?string $header, Locale $expected) {
expect(Locale::detectFromHeader($header))->toBe($expected);
})->with([
'spanish with region' => ['es-ES,es;q=0.9', Locale::Spanish],
'spanish bare' => ['es', Locale::Spanish],
'french with region' => ['fr-FR,fr;q=0.9,en;q=0.8', Locale::French],
'french bare' => ['fr', Locale::French],
'english' => ['en-US,en;q=0.9', Locale::English],
'unsupported falls back to english' => ['de-DE,de;q=0.9', Locale::English],
'empty falls back to english' => ['', Locale::English],
'null falls back to english' => [null, Locale::English],
]);