feat(integration-requests): community board to request & vote bank integrations (#550)

## What

A community board where users **propose** a bank/service (name + link)
and **vote** on the integrations they want most, so we can prioritise by
real demand.

## How it works

- **Global board**: a shared list sorted by votes desc. `has_voted` and
the monthly quota are per user.
- **Limit**: 3 combined actions (proposal + vote) per user per calendar
month. Creating a proposal **auto-votes** it → costs 2 actions.
- **Moderation**: proposals start as `pending` (visible only to their
author, with a "Pending review" badge); `approved` ones are visible to
everyone. You can't vote on what you can't see (404). The `php artisan
integration-requests:review` command approves/rejects pending ones.
- **Access**:
  - Bottom drawer (Vaul, ~95vh) that opens the same board.
- Entry points: the **connect-bank** dialog (bank-selector step), the
**create-account** dialog, and a global **"Request integration"** item
in the user menu (above Support) → available on any screen.
- The `/integration-requests` URL renders the **dashboard** with the
drawer opened on top (behind auth + verified + onboarded + subscribed).
- **Seed**: a migration that inserts Bitunix, XTB, Kraken, Degiro and
Interactive Brokers as `approved` and self-voted by the earliest-created
user (idempotent, no-op when no users exist).

## Endpoints

- `GET /integration-requests` → dashboard + drawer.
- `GET /integration-requests/data` → board state as JSON (feeds the
drawer).
- `POST /integration-requests` → create a proposal (+ auto-vote).
- `POST /integration-requests/{id}/vote` → toggle vote.

## Tests

- 15 Feature tests (access, status visibility, monthly limit, auto-vote,
vote toggle, review command) plus the user-menu tests. All green;
`pint`/`eslint`/`prettier`/`tsc`/PHPStan clean.

## Notes / follow-ups

- No Pennant flag (ships to everyone). No dedup of duplicate proposals
(moderation covers it).
- Seeding 5 integrations to one user uses up that user's monthly quota
(only affects that account via the UI).
This commit is contained in:
Víctor Falcón 2026-06-17 14:50:51 +02:00 committed by GitHub
parent 9a20335c6a
commit 5e8f227fbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1520 additions and 384 deletions

View File

@ -0,0 +1,71 @@
<?php
namespace App\Console\Commands;
use App\Enums\IntegrationRequestStatus;
use App\Models\IntegrationRequest;
use Illuminate\Console\Command;
class ReviewIntegrationRequestsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'integration-requests:review';
/**
* @var string
*/
protected $description = 'Review pending integration requests and approve or reject them';
public function handle(): int
{
$pending = IntegrationRequest::query()
->where('status', IntegrationRequestStatus::Pending)
->withCount('votes')
->with('user:id,email')
->orderBy('created_at')
->get();
if ($pending->isEmpty()) {
$this->info('No pending integration requests.');
return self::SUCCESS;
}
$this->table(
['Name', 'URL', 'Submitted by', 'Votes', 'Created'],
$pending->map(fn (IntegrationRequest $request): array => [
$request->name,
$request->url,
$request->user->email,
$request->votes_count,
$request->created_at?->format('Y-m-d') ?? '—',
])->all(),
);
$approved = 0;
$rejected = 0;
foreach ($pending as $request) {
$decision = $this->choice(
"Review \"{$request->name}\" ({$request->url})",
['approve', 'reject', 'skip'],
'skip',
);
if ($decision === 'approve') {
$request->update(['status' => IntegrationRequestStatus::Approved]);
$approved++;
} elseif ($decision === 'reject') {
$request->update(['status' => IntegrationRequestStatus::Rejected]);
$rejected++;
}
}
$skipped = $pending->count() - $approved - $rejected;
$this->info("Done. Approved: {$approved}, rejected: {$rejected}, skipped: {$skipped}.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum IntegrationRequestStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::Pending => 'Pending',
self::Approved => 'Approved',
self::Rejected => 'Rejected',
};
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers;
use App\Enums\IntegrationRequestStatus;
use App\Http\Requests\StoreIntegrationRequestRequest;
use App\Models\IntegrationRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Inertia\Response;
class IntegrationRequestController extends Controller
{
private const MONTHLY_ACTION_LIMIT = 3;
public function index(Request $request, DashboardController $dashboard): Response
{
// Render the dashboard with the integration-requests drawer opened on top of it.
return $dashboard($request)->with('openIntegrationRequests', true);
}
public function data(Request $request): JsonResponse
{
$user = $request->user();
return response()->json([
'requests' => $this->list($user),
'actionsRemaining' => $this->actionsRemaining($user),
]);
}
public function store(StoreIntegrationRequestRequest $request): JsonResponse
{
$user = $request->user();
// Creating a request also auto-votes it for the author, so it costs two actions.
if ($this->actionsRemaining($user) < 2) {
return $this->limitReachedResponse();
}
$integrationRequest = $user->integrationRequests()->create($request->only(['name', 'url']));
$integrationRequest->votes()->create(['user_id' => $user->id]);
return $this->payload($user, 201);
}
public function vote(Request $request, IntegrationRequest $integrationRequest): JsonResponse
{
$user = $request->user();
if ($integrationRequest->status !== IntegrationRequestStatus::Approved
&& $integrationRequest->user_id !== $user->id) {
abort(404);
}
$vote = $integrationRequest->votes()->where('user_id', $user->id)->first();
if ($vote !== null) {
$vote->delete();
return $this->payload($user);
}
if ($this->actionsRemaining($user) <= 0) {
return $this->limitReachedResponse();
}
$integrationRequest->votes()->create(['user_id' => $user->id]);
return $this->payload($user);
}
/**
* The board state shared by the page, the drawer and every mutation.
*
* @return Collection<int, IntegrationRequest>
*/
private function list(User $user): Collection
{
return IntegrationRequest::query()
->where(function ($query) use ($user) {
$query->where('status', IntegrationRequestStatus::Approved)
->orWhere(function ($inner) use ($user) {
$inner->where('status', IntegrationRequestStatus::Pending)
->where('user_id', $user->id);
});
})
->withCount('votes')
->withExists(['votes as has_voted' => fn ($query) => $query->where('user_id', $user->id)])
->orderByDesc('votes_count')
->orderByDesc('created_at')
->get();
}
private function actionsRemaining(User $user): int
{
$start = now()->startOfMonth();
$used = $user->integrationRequests()->where('created_at', '>=', $start)->count()
+ $user->integrationRequestVotes()->where('created_at', '>=', $start)->count();
return max(0, self::MONTHLY_ACTION_LIMIT - $used);
}
private function payload(User $user, int $status = 200): JsonResponse
{
return response()->json([
'requests' => $this->list($user),
'actionsRemaining' => $this->actionsRemaining($user),
], $status);
}
private function limitReachedResponse(): JsonResponse
{
return response()->json([
'message' => __('You have reached your monthly limit of :count integration actions. Try again next month.', ['count' => self::MONTHLY_ACTION_LIMIT]),
], 422);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreIntegrationRequestRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'url' => ['required', 'url', 'max:2048'],
];
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\IntegrationRequestStatus;
use Database\Factories\IntegrationRequestFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property string $id
* @property string $name
* @property string $url
* @property IntegrationRequestStatus $status
* @property string $user_id
*/
class IntegrationRequest extends Model
{
/** @use HasFactory<IntegrationRequestFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'name',
'url',
'status',
'user_id',
];
/** @var list<string> */
protected $hidden = [
'user_id',
'updated_at',
];
protected function casts(): array
{
return [
'status' => IntegrationRequestStatus::class,
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return HasMany<IntegrationRequestVote, $this> */
public function votes(): HasMany
{
return $this->hasMany(IntegrationRequestVote::class);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Database\Factories\IntegrationRequestVoteFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $id
* @property string $integration_request_id
* @property string $user_id
*/
class IntegrationRequestVote extends Model
{
/** @use HasFactory<IntegrationRequestVoteFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'integration_request_id',
'user_id',
];
/** @return BelongsTo<IntegrationRequest, $this> */
public function integrationRequest(): BelongsTo
{
return $this->belongsTo(IntegrationRequest::class);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -148,6 +148,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
return $this->hasMany(SuggestionRun::class);
}
/** @return HasMany<IntegrationRequest, $this> */
public function integrationRequests(): HasMany
{
return $this->hasMany(IntegrationRequest::class);
}
/** @return HasMany<IntegrationRequestVote, $this> */
public function integrationRequestVotes(): HasMany
{
return $this->hasMany(IntegrationRequestVote::class);
}
/**
* Whether the user has an active, current-version AI consent.
*/

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Enums\IntegrationRequestStatus;
use App\Models\IntegrationRequest;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<IntegrationRequest>
*/
class IntegrationRequestFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->company(),
'url' => fake()->url(),
'status' => IntegrationRequestStatus::Pending,
'user_id' => User::factory(),
];
}
public function approved(): static
{
return $this->state(['status' => IntegrationRequestStatus::Approved]);
}
public function rejected(): static
{
return $this->state(['status' => IntegrationRequestStatus::Rejected]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\IntegrationRequest;
use App\Models\IntegrationRequestVote;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<IntegrationRequestVote>
*/
class IntegrationRequestVoteFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'integration_request_id' => IntegrationRequest::factory(),
'user_id' => User::factory(),
];
}
}

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('integration_requests', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('url', 2048);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('integration_requests');
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('integration_request_votes', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('integration_request_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['integration_request_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('integration_request_votes');
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('integration_requests', function (Blueprint $table) {
$table->string('status')->default('pending')->index()->after('url');
});
}
public function down(): void
{
Schema::table('integration_requests', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};

View File

@ -0,0 +1,52 @@
<?php
use App\Enums\IntegrationRequestStatus;
use App\Models\IntegrationRequest;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* @var list<array{name: string, url: string}>
*/
private array $integrations = [
['name' => 'Bitunix', 'url' => 'https://www.bitunix.com/es-es'],
['name' => 'XTB', 'url' => 'https://www.xtb.com/int'],
['name' => 'Kraken', 'url' => 'https://www.kraken.com/'],
['name' => 'Degiro', 'url' => 'https://www.degiro.es/'],
['name' => 'Interactive Brokers', 'url' => 'https://www.interactivebrokers.com/'],
];
public function up(): void
{
$user = User::query()->oldest('created_at')->first();
if ($user === null) {
return;
}
foreach ($this->integrations as $integration) {
$request = IntegrationRequest::query()->firstOrCreate(
['name' => $integration['name'], 'user_id' => $user->id],
['url' => $integration['url'], 'status' => IntegrationRequestStatus::Approved],
);
$request->votes()->firstOrCreate(['user_id' => $user->id]);
}
}
public function down(): void
{
$user = User::query()->oldest('created_at')->first();
if ($user === null) {
return;
}
IntegrationRequest::query()
->where('user_id', $user->id)
->whereIn('name', array_column($this->integrations, 'name'))
->delete();
}
};

View File

@ -1,4 +1,16 @@
{
"Link": "Enlace",
"Submit": "Enviar",
"Pending review": "Pendiente de revisión",
"Something went wrong.": "Algo salió mal.",
"Integration requests": "Solicitudes de integración",
"Request integration": "Solicitar integración",
"Can't find your bank? Request or vote for it": "¿No encuentras tu banco? Solicítalo o vótalo",
"e.g. Revolut": "p. ej. Revolut",
"You have :count actions left this month.": "Te quedan :count acciones este mes.",
"No integrations requested yet. Be the first!": "Aún no hay integraciones solicitadas. ¡Sé el primero!",
"Request a bank or service and vote for the ones you want us to add next.": "Solicita un banco o servicio y vota los que quieres que añadamos a continuación.",
"You have reached your monthly limit of :count integration actions. Try again next month.": "Has alcanzado tu límite mensual de :count acciones de integración. Inténtalo de nuevo el mes que viene.",
"Categorized by AI": "Categorizado por IA",
"Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza",
"Only show AI guesses": "Mostrar solo sugerencias de IA",

View File

@ -1,4 +1,16 @@
{
"Link": "Lien",
"Submit": "Envoyer",
"Pending review": "En attente de validation",
"Something went wrong.": "Une erreur s'est produite.",
"Integration requests": "Demandes d'intégration",
"Request integration": "Demander une intégration",
"Can't find your bank? Request or vote for it": "Vous ne trouvez pas votre banque ? Demandez-la ou votez",
"e.g. Revolut": "p. ex. Revolut",
"You have :count actions left this month.": "Il vous reste :count actions ce mois-ci.",
"No integrations requested yet. Be the first!": "Aucune intégration demandée pour l'instant. Soyez le premier !",
"Request a bank or service and vote for the ones you want us to add next.": "Demandez une banque ou un service et votez pour ceux que vous voulez que nous ajoutions ensuite.",
"You have reached your monthly limit of :count integration actions. Try again next month.": "Vous avez atteint votre limite mensuelle de :count actions d'intégration. Réessayez le mois prochain.",
"Analyze": "Analyser",
"Monthly average": "Moyenne mensuelle",
"Trend": "S'orienter",
@ -2092,4 +2104,4 @@
"All untracked expenses": "Toutes les dépenses non suivies",
"Automatically track every expense that no other budget covers. You can only have one.": "Suit automatiquement chaque dépense qu'aucun autre budget ne couvre. Vous ne pouvez en avoir qu'un seul.",
"This catch-all budget tracks every expense that no other budget covers.": "Ce budget général suit chaque dépense qu'aucun autre budget ne couvre."
}
}

View File

@ -1,5 +1,6 @@
import { store } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController';
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog';
import { UpgradeConnectionDialog } from '@/components/open-banking/upgrade-connection-dialog';
import { Button } from '@/components/ui/button';
@ -51,6 +52,7 @@ export function CreateAccountDialog({
const [isSubmitting, setIsSubmitting] = useState(false);
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false);
const formDataRef = useRef<AccountFormData>({
displayName: '',
bankId: null,
@ -337,6 +339,11 @@ export function CreateAccountDialog({
open={upgradeDialogOpen}
onOpenChange={setUpgradeDialogOpen}
/>
<IntegrationRequestsDrawer
open={integrationDrawerOpen}
onOpenChange={setIntegrationDrawerOpen}
/>
</>
);
}

View File

@ -0,0 +1,259 @@
import {
data,
store,
vote,
} from '@/actions/App/Http/Controllers/IntegrationRequestController';
import InputError from '@/components/input-error';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { getCsrfToken } from '@/lib/csrf';
import { __ } from '@/utils/i18n';
import { ChevronUp } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
export interface IntegrationRequestItem {
id: string;
name: string;
url: string;
status: 'pending' | 'approved' | 'rejected';
votes_count: number;
has_voted: boolean;
created_at: string;
}
interface BoardPayload {
requests: IntegrationRequestItem[];
actionsRemaining: number;
}
interface Props {
initialRequests?: IntegrationRequestItem[];
initialActionsRemaining?: number;
}
async function sendJson(
url: string,
method: 'GET' | 'POST',
body?: Record<string, string>,
): Promise<Response> {
return fetch(url, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCsrfToken(),
},
body: body ? JSON.stringify(body) : undefined,
});
}
export function IntegrationRequestsBoard({
initialRequests,
initialActionsRemaining,
}: Props) {
const [requests, setRequests] = useState<IntegrationRequestItem[] | null>(
initialRequests ?? null,
);
const [actionsRemaining, setActionsRemaining] = useState<number>(
initialActionsRemaining ?? 0,
);
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [showForm, setShowForm] = useState(false);
useEffect(() => {
if (initialRequests !== undefined) {
return;
}
void (async () => {
const response = await sendJson(data().url, 'GET');
if (response.ok) {
const payload: BoardPayload = await response.json();
setRequests(payload.requests);
setActionsRemaining(payload.actionsRemaining);
}
})();
}, [initialRequests]);
const apply = (payload: BoardPayload) => {
setRequests(payload.requests);
setActionsRemaining(payload.actionsRemaining);
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setBusy(true);
try {
const response = await sendJson(store().url, 'POST', { name, url });
if (response.ok) {
apply(await response.json());
setName('');
setUrl('');
setShowForm(false);
return;
}
const body = await response.json();
setError(body.message ?? __('Something went wrong.'));
} finally {
setBusy(false);
}
};
const handleVote = async (item: IntegrationRequestItem) => {
if (busy || (!item.has_voted && actionsRemaining <= 0)) {
return;
}
setError(null);
setBusy(true);
try {
const response = await sendJson(vote(item.id).url, 'POST');
if (response.ok) {
apply(await response.json());
return;
}
const body = await response.json();
setError(body.message ?? __('Something went wrong.'));
} finally {
setBusy(false);
}
};
const outOfActions = actionsRemaining <= 0;
// A new request also auto-votes it, so it costs two actions.
const cannotRequest = actionsRemaining < 2;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{__('You have :count actions left this month.', {
count: actionsRemaining,
})}
</p>
{!showForm && (
<Button
onClick={() => setShowForm(true)}
disabled={cannotRequest}
>
{__('Request integration')}
</Button>
)}
</div>
{showForm && (
<form
onSubmit={handleSubmit}
className="space-y-3 rounded-lg border p-4"
>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="integration-name">
{__('Name')}
</Label>
<Input
id="integration-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={__('e.g. Revolut')}
maxLength={255}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="integration-url">
{__('Link')}
</Label>
<Input
id="integration-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
maxLength={2048}
required
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => {
setShowForm(false);
setError(null);
}}
>
{__('Cancel')}
</Button>
<Button type="submit" disabled={busy || cannotRequest}>
{__('Submit')}
</Button>
</div>
<InputError message={error ?? undefined} />
</form>
)}
{requests === null ? (
<div className="flex justify-center py-10">
<Spinner />
</div>
) : requests.length === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
{__('No integrations requested yet. Be the first!')}
</p>
) : (
<ul className="space-y-2">
{requests.map((item) => (
<li
key={item.id}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex min-w-0 items-center gap-2">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="truncate font-medium hover:underline"
>
{item.name}
</a>
{item.status === 'pending' && (
<Badge variant="secondary">
{__('Pending review')}
</Badge>
)}
</div>
<Button
variant={item.has_voted ? 'default' : 'outline'}
size="sm"
disabled={
busy || (!item.has_voted && outOfActions)
}
onClick={() => handleVote(item)}
aria-pressed={item.has_voted}
>
<ChevronUp className="h-4 w-4" />
{item.votes_count}
</Button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,35 @@
import { IntegrationRequestsBoard } from '@/components/integration-requests/integration-requests-board';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import { __ } from '@/utils/i18n';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function IntegrationRequestsDrawer({ open, onOpenChange }: Props) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[95vh] data-[vaul-drawer-direction=bottom]:max-h-[95vh]">
<div className="mx-auto w-full max-w-2xl overflow-y-auto p-6">
<DrawerHeader className="px-0">
<DrawerTitle>{__('Integration requests')}</DrawerTitle>
<DrawerDescription>
{__(
'Request a bank or service and vote for the ones you want us to add next.',
)}
</DrawerDescription>
</DrawerHeader>
<IntegrationRequestsBoard />
</div>
</DrawerContent>
</Drawer>
);
}

View File

@ -1,3 +1,4 @@
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
import { SupportDialog } from '@/components/support-dialog';
import {
DropdownMenu,
@ -23,6 +24,8 @@ export function NavUser({ className }: { className?: string }) {
const { state } = useSidebar();
const isMobile = useIsMobile();
const [supportOpen, setSupportOpen] = useState(false);
const [integrationRequestsOpen, setIntegrationRequestsOpen] =
useState(false);
return (
<SidebarMenu className={className}>
@ -52,6 +55,9 @@ export function NavUser({ className }: { className?: string }) {
<UserMenuContent
user={auth.user}
onOpenSupport={() => setSupportOpen(true)}
onOpenIntegrationRequests={() =>
setIntegrationRequestsOpen(true)
}
/>
</DropdownMenuContent>
</DropdownMenu>
@ -60,6 +66,10 @@ export function NavUser({ className }: { className?: string }) {
onOpenChange={setSupportOpen}
user={auth.user}
/>
<IntegrationRequestsDrawer
open={integrationRequestsOpen}
onOpenChange={setIntegrationRequestsOpen}
/>
</SidebarMenuItem>
</SidebarMenu>
);

View File

@ -1,4 +1,5 @@
import { BankLogo } from '@/components/bank-logo';
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -95,6 +96,7 @@ export function ConnectAccountDialog({
connections = [],
}: ConnectAccountDialogProps) {
const [step, setStep] = useState<Step>('country');
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false);
const [country, setCountry] = useState<string>('');
const [institutions, setInstitutions] = useState<
EnableBankingInstitution[]
@ -311,421 +313,454 @@ export function ConnectAccountDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{__('Connect Bank Account')}</DialogTitle>
<DialogDescription>
{step === 'country' &&
__(
'Select the country where your bank is located.',
)}
{step === 'bank' && __('Select your bank.')}
{step === 'confirm' &&
isWise &&
__(
'Enter your Wise Personal API token to connect your account.',
)}
{step === 'confirm' &&
!isIndexaCapital &&
!isBinance &&
!isBitpanda &&
!isCoinbase &&
!isWise &&
__(
'You will be redirected to your bank to authorize access.',
)}
{step === 'confirm' &&
isIndexaCapital &&
__(
'Enter your API token to connect your Indexa Capital account.',
)}
{step === 'confirm' &&
isBinance &&
__(
'Enter your API Key and Secret to connect your Binance account.',
)}
{step === 'confirm' &&
isBitpanda &&
__(
'Enter your API Key to connect your Bitpanda account.',
)}
{step === 'confirm' &&
isCoinbase &&
__(
'Enter your CDP App Key ID and Secret to connect your Coinbase account.',
)}
</DialogDescription>
</DialogHeader>
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{__('Connect Bank Account')}</DialogTitle>
<DialogDescription>
{step === 'country' &&
__(
'Select the country where your bank is located.',
)}
{step === 'bank' && __('Select your bank.')}
{step === 'confirm' &&
isWise &&
__(
'Enter your Wise Personal API token to connect your account.',
)}
{step === 'confirm' &&
!isIndexaCapital &&
!isBinance &&
!isBitpanda &&
!isCoinbase &&
!isWise &&
__(
'You will be redirected to your bank to authorize access.',
)}
{step === 'confirm' &&
isIndexaCapital &&
__(
'Enter your API token to connect your Indexa Capital account.',
)}
{step === 'confirm' &&
isBinance &&
__(
'Enter your API Key and Secret to connect your Binance account.',
)}
{step === 'confirm' &&
isBitpanda &&
__(
'Enter your API Key to connect your Bitpanda account.',
)}
{step === 'confirm' &&
isCoinbase &&
__(
'Enter your CDP App Key ID and Secret to connect your Coinbase account.',
)}
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{step === 'country' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>{__('Country')}</Label>
<Select value={country} onValueChange={setCountry}>
<SelectTrigger>
<SelectValue
placeholder={__('Select country')}
/>
</SelectTrigger>
<SelectContent>
{COUNTRIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{__(c.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{__('Cancel')}
</Button>
<Button
disabled={!country || isLoading}
onClick={() => fetchInstitutions(country)}
>
{isLoading ? __('Loading...') : __('Continue')}
</Button>
</div>
</div>
)}
{step === 'bank' && (
<div className="space-y-4">
<Input
placeholder={__('Search banks...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="max-h-[300px] space-y-1 overflow-y-auto">
{filteredInstitutions.map((institution) => (
<button
key={institution.name}
type="button"
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedBank?.name === institution.name
? 'bg-accent'
: ''
}`}
onClick={() => setSelectedBank(institution)}
{step === 'country' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>{__('Country')}</Label>
<Select
value={country}
onValueChange={setCountry}
>
<SelectTrigger>
<SelectValue
placeholder={__('Select country')}
/>
</SelectTrigger>
<SelectContent>
{COUNTRIES.map((c) => (
<SelectItem
key={c.code}
value={c.code}
>
{__(c.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{__('Cancel')}
</Button>
<Button
disabled={!country || isLoading}
onClick={() => fetchInstitutions(country)}
>
{isLoading
? __('Loading...')
: __('Continue')}
</Button>
</div>
</div>
)}
{step === 'bank' && (
<div className="space-y-4">
<Input
placeholder={__('Search banks...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="max-h-[300px] space-y-1 overflow-y-auto">
{filteredInstitutions.map((institution) => (
<button
key={institution.name}
type="button"
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedBank?.name ===
institution.name
? 'bg-accent'
: ''
}`}
onClick={() =>
setSelectedBank(institution)
}
>
<BankLogo
src={institution.logo}
className="h-6 w-6"
/>
<span>{institution.name}</span>
</button>
))}
{filteredInstitutions.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
{__('No banks found.')}
</p>
)}
</div>
<button
type="button"
className="cursor-pointer border-b border-dotted text-sm text-primary hover:border-solid"
onClick={() => setIntegrationDrawerOpen(true)}
>
{__(
"Can't find your bank? Request or vote for it",
)}
</button>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setStep('country')}
>
{__('Back')}
</Button>
<Button
disabled={!selectedBank}
onClick={() => setStep('confirm')}
>
{__('Continue')}
</Button>
</div>
</div>
)}
{step === 'confirm' && selectedBank && (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center gap-3">
<BankLogo
src={institution.logo}
className="h-6 w-6"
src={selectedBank.logo}
className="size-16 p-1"
/>
<span>{institution.name}</span>
</button>
))}
{filteredInstitutions.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
{__('No banks found.')}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setStep('country')}
>
{__('Back')}
</Button>
<Button
disabled={!selectedBank}
onClick={() => setStep('confirm')}
>
{__('Continue')}
</Button>
</div>
</div>
)}
{step === 'confirm' && selectedBank && (
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center gap-3">
<BankLogo
src={selectedBank.logo}
className="size-16 p-1"
/>
<div>
<p className="font-medium">
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{isBitpanda
? __(
'Connect your Bitpanda account using your API Key.',
)
: isBinance
? __(
'Connect your Binance account using your API Key and Secret.',
)
: isIndexaCapital
<div>
<p className="font-medium">
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{isBitpanda
? __(
'Connect your Indexa Capital account using your API token.',
'Connect your Bitpanda account using your API Key.',
)
: isCoinbase
: isBinance
? __(
'Connect your Coinbase account using a CDP API key.',
'Connect your Binance account using your API Key and Secret.',
)
: isWise
: isIndexaCapital
? __(
'Connect your Wise account using a Personal API token.',
'Connect your Indexa Capital account using your API token.',
)
: __(
'You will be redirected to authorize access to your account data.',
)}
</p>
: isCoinbase
? __(
'Connect your Coinbase account using a CDP API key.',
)
: isWise
? __(
'Connect your Wise account using a Personal API token.',
)
: __(
'You will be redirected to authorize access to your account data.',
)}
</p>
</div>
</div>
</div>
</div>
{isIndexaCapital && (
<div className="space-y-2">
<Label htmlFor="api-token">
{__('API Token')}
</Label>
<Input
id="api-token"
type="password"
value={apiToken}
onChange={(e) =>
setApiToken(e.target.value)
}
placeholder={__(
'Paste your Indexa Capital API token',
)}
className="my-2"
/>
<p className="text-xs text-muted-foreground">
{__(
'You can generate your API token from your Indexa Capital dashboard under',
)}{' '}
<a
href="https://indexacapital.com/es/u/user#settings-apps"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('Settings > Applications')}
</a>
.
</p>
</div>
)}
{isBinance && (
<div className="space-y-4">
{isIndexaCapital && (
<div className="space-y-2">
<Label htmlFor="api-key">
<Label htmlFor="api-token">
{__('API Token')}
</Label>
<Input
id="api-token"
type="password"
value={apiToken}
onChange={(e) =>
setApiToken(e.target.value)
}
placeholder={__(
'Paste your Indexa Capital API token',
)}
className="my-2"
/>
<p className="text-xs text-muted-foreground">
{__(
'You can generate your API token from your Indexa Capital dashboard under',
)}{' '}
<a
href="https://indexacapital.com/es/u/user#settings-apps"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('Settings > Applications')}
</a>
.
</p>
</div>
)}
{isBinance && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">
{__('API Key')}
</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) =>
setApiKey(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Binance API Key',
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="api-secret">
{__('API Secret')}
</Label>
<Input
id="api-secret"
type="password"
value={apiSecret}
onChange={(e) =>
setApiSecret(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Binance API Secret',
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Binance account under',
)}{' '}
<a
href="https://www.binance.com/es/my/settings/api-management"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Management')}
</a>
.
</p>
</div>
)}
{isBitpanda && (
<div className="space-y-2">
<Label htmlFor="bitpanda-api-key">
{__('API Key')}
</Label>
<Input
id="api-key"
id="bitpanda-api-key"
type="password"
value={apiKey}
value={bitpandaApiKey}
onChange={(e) =>
setApiKey(e.target.value)
setBitpandaApiKey(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Binance API Key',
'Paste your Bitpanda API Key',
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="api-secret">
{__('API Secret')}
</Label>
<Input
id="api-secret"
type="password"
value={apiSecret}
onChange={(e) =>
setApiSecret(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Binance API Secret',
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Binance account under',
)}{' '}
<a
href="https://www.binance.com/es/my/settings/api-management"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Management')}
</a>
.
</p>
</div>
)}
{isBitpanda && (
<div className="space-y-2">
<Label htmlFor="bitpanda-api-key">
{__('API Key')}
</Label>
<Input
id="bitpanda-api-key"
type="password"
value={bitpandaApiKey}
onChange={(e) =>
setBitpandaApiKey(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Bitpanda API Key',
)}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Bitpanda account under',
)}{' '}
<a
href="https://web.bitpanda.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Key Management')}
</a>
.
</p>
</div>
)}
{isWise && (
<div className="space-y-2">
<Label htmlFor="wise-api-token">
{__('Personal API Token')}
</Label>
<Input
id="wise-api-token"
type="password"
value={wiseApiToken}
onChange={(e) =>
setWiseApiToken(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Wise API token',
)}
/>
<p className="text-xs text-muted-foreground">
{__('Generate a token in Wise under')}{' '}
<a
href="https://wise.com/user/account#/developer"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
<p className="text-xs text-muted-foreground">
{__(
'Settings → Developer Tools → API tokens',
)}
</a>
.
</p>
</div>
)}
'You can create API keys from your Bitpanda account under',
)}{' '}
<a
href="https://web.bitpanda.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Key Management')}
</a>
.
</p>
</div>
)}
{isCoinbase && (
<div className="space-y-4">
{isWise && (
<div className="space-y-2">
<Label htmlFor="coinbase-key-name">
{__('App Key ID')}
<Label htmlFor="wise-api-token">
{__('Personal API Token')}
</Label>
<Input
id="coinbase-key-name"
type="text"
value={coinbaseKeyName}
id="wise-api-token"
type="password"
value={wiseApiToken}
onChange={(e) =>
setCoinbaseKeyName(e.target.value)
setWiseApiToken(e.target.value)
}
className="mt-1 font-mono text-xs"
placeholder="00000000-0000-0000-0000-000000000000"
className="mt-1"
placeholder={__(
'Paste your Wise API token',
)}
/>
<p className="text-xs text-muted-foreground">
{__('Generate a token in Wise under')}{' '}
<a
href="https://wise.com/user/account#/developer"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__(
'Settings → Developer Tools → API tokens',
)}
</a>
.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="coinbase-private-key">
{__('Secret')}
</Label>
<Textarea
id="coinbase-private-key"
value={coinbasePrivateKey}
onChange={(e) =>
setCoinbasePrivateKey(
e.target.value,
)
}
rows={6}
className="mt-1 font-mono text-xs"
placeholder={
'Paste your CDP API key secret'
}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
)}{' '}
<a
href="https://portal.cdp.coinbase.com/access/api"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Keys')}
</a>
. {__('Use a view-only key.')}
</p>
</div>
)}
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setStep('bank')}
disabled={isSubmitting}
>
{__('Back')}
</Button>
<Button
onClick={handleAuthorize}
disabled={
isSubmitting ||
(isIndexaCapital && !apiToken) ||
(isBinance && (!apiKey || !apiSecret)) ||
(isBitpanda && !bitpandaApiKey) ||
(isCoinbase &&
(!coinbaseKeyName ||
!coinbasePrivateKey)) ||
(isWise && !wiseApiToken)
}
>
{isSubmitting
? __('Connecting...')
: __('Connect')}
</Button>
{isCoinbase && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="coinbase-key-name">
{__('App Key ID')}
</Label>
<Input
id="coinbase-key-name"
type="text"
value={coinbaseKeyName}
onChange={(e) =>
setCoinbaseKeyName(
e.target.value,
)
}
className="mt-1 font-mono text-xs"
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="coinbase-private-key">
{__('Secret')}
</Label>
<Textarea
id="coinbase-private-key"
value={coinbasePrivateKey}
onChange={(e) =>
setCoinbasePrivateKey(
e.target.value,
)
}
rows={6}
className="mt-1 font-mono text-xs"
placeholder={
'Paste your CDP API key secret'
}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
)}{' '}
<a
href="https://portal.cdp.coinbase.com/access/api"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Keys')}
</a>
. {__('Use a view-only key.')}
</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setStep('bank')}
disabled={isSubmitting}
>
{__('Back')}
</Button>
<Button
onClick={handleAuthorize}
disabled={
isSubmitting ||
(isIndexaCapital && !apiToken) ||
(isBinance &&
(!apiKey || !apiSecret)) ||
(isBitpanda && !bitpandaApiKey) ||
(isCoinbase &&
(!coinbaseKeyName ||
!coinbasePrivateKey)) ||
(isWise && !wiseApiToken)
}
>
{isSubmitting
? __('Connecting...')
: __('Connect')}
</Button>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
)}
</DialogContent>
</Dialog>
<IntegrationRequestsDrawer
open={integrationDrawerOpen}
onOpenChange={setIntegrationDrawerOpen}
/>
</>
);
}

View File

@ -84,7 +84,13 @@ const user: User = {
describe('UserMenuContent', () => {
it('shows community above feedback in the user dropdown', () => {
render(<UserMenuContent user={user} onOpenSupport={vi.fn()} />);
render(
<UserMenuContent
user={user}
onOpenSupport={vi.fn()}
onOpenIntegrationRequests={vi.fn()}
/>,
);
const community = screen.getByRole('link', { name: /community/i });
const feedback = screen.getByRole('link', { name: /feedback/i });
@ -100,7 +106,13 @@ describe('UserMenuContent', () => {
it('triggers the support callback when the support item is clicked', () => {
const onOpenSupport = vi.fn();
render(<UserMenuContent user={user} onOpenSupport={onOpenSupport} />);
render(
<UserMenuContent
user={user}
onOpenSupport={onOpenSupport}
onOpenIntegrationRequests={vi.fn()}
/>,
);
fireEvent.click(screen.getByText('Support'));

View File

@ -18,6 +18,7 @@ import { Link, router, usePage } from '@inertiajs/react';
import {
Eye,
EyeOff,
Landmark,
LifeBuoy,
LogOut,
Map,
@ -29,9 +30,14 @@ import {
interface UserMenuContentProps {
user: User;
onOpenSupport: () => void;
onOpenIntegrationRequests: () => void;
}
export function UserMenuContent({ user, onOpenSupport }: UserMenuContentProps) {
export function UserMenuContent({
user,
onOpenSupport,
onOpenIntegrationRequests,
}: UserMenuContentProps) {
const cleanup = useMobileNavigation();
const { isPrivacyModeEnabled, togglePrivacyMode } = usePrivacyMode();
const { version } = usePage<SharedData>().props;
@ -131,6 +137,15 @@ export function UserMenuContent({ user, onOpenSupport }: UserMenuContentProps) {
{__('Roadmap')}
</a>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onOpenIntegrationRequests();
cleanup();
}}
>
<Landmark className="mr-2" />
{__('Request integration')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onOpenSupport();

View File

@ -3,6 +3,7 @@ import { CashflowSummaryCard } from '@/components/dashboard/cashflow-summary-car
import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart';
import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
import HeadingSmall from '@/components/heading-small';
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
import UnlockMessageDialog from '@/components/unlock-message-dialog';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import {
@ -41,6 +42,7 @@ interface DashboardProps extends SharedData {
current: CashflowSummary;
previous: CashflowSummary;
};
openIntegrationRequests?: boolean;
}
export default function Dashboard() {
@ -49,6 +51,9 @@ export default function Dashboard() {
const { isKeySet, encryptedMessageData, fetchEncryptedMessage } =
useEncryptionKey();
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(
!!props.openIntegrationRequests,
);
const netWorthEvolution = useMemo(
() =>
@ -164,6 +169,16 @@ export default function Dashboard() {
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title={__('Dashboard')} />
<IntegrationRequestsDrawer
open={integrationDrawerOpen}
onOpenChange={(open) => {
setIntegrationDrawerOpen(open);
if (!open) {
router.visit(dashboard().url, { preserveScroll: true });
}
}}
/>
{encryptedMessageData && (
<UnlockMessageDialog
open={showUnlockDialog}

View File

@ -7,6 +7,7 @@ use App\Http\Controllers\Auth\VerifyEmailController;
use App\Http\Controllers\BudgetController;
use App\Http\Controllers\CashflowController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\IntegrationRequestController;
use App\Http\Controllers\LoanDetailController;
use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\OpenBanking\AccountMappingController;
@ -121,10 +122,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('preview', [RuleSuggestionController::class, 'preview'])->name('preview');
Route::post('accept', [RuleSuggestionController::class, 'accept'])->name('accept');
});
// Integration requests — community board to propose and vote on bank integrations.
Route::get('integration-requests/data', [IntegrationRequestController::class, 'data'])->name('integration-requests.data');
Route::post('integration-requests', [IntegrationRequestController::class, 'store'])->name('integration-requests.store');
Route::post('integration-requests/{integrationRequest}/vote', [IntegrationRequestController::class, 'vote'])->name('integration-requests.vote');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
// Renders the dashboard with the integration-requests drawer opened on top.
Route::get('integration-requests', [IntegrationRequestController::class, 'index'])->name('integration-requests.index');
Route::get('cashflow', CashflowController::class)->name('cashflow');
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');

View File

@ -0,0 +1,190 @@
<?php
use App\Enums\IntegrationRequestStatus;
use App\Models\IntegrationRequest;
use App\Models\User;
test('guests cannot access the integration requests page', function () {
$this->get('/integration-requests')->assertRedirect();
});
test('the integration requests url renders the dashboard with the drawer open', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user)
->get('/integration-requests')
->assertOk()
->assertInertia(fn ($page) => $page
->component('dashboard')
->where('openIntegrationRequests', true)
);
});
test('the data endpoint returns the board state as json', function () {
$user = User::factory()->create();
IntegrationRequest::factory()->approved()->create();
$this->actingAs($user)
->getJson('/integration-requests/data')
->assertOk()
->assertJsonCount(1, 'requests')
->assertJsonPath('actionsRemaining', 3);
});
test('creating a request auto-votes it and costs two actions', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/integration-requests', [
'name' => 'Revolut',
'url' => 'https://revolut.com',
])
->assertCreated()
->assertJsonPath('actionsRemaining', 1)
->assertJsonPath('requests.0.votes_count', 1)
->assertJsonPath('requests.0.has_voted', true);
$this->assertDatabaseHas('integration_requests', [
'user_id' => $user->id,
'name' => 'Revolut',
'status' => 'pending',
]);
$this->assertDatabaseCount('integration_request_votes', 1);
});
test('a user cannot create a request with only one action left', function () {
$user = User::factory()->create();
IntegrationRequest::factory()->count(2)->create(['user_id' => $user->id]);
$this->actingAs($user)
->postJson('/integration-requests', ['name' => 'X', 'url' => 'https://x.com'])
->assertStatus(422);
});
test('requesting an integration requires a name and a valid url', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/integration-requests', ['name' => '', 'url' => 'not-a-url'])
->assertStatus(422)
->assertJsonValidationErrors(['name', 'url']);
});
test('a user can vote and then remove the vote', function () {
$user = User::factory()->create();
$request = IntegrationRequest::factory()->approved()->create();
$this->actingAs($user)
->postJson("/integration-requests/{$request->id}/vote")
->assertOk();
$this->assertDatabaseHas('integration_request_votes', [
'integration_request_id' => $request->id,
'user_id' => $user->id,
]);
$this->actingAs($user)
->postJson("/integration-requests/{$request->id}/vote")
->assertOk();
$this->assertDatabaseMissing('integration_request_votes', [
'integration_request_id' => $request->id,
'user_id' => $user->id,
]);
});
test('a user cannot exceed the monthly action limit', function () {
$user = User::factory()->create();
IntegrationRequest::factory()->count(3)->create(['user_id' => $user->id]);
$this->actingAs($user)
->postJson('/integration-requests', ['name' => 'X', 'url' => 'https://x.com'])
->assertStatus(422);
$other = IntegrationRequest::factory()->approved()->create();
$this->actingAs($user)
->postJson("/integration-requests/{$other->id}/vote")
->assertStatus(422);
});
test('removing a vote is allowed even at the monthly limit', function () {
$user = User::factory()->create();
$request = IntegrationRequest::factory()->approved()->create();
IntegrationRequest::factory()->count(2)->create(['user_id' => $user->id]);
$request->votes()->create(['user_id' => $user->id]);
$this->actingAs($user)
->postJson("/integration-requests/{$request->id}/vote")
->assertOk();
$this->assertDatabaseMissing('integration_request_votes', [
'integration_request_id' => $request->id,
'user_id' => $user->id,
]);
});
test('pending requests are only visible to their creator', function () {
$owner = User::factory()->create();
$viewer = User::factory()->create();
IntegrationRequest::factory()->create(['user_id' => $owner->id]);
$this->actingAs($viewer)
->getJson('/integration-requests/data')
->assertJsonCount(0, 'requests');
$this->actingAs($owner)
->getJson('/integration-requests/data')
->assertJsonCount(1, 'requests');
});
test('approved requests are visible to every user', function () {
IntegrationRequest::factory()->approved()->create();
$this->actingAs(User::factory()->create())
->getJson('/integration-requests/data')
->assertJsonCount(1, 'requests');
});
test('a user cannot vote on a pending request they do not own', function () {
$request = IntegrationRequest::factory()->create();
$this->actingAs(User::factory()->create())
->postJson("/integration-requests/{$request->id}/vote")
->assertNotFound();
});
test('the review command approves a pending request', function () {
$request = IntegrationRequest::factory()->create(['name' => 'Revolut']);
$this->artisan('integration-requests:review')
->expectsChoice(
"Review \"{$request->name}\" ({$request->url})",
'approve',
['approve', 'reject', 'skip'],
)
->assertSuccessful();
expect($request->fresh()->status)->toBe(IntegrationRequestStatus::Approved);
});
test('the review command rejects a pending request', function () {
$request = IntegrationRequest::factory()->create(['name' => 'Spam']);
$this->artisan('integration-requests:review')
->expectsChoice(
"Review \"{$request->name}\" ({$request->url})",
'reject',
['approve', 'reject', 'skip'],
)
->assertSuccessful();
expect($request->fresh()->status)->toBe(IntegrationRequestStatus::Rejected);
});
test('the review command reports when there is nothing to review', function () {
$this->artisan('integration-requests:review')
->expectsOutput('No pending integration requests.')
->assertSuccessful();
});