diff --git a/app/Concerns/Enums/HasColor.php b/app/Concerns/Enums/HasColor.php new file mode 100644 index 00000000..2bfc71fa --- /dev/null +++ b/app/Concerns/Enums/HasColor.php @@ -0,0 +1,30 @@ +flip() + ->all(); + } + + public function color(): ?string + { + return collect([ + static::colors()[$this->value] ?? 'bg-gray-100', + Str::slug($this->value), + ])->join(' '); + } +} diff --git a/app/Console/Commands/ProcessEuPlatescTransactions.php b/app/Console/Commands/ProcessEuPlatescTransactions.php index 701ab9c8..2de555e1 100644 --- a/app/Console/Commands/ProcessEuPlatescTransactions.php +++ b/app/Console/Commands/ProcessEuPlatescTransactions.php @@ -1,12 +1,16 @@ getOrganizationsWithOpenDonations(); + Log::info('Processing EuPlatesc transactions' . \count($organizations)); foreach ($organizations as $organization) { $organizationID = $organization->id; $service = new EuPlatescService($organizationID); - if (!$service->canCaptureTransaction()) { + if (! $service->canCaptureTransaction()) { continue; } foreach ($organization->donations as $donation) { if ($service->recipeTransaction($donation)) { - $donation->update(['status' => EuPlatescStatus::CAPTURE]); + $donation->update([ + 'status' => EuPlatescStatus::CHARGED, + 'status_updated_at' => now(), + ]); } } } @@ -49,9 +57,7 @@ public function handle(): void private function getOrganizationsWithOpenDonations(): Collection|array { return Organization::query() - ->withWhereHas('donations', fn ($query) => $query->whereNotNull('ep_id')->where('donations.status', EuPlatescStatus::AUTHORIZED)) + ->withWhereHas('donations', fn (Builder $query) => $query->whereNotNull('ep_id')->where('donations.status', EuPlatescStatus::AUTHORIZED)) ->get(); - } - } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 4dff762f..9e25cb47 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -15,12 +15,11 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule): void { - // $schedule->command('inspire')->hourly(); - $schedule->command('model:prune')->daily(); + $schedule->command('model:prune') + ->daily(); + $schedule->command(ProcessEuPlatescTransactions::class) - ->daily() - ->everyFourHours() - ->timezone('Europe/Bucharest'); + ->everyFourHours(); } /** diff --git a/app/Enums/EuPlatescStatus.php b/app/Enums/EuPlatescStatus.php index ee975a2d..38b01f4b 100644 --- a/app/Enums/EuPlatescStatus.php +++ b/app/Enums/EuPlatescStatus.php @@ -5,16 +5,20 @@ namespace App\Enums; use App\Concerns\Enums\Arrayable; +use App\Concerns\Enums\Comparable; +use App\Concerns\Enums\HasColor; use App\Concerns\Enums\HasLabel; enum EuPlatescStatus: string { use Arrayable; + use Comparable; + use HasColor; use HasLabel; + case INITIALIZE = 'initialize'; case AUTHORIZED = 'authorized'; case UNAUTHORIZED = 'unauthorized'; - case CAPTURE = 'capture'; case CANCELED = 'canceled'; case ABORTED = 'aborted'; case PAYMENT_DECLINED = 'payment_declined'; @@ -25,4 +29,18 @@ public function labelKeyPrefix(): string { return 'donation.statuses'; } + + public static function colors(): array + { + return [ + 'initialize' => 'bg-blue-100 text-blue-800', + 'authorized' => 'bg-blue-50 text-blue-800', + 'unauthorized' => 'bg-red-100 text-red-800', + 'canceled' => 'bg-yellow-100 text-yellow-800', + 'aborted' => 'bg-yellow-50 text-yellow-800', + 'payment_declined' => 'bg-red-600 text-red-100', + 'possible_fraud' => 'bg-red-700 text-red-100', + 'charged' => 'bg-green-100 text-green-800', + ]; + } } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index bcd23a2f..a475a6ed 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -4,6 +4,7 @@ namespace App\Exceptions; +use App\Http\Middleware\HandleInertiaRequests; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Inertia\Inertia; use Throwable; @@ -38,6 +39,9 @@ public function render($request, Throwable $e) $response = parent::render($request, $e); if (! app()->isLocal() && \in_array($response->status(), [401, 403, 404, 429, 500, 503])) { + // This fixes SSR errors on error pages. + Inertia::share((new HandleInertiaRequests)->share($request)); + return Inertia::render('Error', [ 'status' => $response->status(), 'title' => __('error.' . $response->status() . '.title'), diff --git a/app/Filament/Resources/DonationResource.php b/app/Filament/Resources/DonationResource.php index 200e5eb2..1c64ed9e 100644 --- a/app/Filament/Resources/DonationResource.php +++ b/app/Filament/Resources/DonationResource.php @@ -109,18 +109,13 @@ public static function table(Table $table): Table ->label(__('donation.labels.created_at')) ->searchable() ->sortable(), - TextColumn::make('created_at') - ->formatStateUsing(fn (Donation $record) => $record->created_at->format('Y-m-d H:i')) - ->label(__('donation.labels.created_at')) - ->searchable() - ->sortable(), - TextColumn::make('charge_date') + TextColumn::make('status_updated_at') ->formatStateUsing(fn (Donation $record) => $record->charge_date?->format('Y-m-d H:i')) - ->label(__('donation.labels.charge_date')) + ->label(__('donation.labels.status_updated_at')) ->searchable() ->sortable(), TextColumn::make('status') - ->label(__('donation.labels.organization')) + ->label(__('donation.labels.status')) ->formatStateUsing(fn (Donation $record) => __($record->status->label())) ->searchable() ->sortable(), @@ -152,6 +147,7 @@ public static function table(Table $table): Table ), DateFilter::make('created_at'), ]) + ->defaultSort('created_at', 'desc') ->actions([ ViewAction::make()->iconButton(), ]); diff --git a/app/Filament/Resources/EditionsResource.php b/app/Filament/Resources/EditionsResource.php index c4e7e0cc..218abb7c 100644 --- a/app/Filament/Resources/EditionsResource.php +++ b/app/Filament/Resources/EditionsResource.php @@ -78,7 +78,7 @@ public static function form(Form $form): Form ->maxFiles(1) ->columnSpanFull(), - Select::make('rule_page') + Select::make('page_id') ->relationship('page', 'title') ->label(__('edition.labels.rule_page')) ->preload() diff --git a/app/Filament/Resources/ProjectResource/Pages/ViewProject.php b/app/Filament/Resources/ProjectResource/Pages/ViewProject.php index 734f0071..c0da0471 100644 --- a/app/Filament/Resources/ProjectResource/Pages/ViewProject.php +++ b/app/Filament/Resources/ProjectResource/Pages/ViewProject.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\ProjectResource\Pages; use App\Filament\Resources\ProjectResource; +use App\Filament\Resources\ProjectResource\Widgets\DonationsOverviewWidget; use Filament\Resources\Pages\ViewRecord; class ViewProject extends ViewRecord @@ -15,4 +16,11 @@ public function hasCombinedRelationManagerTabsWithForm(): bool { return true; } + + protected function getHeaderWidgets(): array + { + return [ + DonationsOverviewWidget::class, + ]; + } } diff --git a/app/Filament/Resources/ProjectResource/RelationManagers/DonationsRelationManager.php b/app/Filament/Resources/ProjectResource/RelationManagers/DonationsRelationManager.php index 0f369bc4..f8c24890 100644 --- a/app/Filament/Resources/ProjectResource/RelationManagers/DonationsRelationManager.php +++ b/app/Filament/Resources/ProjectResource/RelationManagers/DonationsRelationManager.php @@ -4,11 +4,14 @@ namespace App\Filament\Resources\ProjectResource\RelationManagers; -use Filament\Forms; +use App\Enums\EuPlatescStatus; +use App\Filament\Forms\Components\Value; +use App\Models\Donation; use Filament\Resources\Form; use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\Table; use Filament\Tables; +use Illuminate\Support\Number; class DonationsRelationManager extends RelationManager { @@ -21,24 +24,30 @@ public static function getTitle(): string return __('donation.label.plural'); } - protected function getTableHeading(): string - { - return __( - 'donation.labels.count_with_amount', - [ - 'count' => $this->getTableQuery()->count(), - 'total' => $this->getTableQuery()->sum('amount'), - ] - ); - } - public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\TextInput::make('uuid') - ->required() - ->maxLength(255), + Value::make('full_name') + ->label(__('donation.labels.full_name')), + + Value::make('email') + ->label(__('donation.labels.email')), + + Value::make('amount') + ->label(__('donation.labels.amount')) + ->content(fn (Donation $record) => Number::currency($record->amount, 'RON', app()->getLocale())), + + Value::make('status') + ->label(__('donation.labels.status')), + + Value::make('created_at') + ->label(__('donation.labels.created_at')) + ->withTime(), + + Value::make('charge_date') + ->label(__('donation.labels.charge_date')) + ->withTime(), ]); } @@ -46,20 +55,40 @@ public static function table(Table $table): Table { return $table ->columns([ - Tables\Columns\TextColumn::make('uuid'), + + Tables\Columns\TextColumn::make('full_name') + ->label(__('donation.labels.full_name')) + ->searchable(), + + Tables\Columns\TextColumn::make('amount') + ->label(__('donation.labels.amount')) + ->formatStateUsing(fn ($state) => Number::currency($state, 'RON', app()->getLocale())) + ->sortable() + ->alignRight(), + + Tables\Columns\TextColumn::make('status') + ->label(__('donation.labels.status')) + ->formatStateUsing(fn ($state) => EuPlatescStatus::tryFrom($state)?->label()), + + Tables\Columns\TextColumn::make('created_at') + ->label(__('donation.labels.created_at')) + ->sortable(), + + Tables\Columns\TextColumn::make('charge_date') + ->label(__('donation.labels.charge_date')) + ->sortable(), + ]) ->filters([ // ]) - ->headerActions([ - Tables\Actions\CreateAction::make(), - ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\ViewAction::make(), Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ Tables\Actions\DeleteBulkAction::make(), - ]); + ]) + ->defaultSort('created_at', 'desc'); } } diff --git a/app/Filament/Resources/ProjectResource/Widgets/DonationsOverviewWidget.php b/app/Filament/Resources/ProjectResource/Widgets/DonationsOverviewWidget.php new file mode 100644 index 00000000..df0b1511 --- /dev/null +++ b/app/Filament/Resources/ProjectResource/Widgets/DonationsOverviewWidget.php @@ -0,0 +1,29 @@ +record->donations()->whereCharged()->count()), + Card::make(__('donation.stats.charged_amount'), Number::currency( + $this->record->donations()->whereCharged()->sum('amount'), + 'RON', + app()->getLocale() + )), + Card::make(__('donation.stats.pending'), $this->record->donations()->wherePending()->count()), + Card::make(__('donation.stats.failed'), $this->record->donations()->wherePending()->count()), + ]; + } +} diff --git a/app/Filament/Widgets/StatisticsDonationsChart.php b/app/Filament/Widgets/StatisticsDonationsChart.php index fb03e135..24d9720a 100644 --- a/app/Filament/Widgets/StatisticsDonationsChart.php +++ b/app/Filament/Widgets/StatisticsDonationsChart.php @@ -36,7 +36,7 @@ protected function getData(): array ] ) ->where('created_at', '>', $whereCreatedCondition) - ->whereIn('status', [EuPlatescStatus::CAPTURE]) + ->whereIn('status', [EuPlatescStatus::CHARGED]) ->groupBy($chartInterval) ->orderBy($chartInterval) ->get(); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index e2029984..7c0bc984 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -62,6 +62,10 @@ public function shareOnce(Request $request): array protected function flash(Request $request): ?array { + if (! $request->hasSession()) { + return null; + } + $type = match (true) { $request->session()->has('error') => 'error', $request->session()->has('success') => 'success', diff --git a/app/Models/Donation.php b/app/Models/Donation.php index 1817867c..3740c283 100644 --- a/app/Models/Donation.php +++ b/app/Models/Donation.php @@ -60,6 +60,30 @@ public function scopeSearch(Builder $query, string $searchedText): Builder ->orWhere('email', 'LIKE', "%{$searchedText}%"); } + public function scopeWhereCharged(Builder $query): Builder + { + return $query->where('status', EuPlatescStatus::CHARGED); + } + + public function scopeWherePending(Builder $query): Builder + { + return $query->whereIn('status', [ + EuPlatescStatus::INITIALIZE, + EuPlatescStatus::AUTHORIZED, + ]); + } + + public function scopeWhereFailed(Builder $query): Builder + { + return $query->whereIn('status', [ + EuPlatescStatus::UNAUTHORIZED, + EuPlatescStatus::CANCELED, + EuPlatescStatus::ABORTED, + EuPlatescStatus::PAYMENT_DECLINED, + EuPlatescStatus::POSSIBLE_FRAUD, + ]); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Models/Edition.php b/app/Models/Edition.php index ec06e7d5..f5853a03 100644 --- a/app/Models/Edition.php +++ b/app/Models/Edition.php @@ -24,6 +24,8 @@ class Edition extends Model implements HasMedia 'short_description', 'start_date', 'end_date', + 'page_id', + 'article_category_id', ]; public function editionCategories(): HasMany diff --git a/app/Services/EuPlatescService.php b/app/Services/EuPlatescService.php index a6f4e66d..72541a80 100644 --- a/app/Services/EuPlatescService.php +++ b/app/Services/EuPlatescService.php @@ -7,12 +7,14 @@ use App\Enums\EuPlatescStatus; use App\Models\Donation; use App\Models\Organization; -use Http; +use Illuminate\Support\Facades\Http; class EuPlatescService { public const CAPTURE_METHOD = 'capture'; + private const ErrCodeForAlreadyCaptured = 8; + private string $merchantId; private string $privateKey; @@ -30,8 +32,8 @@ public function __construct($organizationId) $organization = Organization::findOrFail($organizationId); $this->merchantId = $organization->eu_platesc_merchant_id; $this->privateKey = $organization->eu_platesc_private_key; - $this->userKey = $organization->eu_platesc_user_key ?? ''; - $this->userApiKey = $organization->eu_platesc_user_api_key ?? ''; + $this->userKey = $organization->eu_platesc_merchant_id ?? ''; + $this->userApiKey = $organization->eu_platesc_private_key ?? ''; $this->testMode = config('services.eu_platesc.test_mode'); $this->url = config('services.eu_platesc.url'); @@ -124,15 +126,17 @@ public function processIpn(Donation $donation, array $validatedData): void $donation->update($values); $user = $donation->user; - $userBadge = new UserBadge(); - $userBadge->updateDonationBadge($user); + if ($user) { + $userBadge = new UserBadge(); + $userBadge->updateDonationBadge($user); + } } public function recipeTransaction(Donation $donation): bool { $data = [ 'method' => self::CAPTURE_METHOD, - 'ukey' => $this->userKey, + 'mid' => $this->userKey, 'epid' => $donation->ep_id, 'timestamp' => gmdate('YmdHis'), 'nonce' => md5(mt_rand() . time()), @@ -142,6 +146,10 @@ public function recipeTransaction(Donation $donation): bool $response = $this->callMethod($data); if (isset($response['error'])) { + if ($response['ecode'] == self::ErrCodeForAlreadyCaptured) { + return true; + } + return false; } diff --git a/app/Services/UserBadge.php b/app/Services/UserBadge.php index c4d4b0f0..7b342abe 100644 --- a/app/Services/UserBadge.php +++ b/app/Services/UserBadge.php @@ -19,7 +19,7 @@ class UserBadge public function updateDonationBadge(User $user): void { $donationCount = $user->donations - ->filter(fn (Donation $donation) => $donation->status == EuPlatescStatus::CAPTURE) + ->filter(fn (Donation $donation) => EuPlatescStatus::CHARGED->is($donation->status)) ->count(); $badgeRule = $this->getBadgeRuleByDonationCount($donationCount); diff --git a/lang/ro/donation.php b/lang/ro/donation.php index a8dc1186..20187de8 100644 --- a/lang/ro/donation.php +++ b/lang/ro/donation.php @@ -34,6 +34,7 @@ 'count' => 'Numar donatii', 'count_with_amount' => 'Numarul de dontatii :count ( :total RON)', 'donors' => 'Donatori', + 'status_updated_at' => 'Data actualizării statusului', ], 'statuses' => [ @@ -44,9 +45,15 @@ 'aborted' => 'Abandonată', 'payment_declined' => 'Plata refuzată', 'possible_fraud' => 'Posibil fraudulos', - 'charged' => 'Incasată', - 'capture' => 'Incasată', + 'charged' => 'Încasată', ], 'header' => 'Donatii (:number)', + + 'stats' => [ + 'charged' => 'Donații încasate', + 'charged_amount' => 'Sumă încasată', + 'pending' => 'Donații în așteptare', + 'failed' => 'Donații eșuate', + ], ]; diff --git a/lang/ro/validation.php b/lang/ro/validation.php index 9ece34d8..e43ff824 100644 --- a/lang/ro/validation.php +++ b/lang/ro/validation.php @@ -108,7 +108,7 @@ 'mixed' => ':Attribute trebuie să conțină cel puțin o literă mare și o literă mică.', 'numbers' => ':Attribute trebuie să conțină cel puțin un număr.', 'symbols' => ':Attribute trebuie să conțină cel puțin un simbol.', - 'uncompromised' => ':Attribute dat a apărut într-o scurgere de date. Vă rugăm să alegeți un alt :attribute.', + 'uncompromised' => ':Attribute nu este sigură. Te rugăm să folosești o altă parolă.', ], 'present' => 'Câmpul :attribute trebuie să fie prezent.', 'prohibited' => 'Câmpul :attribute este interzis.', diff --git a/resources/js/Layouts/DashboardLayout.vue b/resources/js/Layouts/DashboardLayout.vue index f2a7b6c0..636ed050 100644 --- a/resources/js/Layouts/DashboardLayout.vue +++ b/resources/js/Layouts/DashboardLayout.vue @@ -166,10 +166,10 @@ const navigation = [ ]; const isActive = (item) => { - if (item.route === `${window.location.origin}${window.location.pathname}${window.location.search}`) { - return true; + if (typeof window === 'undefined') { + return false; } - return false; + return item.route === `${window.location.origin}${window.location.pathname}${window.location.search}`; };