feat(banking): let Wise credentials be updated (#588)

## What

Makes **Wise** connections support credential updates, like every other
API-key provider.

Wise was the only API-key provider not wired into the credential-update
path:
`ConnectionController::validateProviderCredentials()` had no Wise arm,
so it
fell to the `Unsupported provider` default, and the update dialog
(driven by the
provider registry's `updatable` flag) rendered nothing. A Wise
connection in an
auth-error state therefore showed an **Update Credentials** button that
opened
an empty, unusable dialog.

## Fix

- Add the Wise validation arm in `validateProviderCredentials`
  (`WiseClient::getProfiles()`). The validation rules and the
`api_token` → column mapping already come from `BankingProvider`'s
credential
  registry, so nothing else on the backend changes.
- Drop the now-unused `updatable` flag from the connect-providers
registry —
every connect provider is updatable — and simplify
`update-credentials-dialog`
  to render fields for any registry provider.

## Tests

- Updating Wise credentials with a valid token succeeds (status back to
active,
  token stored, sync dispatched).
- `BankingProviderTest`: every API-key provider declares credential
fields,
  guarding against adding a provider without an update path again.

Follow-up to #581 (this branch is off the updated `main`).
This commit is contained in:
Víctor Falcón 2026-06-23 11:54:31 +02:00 committed by GitHub
parent f60e6d7035
commit 619ed0f1db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 50 additions and 13 deletions

View File

@ -17,6 +17,7 @@ use App\Services\Banking\BitpandaClient;
use App\Services\Banking\CoinbaseClient;
use App\Services\Banking\IndexaCapitalClient;
use App\Services\Banking\InteractiveBrokersClient;
use App\Services\Banking\WiseClient;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
@ -119,6 +120,7 @@ class ConnectionController extends Controller
BankingProvider::Bitpanda => (new BitpandaClient($validated['api_key']))->getCryptoWallets(),
BankingProvider::Coinbase => (new CoinbaseClient($validated['api_key_name'], $validated['private_key']))->getAccounts(limit: 1),
BankingProvider::InteractiveBrokers => (new InteractiveBrokersClient($validated['token'], $validated['query_id']))->fetchStatement(),
BankingProvider::Wise => (new WiseClient($validated['api_token']))->getProfiles(),
default => throw new \InvalidArgumentException('Unsupported provider for credential update.'),
};
} catch (\InvalidArgumentException $e) {

View File

@ -34,9 +34,6 @@ export function UpdateCredentialsDialog({
const [error, setError] = useState<string | null>(null);
const provider = connectProviderByKey(connection.provider);
// Only providers the backend exposes a credential-update path for (Wise has none).
const updatableProvider =
provider && provider.updatable !== false ? provider : undefined;
const setCredential = useCallback((key: string, value: string) => {
setCredentials((current) => ({ ...current, [key]: value }));
@ -55,7 +52,7 @@ export function UpdateCredentialsDialog({
}
function handleSubmit() {
if (!updatableProvider) {
if (!provider) {
return;
}
@ -64,14 +61,14 @@ export function UpdateCredentialsDialog({
router.patch(
`/settings/connections/${connection.id}/credentials`,
credentialPayload(updatableProvider, credentials),
credentialPayload(provider, credentials),
{
onSuccess: () => {
onOpenChange(false);
resetState();
},
onError: (errors) => {
const fieldError = updatableProvider.fields
const fieldError = provider.fields
.map((f) => errors[f.key])
.find(Boolean);
@ -90,8 +87,8 @@ export function UpdateCredentialsDialog({
);
}
const isValid = updatableProvider
? isProviderComplete(updatableProvider, credentials)
const isValid = provider
? isProviderComplete(provider, credentials)
: false;
return (
@ -108,9 +105,9 @@ export function UpdateCredentialsDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
{updatableProvider && (
{provider && (
<ProviderCredentialFields
provider={updatableProvider}
provider={provider}
values={credentials}
onChange={setCredential}
idPrefix="update"

View File

@ -43,8 +43,6 @@ export type ConnectProvider = {
onlyCountry?: string;
/** Hidden unless this Pennant feature is active. */
feature?: keyof Features;
/** Backend exposes a credential-update path for this provider. */
updatable?: boolean;
/** Confirm-step header copy (i18n key). */
headerDescription: string;
/** Confirm-card copy (i18n key). */
@ -190,7 +188,6 @@ export const CONNECT_PROVIDERS: ConnectProvider[] = [
maximum_consent_validity: null,
},
endpoint: '/open-banking/wise/connect',
updatable: false,
headerDescription:
'Enter your Wise Personal API token to connect your account.',
cardDescription:

View File

@ -327,6 +327,34 @@ test('users can update bitpanda credentials with valid api key', function () {
expect($connection->api_token)->toBe('new-valid-bitpanda-key-12345');
});
test('users can update wise credentials with valid api token', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->wise()->error()->create([
'user_id' => $user->id,
'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.',
]);
Http::fake([
'api.wise.com/v1/profiles' => Http::response([
['id' => 1, 'type' => 'personal', 'details' => []],
]),
]);
$response = $this->actingAs($user)->patch("/settings/connections/{$connection->id}/credentials", [
'api_token' => 'new-valid-wise-token-12345',
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($connection->error_message)->toBeNull();
expect($connection->api_token)->toBe('new-valid-wise-token-12345');
});
test('updating credentials with invalid token returns validation error', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->error()->create([

View File

@ -55,3 +55,16 @@ it('maps credential inputs onto the encrypted connection columns', function () {
expect(BankingProvider::EnableBanking->credentialColumns([]))->toBe([]);
});
it('defines credential fields for every API-key provider', function () {
foreach (BankingProvider::cases() as $provider) {
if (! $provider->usesApiKey()) {
continue;
}
// Every API-key provider must declare its credential shape; this is
// what drives connect/update rules, the column mapping and the update
// dialog — so a new provider can't be added without an update path.
expect($provider->credentialFields())->not->toBeEmpty();
}
});