Skip to content

Commit c93962e

Browse files
authored
OpenCollective (#252)
* wip * Correctly handle donations
1 parent 5f2ac02 commit c93962e

18 files changed

+1319
-4
lines changed

app/Enums/LicenseSource.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ enum LicenseSource: string
77
case Stripe = 'stripe';
88
case Bifrost = 'bifrost';
99
case Manual = 'manual';
10+
case OpenCollective = 'opencollective';
1011
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<?php
2+
3+
namespace App\Filament\Resources;
4+
5+
use App\Filament\Resources\OpenCollectiveDonationResource\Pages;
6+
use App\Models\OpenCollectiveDonation;
7+
use Filament\Infolists;
8+
use Filament\Infolists\Infolist;
9+
use Filament\Resources\Resource;
10+
use Filament\Tables;
11+
use Filament\Tables\Table;
12+
use Illuminate\Database\Eloquent\Builder;
13+
use Number;
14+
15+
class OpenCollectiveDonationResource extends Resource
16+
{
17+
protected static ?string $model = OpenCollectiveDonation::class;
18+
19+
protected static ?string $navigationIcon = 'heroicon-o-heart';
20+
21+
protected static ?string $navigationGroup = 'Billing';
22+
23+
protected static ?string $navigationLabel = 'OpenCollective Donations';
24+
25+
protected static ?string $modelLabel = 'Donation';
26+
27+
protected static ?string $pluralModelLabel = 'Donations';
28+
29+
protected static ?int $navigationSort = 4;
30+
31+
public static function canCreate(): bool
32+
{
33+
return false;
34+
}
35+
36+
public static function infolist(Infolist $infolist): Infolist
37+
{
38+
return $infolist
39+
->schema([
40+
Infolists\Components\Section::make('Donation Details')
41+
->schema([
42+
Infolists\Components\TextEntry::make('order_id')
43+
->label('Order ID')
44+
->copyable(),
45+
Infolists\Components\TextEntry::make('order_idv2')
46+
->label('Order ID (v2)')
47+
->copyable(),
48+
Infolists\Components\TextEntry::make('amount')
49+
->formatStateUsing(fn ($state, $record) => Number::currency($state / 100, $record->currency)),
50+
Infolists\Components\TextEntry::make('interval')
51+
->default('One-time'),
52+
Infolists\Components\TextEntry::make('created_at')
53+
->label('Received')
54+
->dateTime(),
55+
])->columns(2),
56+
Infolists\Components\Section::make('Contributor')
57+
->schema([
58+
Infolists\Components\TextEntry::make('from_collective_name')
59+
->label('Name'),
60+
Infolists\Components\TextEntry::make('from_collective_slug')
61+
->label('Slug')
62+
->url(fn ($state) => "https://opencollective.com/{$state}")
63+
->openUrlInNewTab(),
64+
Infolists\Components\TextEntry::make('from_collective_id')
65+
->label('Collective ID'),
66+
])->columns(3),
67+
Infolists\Components\Section::make('Claim Status')
68+
->schema([
69+
Infolists\Components\IconEntry::make('claimed_at')
70+
->label('Claimed')
71+
->boolean()
72+
->trueIcon('heroicon-o-check-circle')
73+
->falseIcon('heroicon-o-x-circle')
74+
->trueColor('success')
75+
->falseColor('danger'),
76+
Infolists\Components\TextEntry::make('claimed_at')
77+
->label('Claimed At')
78+
->dateTime()
79+
->placeholder('Not claimed'),
80+
Infolists\Components\TextEntry::make('user.email')
81+
->label('Claimed By')
82+
->placeholder('Not claimed')
83+
->url(fn ($record) => $record->user_id ? UserResource::getUrl('edit', ['record' => $record->user_id]) : null),
84+
])->columns(3),
85+
]);
86+
}
87+
88+
public static function table(Table $table): Table
89+
{
90+
return $table
91+
->defaultSort('created_at', 'desc')
92+
->columns([
93+
Tables\Columns\TextColumn::make('order_id')
94+
->label('Order ID')
95+
->searchable()
96+
->sortable()
97+
->copyable(),
98+
Tables\Columns\TextColumn::make('from_collective_name')
99+
->label('Contributor')
100+
->searchable()
101+
->sortable(),
102+
Tables\Columns\TextColumn::make('amount')
103+
->formatStateUsing(fn ($state, $record) => Number::currency($state / 100, $record->currency))
104+
->sortable(),
105+
Tables\Columns\TextColumn::make('interval')
106+
->badge()
107+
->default('One-time')
108+
->color(fn (?string $state): string => $state ? 'success' : 'gray'),
109+
Tables\Columns\IconColumn::make('claimed_at')
110+
->label('Claimed')
111+
->boolean()
112+
->trueIcon('heroicon-o-check-circle')
113+
->falseIcon('heroicon-o-clock')
114+
->trueColor('success')
115+
->falseColor('warning'),
116+
Tables\Columns\TextColumn::make('user.email')
117+
->label('Claimed By')
118+
->searchable()
119+
->placeholder('-')
120+
->toggleable(),
121+
Tables\Columns\TextColumn::make('created_at')
122+
->label('Received')
123+
->dateTime()
124+
->sortable(),
125+
])
126+
->filters([
127+
Tables\Filters\Filter::make('claimed')
128+
->label('Claimed')
129+
->query(fn (Builder $query): Builder => $query->whereNotNull('claimed_at')),
130+
Tables\Filters\Filter::make('unclaimed')
131+
->label('Unclaimed')
132+
->query(fn (Builder $query): Builder => $query->whereNull('claimed_at')),
133+
Tables\Filters\Filter::make('recurring')
134+
->label('Recurring')
135+
->query(fn (Builder $query): Builder => $query->whereNotNull('interval')),
136+
])
137+
->actions([
138+
Tables\Actions\ViewAction::make(),
139+
]);
140+
}
141+
142+
public static function getRelations(): array
143+
{
144+
return [];
145+
}
146+
147+
public static function getPages(): array
148+
{
149+
return [
150+
'index' => Pages\ListOpenCollectiveDonations::route('/'),
151+
'view' => Pages\ViewOpenCollectiveDonation::route('/{record}'),
152+
];
153+
}
154+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\OpenCollectiveDonationResource\Pages;
4+
5+
use App\Filament\Resources\OpenCollectiveDonationResource;
6+
use Filament\Actions;
7+
use Filament\Resources\Pages\ListRecords;
8+
9+
class ListOpenCollectiveDonations extends ListRecords
10+
{
11+
protected static string $resource = OpenCollectiveDonationResource::class;
12+
13+
protected function getHeaderActions(): array
14+
{
15+
return [
16+
Actions\CreateAction::make(),
17+
];
18+
}
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\OpenCollectiveDonationResource\Pages;
4+
5+
use App\Filament\Resources\OpenCollectiveDonationResource;
6+
use Filament\Actions;
7+
use Filament\Resources\Pages\ViewRecord;
8+
9+
class ViewOpenCollectiveDonation extends ViewRecord
10+
{
11+
protected static string $resource = OpenCollectiveDonationResource::class;
12+
13+
protected function getHeaderActions(): array
14+
{
15+
return [
16+
Actions\EditAction::make(),
17+
];
18+
}
19+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\OpenCollectiveDonation;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Log;
8+
9+
class OpenCollectiveWebhookController extends Controller
10+
{
11+
public function handle(Request $request)
12+
{
13+
// Verify webhook signature if secret is configured
14+
if (config('services.opencollective.webhook_secret')) {
15+
$this->verifySignature($request);
16+
}
17+
18+
$payload = $request->all();
19+
$type = $payload['type'] ?? null;
20+
21+
Log::info('OpenCollective webhook received', [
22+
'type' => $type,
23+
'payload' => $payload,
24+
]);
25+
26+
// Handle different webhook types
27+
match ($type) {
28+
'order.processed' => $this->handleOrderProcessed($payload),
29+
default => Log::info('Unhandled OpenCollective webhook type', ['type' => $type]),
30+
};
31+
32+
return response()->json(['status' => 'success']);
33+
}
34+
35+
protected function verifySignature(Request $request): void
36+
{
37+
$secret = config('services.opencollective.webhook_secret');
38+
$signature = $request->header('X-OpenCollective-Signature');
39+
40+
if (! $signature) {
41+
abort(401, 'Missing webhook signature');
42+
}
43+
44+
$payload = $request->getContent();
45+
$expectedSignature = hash_hmac('sha256', $payload, $secret);
46+
47+
if (! hash_equals($expectedSignature, $signature)) {
48+
abort(401, 'Invalid webhook signature');
49+
}
50+
}
51+
52+
protected function handleOrderProcessed(array $payload): void
53+
{
54+
$webhookId = $payload['id'] ?? null;
55+
$data = $payload['data'] ?? [];
56+
$order = $data['order'] ?? [];
57+
$fromCollective = $data['fromCollective'] ?? [];
58+
59+
$orderId = $order['id'] ?? null;
60+
61+
if (! $orderId) {
62+
Log::warning('OpenCollective order.processed missing order ID', ['payload' => $payload]);
63+
64+
return;
65+
}
66+
67+
// Check if we've already processed this order
68+
if (OpenCollectiveDonation::where('order_id', $orderId)->exists()) {
69+
Log::info('OpenCollective order already processed', ['order_id' => $orderId]);
70+
71+
return;
72+
}
73+
74+
// Store the donation for later claiming
75+
OpenCollectiveDonation::create([
76+
'webhook_id' => $webhookId,
77+
'order_id' => $orderId,
78+
'order_idv2' => $order['idV2'] ?? null,
79+
'amount' => $order['totalAmount'] ?? 0,
80+
'currency' => $order['currency'] ?? 'USD',
81+
'interval' => $order['interval'] ?? null,
82+
'from_collective_id' => $fromCollective['id'] ?? $order['FromCollectiveId'] ?? 0,
83+
'from_collective_name' => $fromCollective['name'] ?? null,
84+
'from_collective_slug' => $fromCollective['slug'] ?? null,
85+
'raw_payload' => $payload,
86+
]);
87+
88+
Log::info('OpenCollective donation stored for claiming', [
89+
'order_id' => $orderId,
90+
'amount' => $order['totalAmount'] ?? 0,
91+
]);
92+
}
93+
}

app/Http/Middleware/VerifyCsrfToken.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ class VerifyCsrfToken extends Middleware
1313
*/
1414
protected $except = [
1515
'stripe/webhook',
16+
'opencollective/contribution',
1617
];
1718
}

0 commit comments

Comments
 (0)