Compare commits
295 Commits
main
...
19425c0f62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19425c0f62 | ||
|
|
1443ff0d3a | ||
|
|
e48ec3c564 | ||
|
|
eeffe4c6f1 | ||
|
|
9a8305d986 | ||
|
|
6ca0b50403 | ||
|
|
ce7da1ff66 | ||
|
|
87f348462b | ||
|
|
dba0cd5882 | ||
|
|
78af7838bf | ||
|
|
b8bb7926c0 | ||
|
|
6e4656946c | ||
|
|
c94fbe4ab8 | ||
|
|
9ccf079a3a | ||
|
|
e0e9723b11 | ||
|
|
0d2759b0d4 | ||
|
|
f0e8cee850 | ||
|
|
981df2ee45 | ||
|
|
6bc1d86009 | ||
|
|
53a6500e6a | ||
|
|
75c4dbd1f0 | ||
|
|
5d48b804a5 | ||
|
|
80dca9fe67 | ||
|
|
78bd3c9267 | ||
|
|
c4ac38e41a | ||
|
|
84e253b61c | ||
|
|
8414305ea3 | ||
|
|
694ce218c9 | ||
|
|
ec98086e23 | ||
|
|
d87d22fa22 | ||
|
|
a21321bb3c | ||
|
|
7a91e40bb3 | ||
|
|
71604c6e41 | ||
|
|
2b4d9e9411 | ||
|
|
35d8c94c11 | ||
|
|
ce43cac145 | ||
|
|
b11f010938 | ||
|
|
e3b356e810 | ||
|
|
6bd75b0788 | ||
|
|
14bb375674 | ||
|
|
a33bf0e3a4 | ||
|
|
1241f5092e | ||
|
|
73728f6baf | ||
|
|
db90b9af2e | ||
|
|
7dd8bc4c91 | ||
|
|
ee6fb7a5bb | ||
|
|
1c4c93c547 | ||
|
|
bdb1789a10 | ||
|
|
4bf0d5052c | ||
|
|
d629b745c4 | ||
|
|
72dd1409e8 | ||
|
|
2729c3c713 | ||
|
|
4135deb110 | ||
|
|
fda97b3c05 | ||
|
|
55608c311d | ||
|
|
ead80025fc | ||
|
|
d000d9b456 | ||
|
|
ebfcc090d6 | ||
|
|
49c4f9ad7d | ||
|
|
0089a14204 | ||
|
|
0eb3b85f06 | ||
|
|
db0fdc58a1 | ||
|
|
0db0ddf3c4 | ||
|
|
df5e8204fa | ||
|
|
6f7bf818dd | ||
|
|
b3ea522e31 | ||
|
|
b267ae2c15 | ||
|
|
4706b21d22 | ||
|
|
96e65ffc0b | ||
|
|
348834250a | ||
|
|
31a5148263 | ||
|
|
35f28fd48d | ||
|
|
53a90fec33 | ||
|
|
e1a2850768 | ||
|
|
03ee16bb87 | ||
|
|
1313135020 | ||
|
|
85f2c42fc5 | ||
|
|
6318aec3cb | ||
|
|
056d864f80 | ||
|
|
ef88342bd0 | ||
|
|
d76b26b7ad | ||
|
|
c1dfbaa51e | ||
|
|
32644eb41e | ||
|
|
db5fea9f2a | ||
|
|
fba9714ede | ||
|
|
cebc1d1ec5 | ||
|
|
5aa79b587d | ||
|
|
2e089f7f77 | ||
|
|
fd52f8e13d | ||
|
|
8ac38cf264 | ||
|
|
66193a6461 | ||
|
|
64c9d7357a | ||
|
|
8aa2efdd9a | ||
|
|
4f3503e3f4 | ||
|
|
4235eda49a | ||
|
|
ad0e8b7923 | ||
|
|
446eb15c6b | ||
|
|
02a24877f7 | ||
|
|
f016004b2b | ||
|
|
a0248d976b | ||
|
|
99a880854a | ||
|
|
a3747138a4 | ||
|
|
287cc8a532 | ||
|
|
191f39cf5b | ||
|
|
543b3015ca | ||
|
|
9d3c866562 | ||
|
|
911880f1a0 | ||
|
|
b9d91c8f40 | ||
|
|
23193a3452 | ||
|
|
da6f95aead | ||
|
|
2f9a700e00 | ||
|
|
50cc4e76df | ||
|
|
941931934f | ||
|
|
9b245e9c51 | ||
|
|
b9708d5174 | ||
|
|
a038594130 | ||
|
|
9bab5f6c89 | ||
|
|
ebab856137 | ||
|
|
fa33e7cbcf | ||
|
|
198fbf6751 | ||
|
|
246e54f970 | ||
|
|
1c5412e82c | ||
|
|
0b1430e64d | ||
|
|
52c2aa0e9b | ||
|
|
dd459aa381 | ||
|
|
02ec14a0d3 | ||
|
|
e490f9995c | ||
|
|
5e5b69f655 | ||
|
|
e5e74febbd | ||
|
|
5674ed99f1 | ||
|
|
6ab24e65a1 | ||
|
|
d7ba1880dc | ||
|
|
9d8f01d294 | ||
|
|
f88aa40315 | ||
|
|
cb5d5a2870 | ||
|
|
e28eb9a90b | ||
|
|
3c2ebdbc0e | ||
|
|
a916bf8c4d | ||
|
|
7a71efedd1 | ||
|
|
e1221e0466 | ||
|
|
508c8201fa | ||
|
|
750acb0bec | ||
|
|
42f6178b6d | ||
|
|
802e360c8e | ||
|
|
7030e8b5b9 | ||
|
|
b61507ea04 | ||
|
|
dfaf21898a | ||
|
|
fbd48afbd6 | ||
|
|
6f6d8901ec | ||
|
|
d4ab9a3a20 | ||
|
|
fbff2afa3e | ||
|
|
926bc7d070 | ||
|
|
f1f552ad2d | ||
|
|
4219daba25 | ||
|
|
1e821a2fb4 | ||
|
|
48d4716ab1 | ||
|
|
45f0cea264 | ||
|
|
9d7990fe71 | ||
|
|
0c5939e541 | ||
|
|
e7e095cec9 | ||
|
|
d905ba8e6c | ||
|
|
40bed1e44e | ||
|
|
7e77dd2931 | ||
|
|
b316beb522 | ||
|
|
6d3f4f36e8 | ||
|
|
9e4ea3dafb | ||
|
|
1517eb8631 | ||
|
|
9a4ece33bf | ||
|
|
30c653913d | ||
|
|
4c37f874bd | ||
|
|
05fdda811b | ||
|
|
eeeca0eed5 | ||
|
|
fa6a5678f0 | ||
|
|
63956087a4 | ||
|
|
a3f153de6f | ||
|
|
8d729c6a86 | ||
|
|
7ad43a3661 | ||
|
|
7aa0a4c847 | ||
|
|
df60be826d | ||
|
|
918bff08aa | ||
|
|
292c8f0b26 | ||
|
|
11018f273d | ||
|
|
7e32d8f706 | ||
|
|
ad829ae509 | ||
|
|
2f93271d94 | ||
|
|
62255dc9e7 | ||
|
|
738659112d | ||
|
|
89d9b656de | ||
|
|
5d0ae0faa5 | ||
|
|
2ecd417b55 | ||
|
|
3755213010 | ||
|
|
9cb236f123 | ||
|
|
10232cf40e | ||
|
|
3ce6507268 | ||
|
|
a39295a0f0 | ||
|
|
5dc69fb187 | ||
|
|
92b341bdcd | ||
|
|
725a7a29b3 | ||
|
|
8634d16359 | ||
|
|
81446b37c3 | ||
|
|
33e46b448d | ||
|
|
289ef70e53 | ||
|
|
d0559bf8c9 | ||
|
|
0ef4b32bf6 | ||
|
|
3612c97e86 | ||
|
|
c0510581c6 | ||
|
|
1ffd3e3b9d | ||
|
|
e05ee3b186 | ||
|
|
cf7b2e563a | ||
|
|
719afb6920 | ||
|
|
83c58358a1 | ||
|
|
2b888078a0 | ||
|
|
2f584162d6 | ||
|
|
0833ea6b36 | ||
|
|
5bdc15d399 | ||
|
|
693540f609 | ||
|
|
c0193c9581 | ||
|
|
03c7b20cae | ||
|
|
3a78c4f2c0 | ||
|
|
fa333deed9 | ||
|
|
a733df6221 | ||
|
|
5ee1baa7e2 | ||
|
|
2f19752199 | ||
|
|
7dd7ec14a4 | ||
|
|
d9568be579 | ||
|
|
9cf6e9d94d | ||
|
|
a23ce0c86f | ||
|
|
9efea136bd | ||
|
|
7a6f489b8b | ||
|
|
cc11e024f0 | ||
|
|
2089251a92 | ||
|
|
53094b8d36 | ||
|
|
0c33c1ddc1 | ||
|
|
ce0b7c951a | ||
|
|
fbbbbdac4c | ||
|
|
94d0713ec0 | ||
|
|
3e36354916 | ||
|
|
24a1319cc2 | ||
|
|
b1250c6246 | ||
|
|
fd7a3c846a | ||
|
|
1ca7545f86 | ||
|
|
9f4a202d2b | ||
|
|
fe0525e678 | ||
|
|
d62efdb55c | ||
|
|
be722f6e37 | ||
|
|
898ac9ff0e | ||
|
|
c8d1ac7971 | ||
|
|
3ee23f3a66 | ||
|
|
993c351832 | ||
|
|
2444a62a4d | ||
|
|
e52720a3cb | ||
|
|
93bed358ba | ||
|
|
a16bd9c498 | ||
|
|
e32b1fa45a | ||
|
|
6edc890e01 | ||
|
|
e4100f7800 | ||
|
|
7786e3d134 | ||
|
|
30f3d148bb | ||
|
|
1970c259ed | ||
|
|
dc5c80cda4 | ||
|
|
75a9bcee12 | ||
|
|
6fe363640f | ||
|
|
3df0542013 | ||
|
|
4f4a527010 | ||
|
|
e69c94ad20 | ||
|
|
5afa96251b | ||
|
|
24f053d4c4 | ||
|
|
ec360ed860 | ||
|
|
83e78d7c66 | ||
|
|
9b1c5bf978 | ||
|
|
fb23a0a2f3 | ||
|
|
2287e7f32c | ||
|
|
cceed361b7 | ||
|
|
02363792c8 | ||
|
|
e93a00f0fc | ||
|
|
c1be7dd1ef | ||
|
|
f01a0e823b | ||
|
|
915aede66e | ||
|
|
b854e3feaa | ||
|
|
4bcaef53f7 | ||
|
|
8f1d3a3eb6 | ||
|
|
ab2cf3e023 | ||
|
|
ce0ab269c9 | ||
|
|
dce24bb86a | ||
|
|
03bf178d61 | ||
|
|
8ebaf6c31d | ||
|
|
1b6dc63ec6 | ||
|
|
accc63f4a2 | ||
|
|
59e318e7b9 | ||
|
|
3de1d3deab | ||
|
|
e9afbeb028 | ||
|
|
3e2b63f71f | ||
|
|
cff014ede5 | ||
|
|
8c5d3b93d5 | ||
|
|
22cb7ed7ce |
1
.beads/last-touched
Normal file
1
.beads/last-touched
Normal file
@@ -0,0 +1 @@
|
||||
fotospiel-app-vc3
|
||||
24
.env.example
24
.env.example
@@ -117,22 +117,14 @@ PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_SANDBOX=true
|
||||
|
||||
# Lemon Squeezy Billing
|
||||
LEMONSQUEEZY_STORE_ID=284860
|
||||
LEMONSQUEEZY_API_KEY=
|
||||
LEMONSQUEEZY_WEBHOOK_SECRET=
|
||||
LEMONSQUEEZY_WEBHOOK_EVENTS=
|
||||
LEMONSQUEEZY_TEST_MODE=false
|
||||
LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD=
|
||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP=
|
||||
# Paddle Billing
|
||||
PADDLE_SANDBOX=true
|
||||
PADDLE_API_KEY=
|
||||
PADDLE_CLIENT_ID=
|
||||
PADDLE_WEBHOOK_SECRET=
|
||||
PADDLE_PUBLIC_KEY=
|
||||
PADDLE_BASE_URL=
|
||||
PADDLE_CONSOLE_URL=
|
||||
|
||||
# Sanctum / SPA auth
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
139
AGENTS.md
139
AGENTS.md
@@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
|
||||
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
|
||||
|
||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Lemon Squeezy API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
|
||||
- Payment Systems: Lemon Squeezy (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
|
||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
|
||||
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
|
||||
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
||||
|
||||
## Repo Structure (high-level)
|
||||
@@ -38,9 +38,6 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||
- resources/js/pages/ — Inertia pages (React).
|
||||
- docs/archive/README.md — historical PRP context.
|
||||
- Marketing frontend language files:
|
||||
- Source translations: `resources/lang/{de,en}/marketing.php` and `resources/lang/{de,en}/marketing.json`.
|
||||
- Runtime i18next JSON served to the frontend: `public/lang/{de,en}/marketing.json` (must stay in sync with the source files).
|
||||
|
||||
## Standard Workflows
|
||||
- Coding tasks (Codegen Agent):
|
||||
@@ -61,7 +58,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
#### Billing & Packages
|
||||
- package:check-status — check event package status.
|
||||
- packages:migrate-legacy — migrate legacy package purchases.
|
||||
- lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
|
||||
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run).
|
||||
- coupons:export — export coupon redemptions.
|
||||
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
|
||||
|
||||
@@ -96,7 +93,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
|
||||
- inspire — inspiring quote (routes/console.php).
|
||||
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
|
||||
- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
|
||||
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
|
||||
|
||||
## PWA Architecture
|
||||
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
||||
@@ -623,134 +620,6 @@ export default () => (
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
=== filament/filament rules ===
|
||||
|
||||
## Filament
|
||||
|
||||
- Filament is used by this application. Follow existing conventions for how and where it's implemented.
|
||||
- Filament is a Server-Driven UI (SDUI) framework for Laravel that lets you define user interfaces in PHP using structured configuration objects. Built on Livewire, Alpine.js, and Tailwind CSS.
|
||||
- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices.
|
||||
|
||||
### Artisan
|
||||
|
||||
- Use Filament-specific Artisan commands to create files. Find them with `list-artisan-commands` or `php artisan --help`.
|
||||
- Inspect required options and always pass `--no-interaction`.
|
||||
|
||||
### Patterns
|
||||
|
||||
Use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values.
|
||||
|
||||
Use `Get $get` to read other form field values for conditional logic:
|
||||
|
||||
<code-snippet name="Conditional form field" lang="php">
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
Select::make('type')
|
||||
->options(CompanyType::class)
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('company_name')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => $get('type') === 'business'),
|
||||
</code-snippet>
|
||||
|
||||
Use `state()` with a `Closure` to compute derived column values:
|
||||
|
||||
<code-snippet name="Computed table column" lang="php">
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
|
||||
TextColumn::make('full_name')
|
||||
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
|
||||
</code-snippet>
|
||||
|
||||
Actions encapsulate a button with optional modal form and logic:
|
||||
|
||||
<code-snippet name="Action with modal form" lang="php">
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
|
||||
Action::make('updateEmail')
|
||||
->form([
|
||||
TextInput::make('email')->email()->required(),
|
||||
])
|
||||
->action(fn (array $data, User $record): void => $record->update($data)),
|
||||
</code-snippet>
|
||||
|
||||
### Testing
|
||||
|
||||
Authenticate before testing panel functionality. Filament uses Livewire, so use `livewire()` or `Livewire::test()`:
|
||||
|
||||
<code-snippet name="Filament Table Test" lang="php">
|
||||
livewire(ListUsers::class)
|
||||
->assertCanSeeTableRecords($users)
|
||||
->searchTable($users->first()->name)
|
||||
->assertCanSeeTableRecords($users->take(1))
|
||||
->assertCanNotSeeTableRecords($users->skip(1));
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Filament Create Resource Test" lang="php">
|
||||
livewire(CreateUser::class)
|
||||
->fillForm([
|
||||
'name' => 'Test',
|
||||
'email' => 'test@example.com',
|
||||
])
|
||||
->call('create')
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
assertDatabaseHas(User::class, [
|
||||
'name' => 'Test',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Testing Validation" lang="php">
|
||||
livewire(CreateUser::class)
|
||||
->fillForm([
|
||||
'name' => null,
|
||||
'email' => 'invalid-email',
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors([
|
||||
'name' => 'required',
|
||||
'email' => 'email',
|
||||
])
|
||||
->assertNotNotified();
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Calling Actions" lang="php">
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\Testing\TestAction;
|
||||
|
||||
livewire(EditUser::class, ['record' => $user->id])
|
||||
->callAction(DeleteAction::class)
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
livewire(ListUsers::class)
|
||||
->callAction(TestAction::make('promote')->table($user), [
|
||||
'role' => 'admin',
|
||||
])
|
||||
->assertNotified();
|
||||
</code-snippet>
|
||||
|
||||
### Common Mistakes
|
||||
|
||||
**Commonly Incorrect Namespaces:**
|
||||
- Form fields (TextInput, Select, etc.): `Filament\Forms\Components\`
|
||||
- Infolist entries (for read-only views) (TextEntry, IconEntry, etc.): `Filament\Forms\Components\`
|
||||
- Layout components (Grid, Section, Fieldset, Tabs, Wizard, etc.): `Filament\Schemas\Components\`
|
||||
- Schema utilities (Get, Set, etc.): `Filament\Schemas\Components\Utilities\`
|
||||
- Actions: `Filament\Actions\` (no `Filament\Tables\Actions\` etc.)
|
||||
- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`)
|
||||
|
||||
**Recent breaking changes to Filament:**
|
||||
- File visibility is `private` by default. Use `->visibility('public')` for public access.
|
||||
- `Grid`, `Section`, and `Fieldset` no longer span all columns by default.
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
## Issue Tracking
|
||||
|
||||
@@ -100,8 +100,6 @@ COPY . .
|
||||
COPY --from=vendor /var/www/html/vendor ./vendor
|
||||
COPY --from=node_builder /var/www/html/public/build ./public/build
|
||||
|
||||
RUN php artisan vendor:publish --tag=livewire:assets --force --no-interaction
|
||||
|
||||
RUN php artisan config:clear \
|
||||
&& php artisan config:cache \
|
||||
&& php artisan route:clear \
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AiEditOutput;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiEditOutputStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AiEditsBackfillStorageCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-edits:backfill-storage
|
||||
{--request-id= : Restrict backfill to one AI edit request id}
|
||||
{--limit=200 : Maximum outputs to process}
|
||||
{--pretend : Dry run without writing changes}';
|
||||
|
||||
protected $description = 'Backfill local storage paths for AI outputs that only have provider URLs.';
|
||||
|
||||
public function __construct(private readonly AiEditOutputStorageService $outputStorage)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$requestId = $this->normalizeRequestId($this->option('request-id'));
|
||||
$pretend = (bool) $this->option('pretend');
|
||||
|
||||
$query = AiEditOutput::query()
|
||||
->with('request')
|
||||
->whereNotNull('provider_url')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder
|
||||
->whereNull('storage_path')
|
||||
->orWhere('storage_path', '');
|
||||
})
|
||||
->orderBy('id');
|
||||
|
||||
if ($requestId !== null) {
|
||||
$query->where('request_id', $requestId);
|
||||
}
|
||||
|
||||
$candidateCount = (clone $query)->count();
|
||||
$outputs = $query->limit($limit)->get();
|
||||
|
||||
if ($outputs->isEmpty()) {
|
||||
$this->info('No AI outputs require storage backfill.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'AI output backfill candidates: %d (processing up to %d).',
|
||||
$candidateCount,
|
||||
$limit
|
||||
));
|
||||
|
||||
if ($pretend) {
|
||||
$this->table(
|
||||
['Output ID', 'Request ID', 'Provider URL'],
|
||||
$outputs->map(static fn (AiEditOutput $output): array => [
|
||||
(string) $output->id,
|
||||
(string) $output->request_id,
|
||||
(string) $output->provider_url,
|
||||
])->all()
|
||||
);
|
||||
|
||||
$this->info('Pretend mode enabled. No records were changed.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$stored = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($outputs as $output) {
|
||||
$processed++;
|
||||
|
||||
$request = $output->request;
|
||||
if (! $request instanceof AiEditRequest) {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Output %d skipped: missing request relation.', $output->id));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$persisted = $this->outputStorage->persist($request, [
|
||||
'provider_url' => $output->provider_url,
|
||||
'provider_asset_id' => $output->provider_asset_id,
|
||||
'storage_disk' => $output->storage_disk,
|
||||
'storage_path' => $output->storage_path,
|
||||
'mime_type' => $output->mime_type,
|
||||
'width' => $output->width,
|
||||
'height' => $output->height,
|
||||
'bytes' => $output->bytes,
|
||||
'checksum' => $output->checksum,
|
||||
'metadata' => $output->metadata,
|
||||
]);
|
||||
|
||||
$output->forceFill([
|
||||
'provider_url' => $persisted['provider_url'] ?? $output->provider_url,
|
||||
'storage_disk' => $persisted['storage_disk'] ?? $output->storage_disk,
|
||||
'storage_path' => $persisted['storage_path'] ?? $output->storage_path,
|
||||
'mime_type' => $persisted['mime_type'] ?? $output->mime_type,
|
||||
'width' => array_key_exists('width', $persisted) ? $persisted['width'] : $output->width,
|
||||
'height' => array_key_exists('height', $persisted) ? $persisted['height'] : $output->height,
|
||||
'bytes' => array_key_exists('bytes', $persisted) ? $persisted['bytes'] : $output->bytes,
|
||||
'checksum' => $persisted['checksum'] ?? $output->checksum,
|
||||
'metadata' => is_array($persisted['metadata'] ?? null) ? $persisted['metadata'] : $output->metadata,
|
||||
])->save();
|
||||
|
||||
$storagePath = trim((string) ($output->storage_path ?? ''));
|
||||
if ($storagePath !== '') {
|
||||
$stored++;
|
||||
} else {
|
||||
$failed++;
|
||||
$this->warn(sprintf('Output %d could not be persisted locally.', $output->id));
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'AI output backfill complete: processed=%d stored=%d failed=%d.',
|
||||
$processed,
|
||||
$stored,
|
||||
$failed
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalizeRequestId(mixed $value): ?int
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$requestId = (int) $value;
|
||||
|
||||
return $requestId > 0 ? $requestId : null;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiUsageLedger;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AiEditsPruneCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-edits:prune
|
||||
{--request-days= : Override AI request retention days}
|
||||
{--ledger-days= : Override usage ledger retention days}
|
||||
{--pretend : Report counts without deleting data}';
|
||||
|
||||
protected $description = 'Prune stale AI edit requests and usage ledgers based on retention settings.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$requestRetentionDays = max(1, (int) ($this->option('request-days') ?: config('ai-editing.retention.request_days', 90)));
|
||||
$ledgerRetentionDays = max(1, (int) ($this->option('ledger-days') ?: config('ai-editing.retention.usage_ledger_days', 365)));
|
||||
$pretend = (bool) $this->option('pretend');
|
||||
|
||||
$requestCutoff = now()->subDays($requestRetentionDays);
|
||||
$ledgerCutoff = now()->subDays($ledgerRetentionDays);
|
||||
|
||||
$requestQuery = AiEditRequest::query()
|
||||
->where(function (Builder $query) use ($requestCutoff): void {
|
||||
$query->where(function (Builder $completedQuery) use ($requestCutoff): void {
|
||||
$completedQuery->whereNotNull('completed_at')
|
||||
->where('completed_at', '<=', $requestCutoff);
|
||||
})->orWhere(function (Builder $expiredQuery): void {
|
||||
$expiredQuery->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now());
|
||||
});
|
||||
});
|
||||
$ledgerQuery = AiUsageLedger::query()
|
||||
->where('recorded_at', '<=', $ledgerCutoff);
|
||||
|
||||
$requestCount = (clone $requestQuery)->count();
|
||||
$ledgerCount = (clone $ledgerQuery)->count();
|
||||
|
||||
$this->line(sprintf(
|
||||
'AI prune candidates -> requests: %d (<= %s), ledgers: %d (<= %s)',
|
||||
$requestCount,
|
||||
$requestCutoff->toDateString(),
|
||||
$ledgerCount,
|
||||
$ledgerCutoff->toDateString()
|
||||
));
|
||||
|
||||
if ($pretend) {
|
||||
$this->info('Pretend mode enabled. No records were deleted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$deletedRequests = $requestQuery->delete();
|
||||
$deletedLedgers = $ledgerQuery->delete();
|
||||
|
||||
$this->info(sprintf(
|
||||
'Pruned AI data -> requests: %d, ledgers: %d.',
|
||||
$deletedRequests,
|
||||
$deletedLedgers
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\PollAiEditRequest;
|
||||
use App\Jobs\ProcessAiEditRequest;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AiEditsRecoverStuckCommand extends Command
|
||||
{
|
||||
protected $signature = 'ai-edits:recover-stuck
|
||||
{--minutes=30 : Minimum age in minutes for queued/processing requests}
|
||||
{--requeue : Re-dispatch stuck requests back to the queue}
|
||||
{--fail : Mark stuck requests as failed}';
|
||||
|
||||
protected $description = 'Inspect stuck AI edit requests and optionally recover them by requeueing or failing.';
|
||||
|
||||
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$minutes = max(1, (int) $this->option('minutes'));
|
||||
$shouldRequeue = (bool) $this->option('requeue');
|
||||
$shouldFail = (bool) $this->option('fail');
|
||||
|
||||
if ($shouldRequeue && $shouldFail) {
|
||||
$this->error('Use either --requeue or --fail, not both.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$cutoff = now()->subMinutes($minutes);
|
||||
$requests = AiEditRequest::query()
|
||||
->with([
|
||||
'event:id,slug,name',
|
||||
'providerRuns' => function (HasMany $query): void {
|
||||
$query->select(['id', 'request_id', 'provider_task_id', 'attempt'])
|
||||
->orderByDesc('attempt');
|
||||
},
|
||||
])
|
||||
->whereIn('status', [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING])
|
||||
->where(function (Builder $query) use ($cutoff): void {
|
||||
$query
|
||||
->where(function (Builder $queuedQuery) use ($cutoff): void {
|
||||
$queuedQuery->whereNull('started_at')
|
||||
->whereNotNull('queued_at')
|
||||
->where('queued_at', '<=', $cutoff);
|
||||
})
|
||||
->orWhere(function (Builder $processingQuery) use ($cutoff): void {
|
||||
$processingQuery->whereNotNull('started_at')
|
||||
->where('started_at', '<=', $cutoff);
|
||||
})
|
||||
->orWhere(function (Builder $fallbackQuery) use ($cutoff): void {
|
||||
$fallbackQuery->whereNull('queued_at')
|
||||
->whereNull('started_at')
|
||||
->where('updated_at', '<=', $cutoff);
|
||||
});
|
||||
})
|
||||
->orderBy('updated_at')
|
||||
->get();
|
||||
|
||||
if ($requests->isEmpty()) {
|
||||
$this->info(sprintf('No stuck AI edit requests older than %d minute(s).', $minutes));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['ID', 'Event', 'Status', 'Queued/Started', 'Latest task'],
|
||||
$requests->map(function (AiEditRequest $request): array {
|
||||
$latestTaskId = $this->latestProviderTaskId($request) ?? '-';
|
||||
$eventLabel = (string) ($request->event?->name ?: $request->event?->slug ?: $request->event_id);
|
||||
$ageSource = $request->started_at ?: $request->queued_at ?: $request->updated_at;
|
||||
|
||||
return [
|
||||
(string) $request->id,
|
||||
$eventLabel,
|
||||
$request->status,
|
||||
$ageSource?->toIso8601String() ?? '-',
|
||||
$latestTaskId,
|
||||
];
|
||||
})->all()
|
||||
);
|
||||
|
||||
if (! $shouldRequeue && ! $shouldFail) {
|
||||
$this->info('Dry-run only. Use --requeue to dispatch recovery jobs or --fail to terminate stuck requests.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($shouldFail) {
|
||||
$count = $this->markAsFailed($requests);
|
||||
$this->info(sprintf('Marked %d AI edit request(s) as failed.', $count));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
[$processDispatches, $pollDispatches] = $this->requeueRequests($requests);
|
||||
$this->info(sprintf(
|
||||
'Recovered %d stuck AI edit request(s): %d process dispatch(es), %d poll dispatch(es).',
|
||||
$processDispatches + $pollDispatches,
|
||||
$processDispatches,
|
||||
$pollDispatches
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:int}
|
||||
*/
|
||||
private function requeueRequests(Collection $requests): array
|
||||
{
|
||||
$queueName = $this->runtimeConfig->queueName();
|
||||
$processDispatches = 0;
|
||||
$pollDispatches = 0;
|
||||
|
||||
foreach ($requests as $request) {
|
||||
if ($request->status === AiEditRequest::STATUS_QUEUED) {
|
||||
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
|
||||
$processDispatches++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$providerTaskId = $this->latestProviderTaskId($request);
|
||||
if ($providerTaskId !== null) {
|
||||
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)->onQueue($queueName);
|
||||
$pollDispatches++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
|
||||
$processDispatches++;
|
||||
}
|
||||
|
||||
return [$processDispatches, $pollDispatches];
|
||||
}
|
||||
|
||||
private function markAsFailed(Collection $requests): int
|
||||
{
|
||||
$updated = 0;
|
||||
$now = now();
|
||||
|
||||
foreach ($requests as $request) {
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'failure_code' => 'operator_recovery_marked_failed',
|
||||
'failure_message' => 'Marked as failed by ai-edits:recover-stuck.',
|
||||
'completed_at' => $now,
|
||||
])->save();
|
||||
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
private function latestProviderTaskId(AiEditRequest $request): ?string
|
||||
{
|
||||
foreach ($request->providerRuns as $run) {
|
||||
$taskId = trim((string) ($run->provider_task_id ?? ''));
|
||||
if ($taskId !== '') {
|
||||
return $taskId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\LemonSqueezy\LemonSqueezyClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LemonSqueezyRegisterWebhooks extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'lemonsqueezy:webhooks:register
|
||||
{--url= : Destination URL for Lemon Squeezy webhooks}
|
||||
{--events=* : Override event types to subscribe}
|
||||
{--secret= : Override the webhook signing secret}
|
||||
{--test-mode : Register the webhook in test mode}
|
||||
{--dry-run : Output payload without creating the destination}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Register Lemon Squeezy webhook notification settings.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(LemonSqueezyClient $client): int
|
||||
{
|
||||
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
|
||||
|
||||
if ($destination === '') {
|
||||
$this->error('Webhook destination URL is required. Use --url=...');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$events = collect((array) $this->option('events'))
|
||||
->filter()
|
||||
->map(fn ($event) => trim((string) $event))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($events === []) {
|
||||
$events = config('lemonsqueezy.webhook_events', []);
|
||||
}
|
||||
|
||||
if ($events === [] || ! is_array($events)) {
|
||||
$this->error('No webhook events configured. Set config(lemonsqueezy.webhook_events) or pass --events.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$secret = (string) ($this->option('secret') ?: config('lemonsqueezy.webhook_secret'));
|
||||
if ($secret === '') {
|
||||
$this->error('Webhook signing secret is required. Set LEMONSQUEEZY_WEBHOOK_SECRET or pass --secret.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$storeId = (string) config('lemonsqueezy.store_id');
|
||||
if ($storeId === '') {
|
||||
$this->error('Lemon Squeezy store id is required. Set LEMONSQUEEZY_STORE_ID.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$testMode = (bool) $this->option('test-mode') || (bool) config('lemonsqueezy.test_mode', false);
|
||||
|
||||
$attributes = array_filter([
|
||||
'url' => $destination,
|
||||
'events' => $events,
|
||||
'secret' => $secret,
|
||||
'test_mode' => $testMode ? true : null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$payload = [
|
||||
'data' => [
|
||||
'type' => 'webhooks',
|
||||
'attributes' => $attributes,
|
||||
'relationships' => [
|
||||
'store' => [
|
||||
'data' => [
|
||||
'type' => 'stores',
|
||||
'id' => $storeId,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ((bool) $this->option('dry-run')) {
|
||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$response = $client->post('/webhooks', $payload);
|
||||
$data = Arr::get($response, 'data', $response);
|
||||
$id = Arr::get($data, 'id');
|
||||
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy webhook registered', [
|
||||
'webhook_id' => $id,
|
||||
'destination' => $destination,
|
||||
'test_mode' => $testMode,
|
||||
]);
|
||||
|
||||
$this->info('Lemon Squeezy webhook registered.');
|
||||
|
||||
if ($id) {
|
||||
$this->line('ID: '.$id);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function defaultWebhookUrl(): string
|
||||
{
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
|
||||
return $base !== '' ? $base.'/lemonsqueezy/webhook' : '';
|
||||
}
|
||||
}
|
||||
132
app/Console/Commands/PaddleRegisterWebhooks.php
Normal file
132
app/Console/Commands/PaddleRegisterWebhooks.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaddleRegisterWebhooks extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'paddle:webhooks:register
|
||||
{--url= : Destination URL for Paddle webhooks}
|
||||
{--description= : Description for the webhook destination}
|
||||
{--events=* : Override event types to subscribe}
|
||||
{--traffic-source=all : platform|simulation|all}
|
||||
{--include-sensitive : Include sensitive fields in webhook payloads}
|
||||
{--show-secret : Output the endpoint secret key}
|
||||
{--dry-run : Output payload without creating the destination}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Register Paddle webhook notification settings.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(PaddleClient $client): int
|
||||
{
|
||||
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
|
||||
|
||||
if ($destination === '') {
|
||||
$this->error('Webhook destination URL is required. Use --url=...');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$events = collect((array) $this->option('events'))
|
||||
->filter()
|
||||
->map(fn ($event) => trim((string) $event))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($events === []) {
|
||||
$events = config('paddle.webhook_events', []);
|
||||
}
|
||||
|
||||
if ($events === [] || ! is_array($events)) {
|
||||
$this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$trafficSource = (string) $this->option('traffic-source');
|
||||
$allowedSources = ['platform', 'simulation', 'all'];
|
||||
|
||||
if (! in_array($trafficSource, $allowedSources, true)) {
|
||||
$this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources)));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'type' => 'url',
|
||||
'destination' => $destination,
|
||||
'description' => $this->resolveDescription(),
|
||||
'subscribed_events' => $events,
|
||||
'traffic_source' => $trafficSource,
|
||||
'include_sensitive_fields' => (bool) $this->option('include-sensitive'),
|
||||
];
|
||||
|
||||
if ((bool) $this->option('dry-run')) {
|
||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$response = $client->post('/notification-settings', $payload);
|
||||
$data = Arr::get($response, 'data', $response);
|
||||
$id = Arr::get($data, 'id');
|
||||
$secret = Arr::get($data, 'endpoint_secret_key');
|
||||
|
||||
Log::channel('paddle-sync')->info('Paddle webhook registered', [
|
||||
'notification_setting_id' => $id,
|
||||
'destination' => $destination,
|
||||
'traffic_source' => $trafficSource,
|
||||
]);
|
||||
|
||||
$this->info('Paddle webhook registered.');
|
||||
|
||||
if ($id) {
|
||||
$this->line('ID: '.$id);
|
||||
}
|
||||
|
||||
if ($secret && $this->option('show-secret')) {
|
||||
$this->line('Secret: '.$secret);
|
||||
} elseif ($secret) {
|
||||
$this->line('Secret returned (hidden). Use --show-secret to display.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function defaultWebhookUrl(): string
|
||||
{
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
|
||||
return $base !== '' ? $base.'/paddle/webhook' : '';
|
||||
}
|
||||
|
||||
protected function resolveDescription(): string
|
||||
{
|
||||
$description = (string) $this->option('description');
|
||||
|
||||
if ($description !== '') {
|
||||
return $description;
|
||||
}
|
||||
|
||||
$environment = (string) config('paddle.environment', 'production');
|
||||
|
||||
return sprintf('Fotospiel Paddle webhooks (%s)', $environment);
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\PullPackageFromLemonSqueezy;
|
||||
use App\Jobs\SyncPackageToLemonSqueezy;
|
||||
use App\Jobs\PullPackageFromPaddle;
|
||||
use App\Jobs\SyncPackageToPaddle;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LemonSqueezySyncPackages extends Command
|
||||
class PaddleSyncPackages extends Command
|
||||
{
|
||||
protected $signature = 'lemonsqueezy:sync-packages
|
||||
protected $signature = 'paddle:sync-packages
|
||||
{--package=* : Limit sync to the given package IDs or slugs}
|
||||
{--dry-run : Generate payload snapshots without calling Lemon Squeezy}
|
||||
{--pull : Fetch remote Lemon Squeezy state instead of pushing local changes}
|
||||
{--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs}
|
||||
{--dry-run : Generate payload snapshots without calling Paddle}
|
||||
{--pull : Fetch remote Paddle state instead of pushing local changes}
|
||||
{--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs}
|
||||
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
|
||||
|
||||
protected $description = 'Synchronise local packages with Lemon Squeezy products and variants.';
|
||||
protected $description = 'Synchronise local packages with Paddle products and prices.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -52,7 +52,7 @@ class LemonSqueezySyncPackages extends Command
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queued %d package %s for Lemon Squeezy %s.',
|
||||
'Queued %d package %s for Paddle %s.',
|
||||
$packages->count(),
|
||||
Str::plural('entry', $packages->count()),
|
||||
$pull ? 'pull' : 'sync'
|
||||
@@ -97,22 +97,22 @@ class LemonSqueezySyncPackages extends Command
|
||||
|
||||
protected function guardUnmappedPackages(Collection $packages): bool
|
||||
{
|
||||
$unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id));
|
||||
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id));
|
||||
|
||||
if ($unmapped->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.');
|
||||
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.');
|
||||
$this->table(
|
||||
['ID', 'Slug', 'Missing'],
|
||||
$unmapped->map(function (Package $package): array {
|
||||
$missing = [];
|
||||
if (blank($package->lemonsqueezy_product_id)) {
|
||||
if (blank($package->paddle_product_id)) {
|
||||
$missing[] = 'product_id';
|
||||
}
|
||||
if (blank($package->lemonsqueezy_variant_id)) {
|
||||
$missing[] = 'variant_id';
|
||||
if (blank($package->paddle_price_id)) {
|
||||
$missing[] = 'price_id';
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -133,26 +133,26 @@ class LemonSqueezySyncPackages extends Command
|
||||
];
|
||||
|
||||
if ($queue) {
|
||||
SyncPackageToLemonSqueezy::dispatch($package->id, $context);
|
||||
SyncPackageToPaddle::dispatch($package->id, $context);
|
||||
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPackageToLemonSqueezy::dispatchSync($package->id, $context);
|
||||
SyncPackageToPaddle::dispatchSync($package->id, $context);
|
||||
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
|
||||
}
|
||||
|
||||
protected function dispatchPullJob(Package $package, bool $queue): void
|
||||
{
|
||||
if ($queue) {
|
||||
PullPackageFromLemonSqueezy::dispatch($package->id);
|
||||
PullPackageFromPaddle::dispatch($package->id);
|
||||
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
PullPackageFromLemonSqueezy::dispatchSync($package->id);
|
||||
PullPackageFromPaddle::dispatchSync($package->id);
|
||||
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantCheckoutHealths extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantCheckoutHealthResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantPaddleHealths extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantPaddleHealthResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables;
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@@ -13,9 +13,12 @@ use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantCheckoutHealthTable
|
||||
class TenantPaddleHealthTable
|
||||
{
|
||||
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
|
||||
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
@@ -32,6 +35,11 @@ class TenantCheckoutHealthTable
|
||||
->label(__('admin.tenants.fields.contact_email'))
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_customer_id')
|
||||
->label('Paddle customer')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->copyable()
|
||||
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||
TextColumn::make('subscription_status')
|
||||
->label('Subscription')
|
||||
->badge()
|
||||
@@ -48,77 +56,134 @@ class TenantCheckoutHealthTable
|
||||
->badge()
|
||||
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_subscription_id')
|
||||
->label('Paddle subscription')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->copyable()
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id)
|
||||
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||
IconColumn::make('missing_paddle_subscription')
|
||||
->label('Missing Paddle subscription')
|
||||
->boolean()
|
||||
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)),
|
||||
IconColumn::make('status_mismatch')
|
||||
->label('Status mismatch')
|
||||
->boolean()
|
||||
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
||||
TextColumn::make('last_checkout_transaction_at')
|
||||
->label('Last transaction')
|
||||
TextColumn::make('paddle_customer_duplicates')
|
||||
->label('Paddle duplicates')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
|
||||
TextColumn::make('paddle_sync_status')
|
||||
->label('Paddle sync')
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'synced' => 'success',
|
||||
'syncing' => 'warning',
|
||||
'pulled' => 'info',
|
||||
'dry-run' => 'gray',
|
||||
'failed', 'pull-failed' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_synced_at')
|
||||
->label('Paddle synced')
|
||||
->badge()
|
||||
->color(fn ($state) => self::syncAgeColor($state))
|
||||
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at),
|
||||
TextColumn::make('last_paddle_transaction_at')
|
||||
->label('Last Paddle tx')
|
||||
->badge()
|
||||
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
||||
->getStateUsing(fn (Tenant $record) => $record->last_checkout_transaction_at
|
||||
? Carbon::parse($record->last_checkout_transaction_at)
|
||||
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
|
||||
? Carbon::parse($record->last_paddle_transaction_at)
|
||||
: null)
|
||||
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_transaction_count_window')
|
||||
->label('Transactions (30d)')
|
||||
TextColumn::make('paddle_transaction_count_window')
|
||||
->label('Paddle tx (30d)')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('checkout_transaction_total_window')
|
||||
->label('Total (30d)')
|
||||
TextColumn::make('paddle_transaction_total_window')
|
||||
->label('Paddle total (30d)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('checkout_refund_count_window')
|
||||
TextColumn::make('paddle_refund_count_window')
|
||||
->label('Refunds (30d)')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_refund_total_window')
|
||||
TextColumn::make('paddle_refund_total_window')
|
||||
->label('Refund total (30d)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_requires_action_count')
|
||||
TextColumn::make('paddle_checkout_requires_action_count')
|
||||
->label('Checkout action required')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_processing_count')
|
||||
TextColumn::make('paddle_checkout_processing_count')
|
||||
->label('Checkout processing')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_expired_count')
|
||||
TextColumn::make('paddle_checkout_expired_count')
|
||||
->label('Checkout expired')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_transaction_count')
|
||||
->label('Transactions (all)')
|
||||
TextColumn::make('paddle_transaction_count')
|
||||
->label('Paddle tx (all)')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('checkout_transaction_total')
|
||||
->label('Total (all)')
|
||||
TextColumn::make('paddle_transaction_total')
|
||||
->label('Paddle total (all)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Filter::make('missing_paddle_customer')
|
||||
->label('Missing Paddle customer')
|
||||
->indicator('Missing Paddle customer')
|
||||
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')),
|
||||
Filter::make('missing_paddle_subscription')
|
||||
->label('Missing Paddle subscription')
|
||||
->indicator('Missing Paddle subscription')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
|
||||
->where('active', true)
|
||||
->whereNull('paddle_subscription_id'))),
|
||||
Filter::make('duplicate_paddle_customer')
|
||||
->label('Duplicate Paddle customer')
|
||||
->indicator('Duplicate Paddle customer')
|
||||
->query(fn (Builder $query) => $query
|
||||
->whereNotNull('paddle_customer_id')
|
||||
->whereIn('paddle_customer_id', function ($subquery) {
|
||||
$subquery->select('paddle_customer_id')
|
||||
->from('tenants')
|
||||
->whereNotNull('paddle_customer_id')
|
||||
->groupBy('paddle_customer_id')
|
||||
->havingRaw('count(*) > 1');
|
||||
})),
|
||||
Filter::make('status_mismatch')
|
||||
->label('Status mismatch')
|
||||
->indicator('Status mismatch')
|
||||
@@ -140,24 +205,39 @@ class TenantCheckoutHealthTable
|
||||
->where('is_suspended', false)
|
||||
->whereNull('pending_deletion_at')
|
||||
->whereNull('anonymized_at')),
|
||||
Filter::make('checkout_transaction_stale')
|
||||
->label('Stale transactions')
|
||||
->indicator('Stale transactions')
|
||||
Filter::make('paddle_sync_failed')
|
||||
->label('Paddle sync failed')
|
||||
->indicator('Paddle sync failed')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))),
|
||||
Filter::make('paddle_sync_stale')
|
||||
->label('Paddle sync stale')
|
||||
->indicator('Paddle sync stale')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||
->whereNotNull('paddle_synced_at')
|
||||
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))),
|
||||
Filter::make('paddle_sync_missing')
|
||||
->label('Missing Paddle sync timestamp')
|
||||
->indicator('Missing Paddle sync timestamp')
|
||||
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||
->whereNull('paddle_synced_at'))),
|
||||
Filter::make('paddle_transaction_stale')
|
||||
->label('Stale Paddle transactions')
|
||||
->indicator('Stale Paddle transactions')
|
||||
->query(function (Builder $query): Builder {
|
||||
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
$provider = TenantCheckoutHealthResource::provider();
|
||||
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return $query
|
||||
->whereHas('purchases', fn (Builder $query) => $query->where('provider', $provider))
|
||||
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
|
||||
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('provider', 'paddle')
|
||||
->where('purchased_at', '>=', $cutoff));
|
||||
}),
|
||||
Filter::make('checkout_attention')
|
||||
->label('Checkout attention')
|
||||
->indicator('Checkout attention')
|
||||
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
|
||||
$query->where('provider', TenantCheckoutHealthResource::provider())
|
||||
$query->where('provider', 'paddle')
|
||||
->where(function (Builder $query) {
|
||||
$query->whereIn('status', [
|
||||
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
||||
@@ -194,11 +274,10 @@ class TenantCheckoutHealthTable
|
||||
return $query;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
$provider = TenantCheckoutHealthResource::provider();
|
||||
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return $query->whereHas('purchases', fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
||||
}),
|
||||
@@ -235,6 +314,13 @@ class TenantCheckoutHealthTable
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function missingPaddleSubscription(Tenant $record): bool
|
||||
{
|
||||
$package = $record->activeResellerPackage;
|
||||
|
||||
return $package && $package->active && ! $package->paddle_subscription_id;
|
||||
}
|
||||
|
||||
private static function applyStatusMismatchFilter(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
@@ -252,13 +338,26 @@ class TenantCheckoutHealthTable
|
||||
});
|
||||
}
|
||||
|
||||
private static function syncAgeColor($state): string
|
||||
{
|
||||
if (! $state) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}
|
||||
|
||||
private static function transactionAgeColor(?Carbon $state): string
|
||||
{
|
||||
if (! $state) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
if ($state->lt(now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths;
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages\ListTenantCheckoutHealths;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables\TenantCheckoutHealthTable;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use BackedEnum;
|
||||
@@ -13,11 +13,11 @@ use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantCheckoutHealthResource extends Resource
|
||||
class TenantPaddleHealthResource extends Resource
|
||||
{
|
||||
public const TRANSACTION_WINDOW_DAYS = 30;
|
||||
public const STALE_SYNC_DAYS = 30;
|
||||
|
||||
public const DEFAULT_PROVIDER = CheckoutSession::PROVIDER_PAYPAL;
|
||||
public const TRANSACTION_WINDOW_DAYS = 30;
|
||||
|
||||
protected static ?string $model = Tenant::class;
|
||||
|
||||
@@ -25,13 +25,13 @@ class TenantCheckoutHealthResource extends Resource
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static ?string $slug = 'checkout-health';
|
||||
protected static ?string $slug = 'paddle-health';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return TenantCheckoutHealthTable::configure($table);
|
||||
return TenantPaddleHealthTable::configure($table);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
@@ -41,7 +41,7 @@ class TenantCheckoutHealthResource extends Resource
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.checkout_health.navigation.label');
|
||||
return __('admin.paddle_health.navigation.label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
@@ -51,32 +51,37 @@ class TenantCheckoutHealthResource extends Resource
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$provider = static::provider();
|
||||
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['activeResellerPackage.package'])
|
||||
->withExists('activeResellerPackage as has_active_reseller_package')
|
||||
->addSelect([
|
||||
'paddle_customer_duplicates' => Tenant::query()
|
||||
->selectRaw('count(*)')
|
||||
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id')
|
||||
->whereNotNull('paddle_customer_id'),
|
||||
])
|
||||
->withCount([
|
||||
'purchases as checkout_transaction_count' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false),
|
||||
'purchases as checkout_transaction_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
'purchases as checkout_refund_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
'checkoutSessions as checkout_requires_action_count' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
||||
'checkoutSessions as checkout_processing_count' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
->where('status', CheckoutSession::STATUS_PROCESSING),
|
||||
'checkoutSessions as checkout_expired_count' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
->whereNotIn('status', [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
@@ -85,37 +90,32 @@ class TenantCheckoutHealthResource extends Resource
|
||||
->where('expires_at', '<', now()),
|
||||
])
|
||||
->withSum([
|
||||
'purchases as checkout_transaction_total' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false),
|
||||
], 'price')
|
||||
->withSum([
|
||||
'purchases as checkout_transaction_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
], 'price')
|
||||
->withSum([
|
||||
'purchases as checkout_refund_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', $provider)
|
||||
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
], 'price')
|
||||
->withMax([
|
||||
'purchases as last_checkout_transaction_at' => fn (Builder $query) => $query
|
||||
->where('provider', $provider),
|
||||
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle'),
|
||||
], 'purchased_at');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListTenantCheckoutHealths::route('/'),
|
||||
'index' => ListTenantPaddleHealths::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function provider(): string
|
||||
{
|
||||
return (string) config('checkout.default_provider', self::DEFAULT_PROVIDER);
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiStyles;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
|
||||
use App\Models\AiStyle;
|
||||
use App\Services\AiEditing\RunwareModelSearchService;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class AiStyleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = AiStyle::class;
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 31;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'AI Styles';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Style Basics')
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->required()
|
||||
->maxLength(120)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(120),
|
||||
TextInput::make('version')
|
||||
->numeric()
|
||||
->default(1)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Auto-increments when core style configuration changes.'),
|
||||
TextInput::make('category')
|
||||
->maxLength(50),
|
||||
TextInput::make('sort')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required(),
|
||||
Toggle::make('is_active')
|
||||
->default(true),
|
||||
Toggle::make('is_premium')
|
||||
->default(false),
|
||||
Toggle::make('requires_source_image')
|
||||
->default(true),
|
||||
])
|
||||
->columns(3),
|
||||
Section::make('Provider Binding')
|
||||
->schema([
|
||||
Select::make('provider')
|
||||
->options([
|
||||
'runware' => 'runware.ai',
|
||||
])
|
||||
->required()
|
||||
->default('runware'),
|
||||
Select::make('provider_model')
|
||||
->label('Runware model (AIR)')
|
||||
->searchable()
|
||||
->getSearchResultsUsing(static fn (string $search): array => app(RunwareModelSearchService::class)->searchOptions($search))
|
||||
->getOptionLabelUsing(static fn (mixed $value): ?string => app(RunwareModelSearchService::class)->labelForModel($value))
|
||||
->helperText('Start typing to search models from runware.ai.')
|
||||
->native(false)
|
||||
->live()
|
||||
->afterStateUpdated(static function (Set $set, ?string $state): void {
|
||||
self::applySelectedRunwareModel($set, $state);
|
||||
}),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Runware Generation')
|
||||
->schema([
|
||||
TextInput::make('metadata.runware.generation.width')
|
||||
->label('Width')
|
||||
->numeric()
|
||||
->minValue(64)
|
||||
->maxValue(4096)
|
||||
->step(64)
|
||||
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'width')),
|
||||
TextInput::make('metadata.runware.generation.height')
|
||||
->label('Height')
|
||||
->numeric()
|
||||
->minValue(64)
|
||||
->maxValue(4096)
|
||||
->step(64)
|
||||
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'height')),
|
||||
TextInput::make('metadata.runware.generation.steps')
|
||||
->label('Steps')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(150)
|
||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'steps')),
|
||||
TextInput::make('metadata.runware.generation.cfg_scale')
|
||||
->label('CFG Scale')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(30)
|
||||
->step(0.1)
|
||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'cfg_scale')),
|
||||
TextInput::make('metadata.runware.generation.strength')
|
||||
->label('Strength')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(1)
|
||||
->step(0.01)
|
||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'strength')),
|
||||
Select::make('metadata.runware.generation.output_format')
|
||||
->label('Output format')
|
||||
->options([
|
||||
'JPG' => 'JPG',
|
||||
'PNG' => 'PNG',
|
||||
'WEBP' => 'WEBP',
|
||||
])
|
||||
->default('JPG')
|
||||
->native(false),
|
||||
Select::make('metadata.runware.generation.delivery_method')
|
||||
->label('Delivery method')
|
||||
->options([
|
||||
'async' => 'async (queue + poll)',
|
||||
'sync' => 'sync',
|
||||
])
|
||||
->default('async')
|
||||
->native(false),
|
||||
])
|
||||
->columns(3),
|
||||
Section::make('Prompts')
|
||||
->schema([
|
||||
Textarea::make('description')
|
||||
->rows(2),
|
||||
Textarea::make('prompt_template')
|
||||
->rows(5),
|
||||
Textarea::make('negative_prompt_template')
|
||||
->rows(4),
|
||||
]),
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
KeyValue::make('metadata')
|
||||
->nullable(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('sort')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('key')
|
||||
->searchable()
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('version')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('provider')
|
||||
->badge(),
|
||||
Tables\Columns\TextColumn::make('provider_model')
|
||||
->toggleable(),
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->boolean(),
|
||||
Tables\Columns\IconColumn::make('is_premium')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('sort')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->since()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active'),
|
||||
Tables\Filters\TernaryFilter::make('is_premium'),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make()
|
||||
->after(fn (array $data, AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
|
||||
static::class
|
||||
)),
|
||||
Actions\DeleteAction::make()
|
||||
->after(fn (AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
)),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ManageAiStyles::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function applySelectedRunwareModel(Set $set, ?string $air): void
|
||||
{
|
||||
if (! is_string($air) || trim($air) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$model = app(RunwareModelSearchService::class)->findByAir($air);
|
||||
if (! is_array($model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$set('metadata.runware.model.air', $model['air']);
|
||||
$set('metadata.runware.model.name', $model['name']);
|
||||
$set('metadata.runware.model.architecture', $model['architecture']);
|
||||
$set('metadata.runware.model.category', $model['category']);
|
||||
|
||||
foreach ((array) ($model['constraints'] ?? []) as $key => $value) {
|
||||
$set("metadata.runware.constraints.{$key}", $value);
|
||||
}
|
||||
|
||||
self::setIfNumeric($set, 'metadata.runware.generation.width', $model['defaults']['width'] ?? null);
|
||||
self::setIfNumeric($set, 'metadata.runware.generation.height', $model['defaults']['height'] ?? null);
|
||||
self::setIfNumeric($set, 'metadata.runware.generation.steps', $model['defaults']['steps'] ?? null);
|
||||
self::setIfNumeric($set, 'metadata.runware.generation.cfg_scale', $model['defaults']['cfg_scale'] ?? null);
|
||||
}
|
||||
|
||||
private static function setIfNumeric(Set $set, string $path, mixed $value): void
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
$set($path, $value);
|
||||
}
|
||||
}
|
||||
|
||||
private static function dimensionConstraintHint(Get $get, string $dimension): ?string
|
||||
{
|
||||
$min = $get("metadata.runware.constraints.min_{$dimension}");
|
||||
$max = $get("metadata.runware.constraints.max_{$dimension}");
|
||||
$step = $get("metadata.runware.constraints.{$dimension}_step");
|
||||
|
||||
if (! is_numeric($min) && ! is_numeric($max) && ! is_numeric($step)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if (is_numeric($min) || is_numeric($max)) {
|
||||
$parts[] = sprintf(
|
||||
'Model range: %s - %s',
|
||||
is_numeric($min) ? (string) (int) $min : '?',
|
||||
is_numeric($max) ? (string) (int) $max : '?'
|
||||
);
|
||||
}
|
||||
|
||||
if (is_numeric($step) && (int) $step > 0) {
|
||||
$parts[] = sprintf('Step: %d', (int) $step);
|
||||
}
|
||||
|
||||
return $parts !== [] ? implode(' | ', $parts) : null;
|
||||
}
|
||||
|
||||
private static function rangeConstraintHint(Get $get, string $field): ?string
|
||||
{
|
||||
$min = $get("metadata.runware.constraints.min_{$field}");
|
||||
$max = $get("metadata.runware.constraints.max_{$field}");
|
||||
|
||||
if (! is_numeric($min) && ! is_numeric($max)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'Model range: %s - %s',
|
||||
is_numeric($min) ? trim((string) $min) : '?',
|
||||
is_numeric($max) ? trim((string) $max) : '?'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\AiStyles\Pages;
|
||||
|
||||
use App\Filament\Resources\AiStyles\AiStyleResource;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ManageAiStyles extends ManageRecords
|
||||
{
|
||||
protected static string $resource = AiStyleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'created',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
|
||||
static::class
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
|
||||
class CreateCoupon extends AuditedCreateRecord
|
||||
{
|
||||
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
|
||||
{
|
||||
parent::afterCreate();
|
||||
|
||||
SyncCouponToLemonSqueezy::dispatch($this->record);
|
||||
SyncCouponToPaddle::dispatch($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
|
||||
source: static::class
|
||||
);
|
||||
|
||||
SyncCouponToLemonSqueezy::dispatch($record, true);
|
||||
SyncCouponToPaddle::dispatch($record, true);
|
||||
}),
|
||||
ForceDeleteAction::make()
|
||||
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
|
||||
{
|
||||
parent::afterSave();
|
||||
|
||||
SyncCouponToLemonSqueezy::dispatch($this->record);
|
||||
SyncCouponToPaddle::dispatch($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class RedemptionsRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('lemonsqueezy_order_id')
|
||||
->recordTitleAttribute('paddle_transaction_id')
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('Tenant'))
|
||||
@@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
|
||||
'failed' => 'danger',
|
||||
default => 'warning',
|
||||
}),
|
||||
TextColumn::make('lemonsqueezy_order_id')
|
||||
TextColumn::make('paddle_transaction_id')
|
||||
->label(__('Transaction'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
@@ -123,22 +123,22 @@ class CouponForm
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make(__('Lemon Squeezy sync'))
|
||||
Section::make(__('Paddle sync'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('lemonsqueezy_mode')
|
||||
->label(__('Lemon Squeezy mode'))
|
||||
Select::make('paddle_mode')
|
||||
->label(__('Paddle mode'))
|
||||
->options([
|
||||
'standard' => __('Standard'),
|
||||
'custom' => __('Custom (one-off)'),
|
||||
])
|
||||
->default('standard'),
|
||||
Placeholder::make('lemonsqueezy_discount_id')
|
||||
->label(__('Lemon Squeezy Discount ID'))
|
||||
->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'),
|
||||
Placeholder::make('lemonsqueezy_last_synced_at')
|
||||
Placeholder::make('paddle_discount_id')
|
||||
->label(__('Paddle Discount ID'))
|
||||
->content(fn ($record) => $record?->paddle_discount_id ?? '—'),
|
||||
Placeholder::make('paddle_last_synced_at')
|
||||
->label(__('Last synced'))
|
||||
->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
||||
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||
Placeholder::make('redemptions_count')
|
||||
->label(__('Total redemptions'))
|
||||
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
|
||||
|
||||
@@ -63,17 +63,17 @@ class CouponInfolist
|
||||
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
|
||||
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
|
||||
]),
|
||||
Section::make(__('Lemon Squeezy'))
|
||||
Section::make(__('Paddle'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('lemonsqueezy_discount_id')
|
||||
TextEntry::make('paddle_discount_id')
|
||||
->label(__('Discount ID'))
|
||||
->copyable()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('lemonsqueezy_last_synced_at')
|
||||
TextEntry::make('paddle_last_synced_at')
|
||||
->label(__('Last synced'))
|
||||
->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
||||
TextEntry::make('lemonsqueezy_mode')
|
||||
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||
TextEntry::make('paddle_mode')
|
||||
->label(__('Mode'))
|
||||
->badge()
|
||||
->placeholder('standard'),
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
@@ -105,9 +105,9 @@ class CouponsTable
|
||||
static::class
|
||||
)),
|
||||
Action::make('sync')
|
||||
->label(__('Sync to Lemon Squeezy'))
|
||||
->label(__('Sync to Paddle'))
|
||||
->icon('heroicon-m-arrow-path')
|
||||
->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record))
|
||||
->action(fn ($record) => SyncCouponToPaddle::dispatch($record))
|
||||
->requiresConfirmation(),
|
||||
])
|
||||
->toolbarActions([
|
||||
|
||||
@@ -6,29 +6,24 @@ use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Resources\EventResource\Pages;
|
||||
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use BackedEnum;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use UnitEnum;
|
||||
|
||||
class EventResource extends Resource
|
||||
@@ -98,10 +93,6 @@ class EventResource extends Resource
|
||||
Toggle::make('is_active')
|
||||
->label(__('admin.events.fields.is_active'))
|
||||
->default(true),
|
||||
Toggle::make('settings.marketing_demo')
|
||||
->label(__('admin.events.fields.marketing_demo'))
|
||||
->helperText(__('admin.events.fields.marketing_demo_help'))
|
||||
->default(false),
|
||||
KeyValue::make('settings')
|
||||
->label(__('admin.events.fields.settings'))
|
||||
->keyLabel(__('admin.common.key'))
|
||||
@@ -173,161 +164,7 @@ class EventResource extends Resource
|
||||
->modalHeading(__('admin.events.modal.join_link_heading'))
|
||||
->modalSubmitActionLabel(__('admin.common.close'))
|
||||
->modalWidth('xl')
|
||||
->registerModalActions([
|
||||
Actions\Action::make('extend_join_token_expiry')
|
||||
->label(__('admin.events.join_link.extend_expiry'))
|
||||
->icon('heroicon-o-clock')
|
||||
->color('warning')
|
||||
->size('xs')
|
||||
->modalHeading(function (Actions\Action $action, Event $record): string {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
return $token
|
||||
? __('admin.events.join_link.extend_expiry_heading', [
|
||||
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
|
||||
])
|
||||
: __('admin.events.join_link.extend_expiry_heading_fallback');
|
||||
})
|
||||
->schema(function (Event $record): array {
|
||||
$minimumExpiry = app(EventJoinTokenService::class)->minimumExpiryForEvent($record);
|
||||
$rules = [
|
||||
'date',
|
||||
'after:now',
|
||||
];
|
||||
|
||||
if ($minimumExpiry) {
|
||||
$rules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
|
||||
}
|
||||
|
||||
return [
|
||||
DateTimePicker::make('expires_at')
|
||||
->label(__('admin.events.join_link.extend_expiry_label'))
|
||||
->required()
|
||||
->seconds(false)
|
||||
->rules($rules)
|
||||
->helperText($minimumExpiry
|
||||
? __('admin.events.join_link.extend_expiry_min', [
|
||||
'date' => $minimumExpiry->isoFormat('LLL'),
|
||||
])
|
||||
: null),
|
||||
];
|
||||
})
|
||||
->fillForm(function (Actions\Action $action, Event $record): array {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
if (! $token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'expires_at' => $token->expires_at,
|
||||
];
|
||||
})
|
||||
->action(function (array $data, Actions\Action $action, Event $record): void {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
if (! $token) {
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.extend_expiry_missing'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$expiresAt = $data['expires_at'] ?? null;
|
||||
|
||||
if (! $expiresAt) {
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.extend_expiry_missing_date'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolvedExpiry = $expiresAt instanceof Carbon
|
||||
? $expiresAt
|
||||
: Carbon::parse($expiresAt);
|
||||
|
||||
$token->forceFill([
|
||||
'expires_at' => $resolvedExpiry,
|
||||
])->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$token,
|
||||
source: static::class
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.extend_expiry_success'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('set_demo_read_only')
|
||||
->label(__('admin.events.join_link.demo_read_only_action'))
|
||||
->icon('heroicon-o-lock-closed')
|
||||
->color('gray')
|
||||
->size('xs')
|
||||
->modalHeading(function (Actions\Action $action, Event $record): string {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
return $token
|
||||
? __('admin.events.join_link.demo_read_only_heading', [
|
||||
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
|
||||
])
|
||||
: __('admin.events.join_link.demo_read_only_heading_fallback');
|
||||
})
|
||||
->schema([
|
||||
Toggle::make('demo_read_only')
|
||||
->label(__('admin.events.join_link.demo_read_only_label'))
|
||||
->helperText(__('admin.events.join_link.demo_read_only_help')),
|
||||
])
|
||||
->fillForm(function (Actions\Action $action, Event $record): array {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
return [
|
||||
'demo_read_only' => (bool) Arr::get($token?->metadata ?? [], 'demo_read_only', false),
|
||||
];
|
||||
})
|
||||
->action(function (array $data, Actions\Action $action, Event $record): void {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
if (! $token) {
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.demo_read_only_missing'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = is_array($token->metadata) ? $token->metadata : [];
|
||||
$enabled = (bool) ($data['demo_read_only'] ?? false);
|
||||
|
||||
if ($enabled) {
|
||||
$metadata['demo_read_only'] = true;
|
||||
} else {
|
||||
unset($metadata['demo_read_only']);
|
||||
}
|
||||
|
||||
$token->metadata = empty($metadata) ? null : $metadata;
|
||||
$token->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$token,
|
||||
source: static::class
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.demo_read_only_success'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->modalContent(function (Actions\Action $action, $record) {
|
||||
->modalContent(function ($record) {
|
||||
$tokens = $record->joinTokens()
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
@@ -397,7 +234,6 @@ class EventResource extends Resource
|
||||
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
||||
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
||||
'is_active' => $token->isActive(),
|
||||
'demo_read_only' => (bool) Arr::get($token->metadata ?? [], 'demo_read_only', false),
|
||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
||||
'layouts' => $layouts,
|
||||
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
||||
@@ -417,7 +253,6 @@ class EventResource extends Resource
|
||||
return view('filament.events.join-link', [
|
||||
'event' => $record,
|
||||
'tokens' => $tokens,
|
||||
'action' => $action,
|
||||
]);
|
||||
}),
|
||||
])
|
||||
@@ -468,19 +303,6 @@ class EventResource extends Resource
|
||||
return is_string($name) ? $name : '';
|
||||
}
|
||||
|
||||
private static function resolveJoinTokenFromAction(Event $record, Actions\Action $action): ?EventJoinToken
|
||||
{
|
||||
$tokenId = $action->getArguments()['token_id'] ?? null;
|
||||
|
||||
if (! $tokenId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $record->joinTokens()
|
||||
->whereKey($tokenId)
|
||||
->first();
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource
|
||||
->label('Empfänger')
|
||||
->toggleable()
|
||||
->searchable(),
|
||||
TextColumn::make('lemonsqueezy_order_id')
|
||||
->label('Lemon Squeezy Order')
|
||||
TextColumn::make('paddle_transaction_id')
|
||||
->label('Paddle Tx')
|
||||
->toggleable()
|
||||
->copyable()
|
||||
->wrap(),
|
||||
|
||||
@@ -46,27 +46,24 @@ class ListGiftVouchers extends ListRecords
|
||||
])
|
||||
->action(function (array $data, GiftVoucherService $service): void {
|
||||
$payload = [
|
||||
'meta' => [
|
||||
'custom_data' => [
|
||||
'type' => 'gift_voucher',
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
'recipient_email' => $data['recipient_email'] ?? null,
|
||||
'recipient_name' => $data['recipient_name'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'gift_code' => $data['code'] ?? null,
|
||||
],
|
||||
'id' => null,
|
||||
'metadata' => [
|
||||
'type' => 'gift_voucher',
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
'recipient_email' => $data['recipient_email'] ?? null,
|
||||
'recipient_name' => $data['recipient_name'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'gift_code' => $data['code'] ?? null,
|
||||
],
|
||||
'data' => [
|
||||
'id' => 'manual_'.Str::uuid(),
|
||||
'attributes' => [
|
||||
'currency' => $data['currency'] ?? 'EUR',
|
||||
'total' => (float) $data['amount'] * 100,
|
||||
'user_email' => $data['purchaser_email'],
|
||||
'currency_code' => $data['currency'] ?? 'EUR',
|
||||
'totals' => [
|
||||
'grand_total' => [
|
||||
'amount' => (float) $data['amount'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$voucher = $service->issueFromLemonSqueezy($payload);
|
||||
$voucher = $service->issueFromPaddle($payload);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'issued',
|
||||
|
||||
@@ -4,21 +4,15 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||
use App\Filament\Resources\PackageAddonResource\Pages;
|
||||
use App\Jobs\SyncPackageAddonToLemonSqueezy;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Jobs\SyncPackageAddonToPaddle;
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
@@ -56,18 +50,10 @@ class PackageAddonResource extends Resource
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(191),
|
||||
TextInput::make('variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
|
||||
->required(fn (Get $get): bool => (bool) $get('active') && ! is_numeric($get('metadata.price_eur')))
|
||||
TextInput::make('price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->helperText('Paddle Billing Preis-ID für dieses Add-on')
|
||||
->maxLength(191),
|
||||
TextInput::make('metadata.price_eur')
|
||||
->label('PayPal Preis (EUR)')
|
||||
->helperText('Für PayPal-Checkout erforderlich (z. B. 9.90).')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->minValue(0.01)
|
||||
->required(fn (Get $get): bool => (bool) $get('active') && blank($get('variant_id'))),
|
||||
TextInput::make('sort')
|
||||
->label('Sortierung')
|
||||
->numeric()
|
||||
@@ -75,23 +61,6 @@ class PackageAddonResource extends Resource
|
||||
Toggle::make('active')
|
||||
->label('Aktiv')
|
||||
->default(true),
|
||||
Placeholder::make('sellable_state')
|
||||
->label('Verfügbarkeits-Check')
|
||||
->content(function (Get $get): string {
|
||||
$isActive = (bool) $get('active');
|
||||
$hasVariant = filled($get('variant_id'));
|
||||
$hasPayPalPrice = is_numeric($get('metadata.price_eur'));
|
||||
|
||||
if (! $isActive) {
|
||||
return 'Inaktiv';
|
||||
}
|
||||
|
||||
if (! $hasVariant && ! $hasPayPalPrice) {
|
||||
return 'Nicht verkäuflich: Variant-ID oder PayPal Preis fehlt.';
|
||||
}
|
||||
|
||||
return 'Verkäuflich';
|
||||
}),
|
||||
]),
|
||||
Section::make('Limits-Inkremente')
|
||||
->columns(3)
|
||||
@@ -112,30 +81,6 @@ class PackageAddonResource extends Resource
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
]),
|
||||
Section::make('Feature-Entitlements')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('metadata.scope')
|
||||
->label('Scope')
|
||||
->options([
|
||||
'photos' => 'Fotos',
|
||||
'guests' => 'Gäste',
|
||||
'gallery' => 'Galerie',
|
||||
'feature' => 'Feature',
|
||||
'bundle' => 'Bundle',
|
||||
])
|
||||
->native(false)
|
||||
->searchable(),
|
||||
TagsInput::make('metadata.entitlements.features')
|
||||
->label('Freigeschaltete Features')
|
||||
->helperText('Feature-Keys für Freischaltungen, z. B. ai_styling')
|
||||
->placeholder('z. B. ai_styling')
|
||||
->columnSpanFull(),
|
||||
DateTimePicker::make('metadata.entitlements.expires_at')
|
||||
->label('Entitlement gültig bis')
|
||||
->seconds(false)
|
||||
->nullable(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -151,33 +96,10 @@ class PackageAddonResource extends Resource
|
||||
->label('Schlüssel')
|
||||
->copyable()
|
||||
->sortable(),
|
||||
TextColumn::make('variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
TextColumn::make('price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->toggleable()
|
||||
->copyable(),
|
||||
TextColumn::make('metadata.price_eur')
|
||||
->label('PayPal Preis (EUR)')
|
||||
->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? number_format((float) $state, 2, ',', '.').' €' : '—')
|
||||
->toggleable(),
|
||||
TextColumn::make('metadata.scope')
|
||||
->label('Scope')
|
||||
->badge()
|
||||
->toggleable(),
|
||||
TextColumn::make('metadata.entitlements.features')
|
||||
->label('Features')
|
||||
->formatStateUsing(function (mixed $state): string {
|
||||
if (! is_array($state)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$features = array_values(array_filter(array_map(
|
||||
static fn (mixed $feature): string => trim((string) $feature),
|
||||
$state,
|
||||
)));
|
||||
|
||||
return $features === [] ? '—' : implode(', ', $features);
|
||||
})
|
||||
->toggleable(),
|
||||
TextColumn::make('extra_photos')->label('Fotos +'),
|
||||
TextColumn::make('extra_guests')->label('Gäste +'),
|
||||
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
|
||||
@@ -188,14 +110,6 @@ class PackageAddonResource extends Resource
|
||||
'danger' => false,
|
||||
])
|
||||
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
|
||||
BadgeColumn::make('sellability')
|
||||
->label('Checkout')
|
||||
->state(fn (PackageAddon $record): string => static::sellabilityLabel($record))
|
||||
->colors([
|
||||
'success' => fn (string $state): bool => $state === 'Verkäuflich',
|
||||
'warning' => fn (string $state): bool => $state === 'Unvollständig',
|
||||
'gray' => fn (string $state): bool => $state === 'Inaktiv',
|
||||
]),
|
||||
TextColumn::make('sort')
|
||||
->label('Sort')
|
||||
->sortable()
|
||||
@@ -206,16 +120,16 @@ class PackageAddonResource extends Resource
|
||||
->label('Aktiv'),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('syncLemonSqueezy')
|
||||
->label('Mit Lemon Squeezy synchronisieren')
|
||||
Actions\Action::make('syncPaddle')
|
||||
->label('Mit Paddle synchronisieren')
|
||||
->icon('heroicon-o-cloud-arrow-up')
|
||||
->action(function (PackageAddon $record) {
|
||||
SyncPackageAddonToLemonSqueezy::dispatch($record->id);
|
||||
SyncPackageAddonToPaddle::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Lemon Squeezy-Sync gestartet')
|
||||
->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
|
||||
->title('Paddle-Sync gestartet')
|
||||
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make()
|
||||
@@ -252,21 +166,4 @@ class PackageAddonResource extends Resource
|
||||
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function sellabilityLabel(PackageAddon $record): string
|
||||
{
|
||||
if (! $record->active) {
|
||||
return 'Inaktiv';
|
||||
}
|
||||
|
||||
return $record->isSellableForProvider(static::addonProvider()) ? 'Verkäuflich' : 'Unvollständig';
|
||||
}
|
||||
|
||||
protected static function addonProvider(): string
|
||||
{
|
||||
return (string) (
|
||||
config('package-addons.provider')
|
||||
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||
use App\Filament\Resources\PackageResource\Pages;
|
||||
use App\Jobs\PullPackageFromPaddle;
|
||||
use App\Jobs\SyncPackageToPaddle;
|
||||
use App\Models\Package;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
@@ -23,6 +26,7 @@ use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
||||
@@ -168,31 +172,31 @@ class PackageResource extends Resource
|
||||
->columnSpanFull()
|
||||
->default([]),
|
||||
]),
|
||||
Section::make('Lemon Squeezy Billing')
|
||||
Section::make('Paddle Billing')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('lemonsqueezy_product_id')
|
||||
->label('Lemon Squeezy Produkt-ID')
|
||||
TextInput::make('paddle_product_id')
|
||||
->label('Paddle Produkt-ID')
|
||||
->maxLength(191)
|
||||
->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
|
||||
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
|
||||
->placeholder('nicht verknüpft'),
|
||||
TextInput::make('lemonsqueezy_variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
TextInput::make('paddle_price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->maxLength(191)
|
||||
->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
|
||||
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
|
||||
->placeholder('nicht verknüpft'),
|
||||
Placeholder::make('lemonsqueezy_sync_status')
|
||||
Placeholder::make('paddle_sync_status')
|
||||
->label('Sync-Status')
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '–')
|
||||
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–')
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('lemonsqueezy_synced_at')
|
||||
Placeholder::make('paddle_synced_at')
|
||||
->label('Zuletzt synchronisiert')
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '–')
|
||||
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('lemonsqueezy_sync_error')
|
||||
Placeholder::make('paddle_sync_error')
|
||||
->label('Letzter Fehler')
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '–')
|
||||
->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
|
||||
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '–')
|
||||
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
@@ -259,15 +263,15 @@ class PackageResource extends Resource
|
||||
->label('Features')
|
||||
->wrap()
|
||||
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
||||
TextColumn::make('lemonsqueezy_product_id')
|
||||
->label('Lemon Squeezy Produkt')
|
||||
TextColumn::make('paddle_product_id')
|
||||
->label('Paddle Produkt')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
TextColumn::make('lemonsqueezy_variant_id')
|
||||
->label('Lemon Squeezy Variant')
|
||||
TextColumn::make('paddle_price_id')
|
||||
->label('Paddle Preis')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
BadgeColumn::make('lemonsqueezy_sync_status')
|
||||
BadgeColumn::make('paddle_sync_status')
|
||||
->label('Sync-Status')
|
||||
->colors([
|
||||
'success' => 'synced',
|
||||
@@ -277,13 +281,13 @@ class PackageResource extends Resource
|
||||
])
|
||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('lemonsqueezy_synced_at')
|
||||
TextColumn::make('paddle_synced_at')
|
||||
->label('Sync am')
|
||||
->dateTime()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('lemonsqueezy_sync_error_message')
|
||||
TextColumn::make('paddle_sync_error_message')
|
||||
->label('Sync-Fehler')
|
||||
->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
|
||||
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
|
||||
->wrap()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
@@ -297,6 +301,71 @@ class PackageResource extends Resource
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('syncPaddle')
|
||||
->label('Mit Paddle abgleichen')
|
||||
->icon('heroicon-o-cloud-arrow-up')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing')
|
||||
->action(function (Package $record) {
|
||||
SyncPackageToPaddle::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Sync gestartet')
|
||||
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('linkPaddle')
|
||||
->label('Paddle verknüpfen')
|
||||
->icon('heroicon-o-link')
|
||||
->color('info')
|
||||
->form([
|
||||
TextInput::make('paddle_product_id')
|
||||
->label('Paddle Produkt-ID')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
TextInput::make('paddle_price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
])
|
||||
->fillForm(fn (Package $record) => [
|
||||
'paddle_product_id' => $record->paddle_product_id,
|
||||
'paddle_price_id' => $record->paddle_price_id,
|
||||
])
|
||||
->action(function (Package $record, array $data): void {
|
||||
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']);
|
||||
|
||||
PullPackageFromPaddle::dispatch($record->id);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'linked',
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||
static::class
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Verknüpfung gespeichert')
|
||||
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('pullPaddle')
|
||||
->label('Status von Paddle holen')
|
||||
->icon('heroicon-o-cloud-arrow-down')
|
||||
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id)
|
||||
->requiresConfirmation()
|
||||
->action(function (Package $record) {
|
||||
PullPackageFromPaddle::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->info()
|
||||
->title('Paddle-Abgleich angefordert')
|
||||
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.')
|
||||
->send();
|
||||
}),
|
||||
ViewAction::make(),
|
||||
EditAction::make()
|
||||
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
@@ -396,7 +465,6 @@ class PackageResource extends Resource
|
||||
'unlimited_sharing' => 'Unbegrenztes Teilen',
|
||||
'no_watermark' => 'Kein Wasserzeichen',
|
||||
'custom_branding' => 'Eigenes Branding',
|
||||
'ai_styling' => 'AI-Styling',
|
||||
'custom_tasks' => 'Eigene Aufgaben',
|
||||
'reseller_dashboard' => 'Reseller-Dashboard',
|
||||
'advanced_analytics' => 'Erweiterte Analytics',
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Models\PackagePurchase;
|
||||
use App\Notifications\Customer\RefundReceipt;
|
||||
use App\Notifications\Ops\RefundProcessed;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
@@ -203,15 +203,15 @@ class PurchaseResource extends Resource
|
||||
$refundSuccess = true;
|
||||
$errorMessage = null;
|
||||
|
||||
if ($record->provider === 'lemonsqueezy' && $record->provider_id) {
|
||||
if ($record->provider === 'paddle' && $record->provider_id) {
|
||||
try {
|
||||
/** @var LemonSqueezyOrderService $lemonsqueezy */
|
||||
$lemonsqueezy = App::make(LemonSqueezyOrderService::class);
|
||||
$lemonsqueezy->refund($record->provider_id, ['reason' => $reason]);
|
||||
/** @var PaddleTransactionService $paddle */
|
||||
$paddle = App::make(PaddleTransactionService::class);
|
||||
$paddle->refund($record->provider_id, ['reason' => $reason]);
|
||||
} catch (\Throwable $exception) {
|
||||
$refundSuccess = false;
|
||||
$errorMessage = $exception->getMessage();
|
||||
Log::warning('Lemon Squeezy refund failed', [
|
||||
Log::warning('Paddle refund failed', [
|
||||
'purchase_id' => $record->id,
|
||||
'provider_id' => $record->provider_id,
|
||||
'error' => $exception->getMessage(),
|
||||
|
||||
@@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord
|
||||
->visible(fn ($record): bool => ! $record->refunded)
|
||||
->action(function ($record) {
|
||||
$record->update(['refunded' => true]);
|
||||
// TODO: Call Lemon Squeezy API for actual refund
|
||||
// TODO: Call Paddle API for actual refund
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'purchase.refunded',
|
||||
|
||||
@@ -56,7 +56,7 @@ class TenantFeedbackResource extends Resource
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.infrastructure');
|
||||
return __('admin.nav.feedback_support');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
|
||||
@@ -73,10 +73,10 @@ class TenantResource extends Resource
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer ID')
|
||||
TextInput::make('paddle_customer_id')
|
||||
->label('Paddle Customer ID')
|
||||
->maxLength(191)
|
||||
->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
|
||||
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
|
||||
->nullable(),
|
||||
TextInput::make('total_revenue')
|
||||
->label(__('admin.tenants.fields.total_revenue'))
|
||||
@@ -135,8 +135,8 @@ class TenantResource extends Resource
|
||||
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||
Tables\Columns\TextColumn::make('contact_email'),
|
||||
Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer')
|
||||
Tables\Columns\TextColumn::make('paddle_customer_id')
|
||||
->label('Paddle Customer')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
||||
|
||||
@@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
Select::make('provider')
|
||||
->label('Anbieter')
|
||||
->options([
|
||||
'lemonsqueezy' => 'Lemon Squeezy',
|
||||
'paddle' => 'Paddle',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
])
|
||||
@@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
TextColumn::make('provider')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'lemonsqueezy' => 'success',
|
||||
'paddle' => 'success',
|
||||
'manual' => 'gray',
|
||||
'free' => 'success',
|
||||
default => 'gray',
|
||||
@@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
]),
|
||||
SelectFilter::make('provider')
|
||||
->options([
|
||||
'lemonsqueezy' => 'Lemon Squeezy',
|
||||
'paddle' => 'Paddle',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
]),
|
||||
|
||||
@@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Ablaufdatum')
|
||||
->required(),
|
||||
TextInput::make('lemonsqueezy_subscription_id')
|
||||
->label('Lemon Squeezy Subscription ID')
|
||||
TextInput::make('paddle_subscription_id')
|
||||
->label('Paddle Subscription ID')
|
||||
->maxLength(191)
|
||||
->helperText('Abonnement-ID aus Lemon Squeezy.')
|
||||
->helperText('Abonnement-ID aus Paddle Billing.')
|
||||
->nullable(),
|
||||
Toggle::make('active')
|
||||
->label('Aktiv'),
|
||||
@@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager
|
||||
TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('lemonsqueezy_subscription_id')
|
||||
->label('Lemon Squeezy Subscription')
|
||||
TextColumn::make('paddle_subscription_id')
|
||||
->label('Paddle Subscription')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
IconColumn::make('active')
|
||||
|
||||
@@ -22,8 +22,8 @@ class TenantInfolist
|
||||
TextEntry::make('user.full_name')
|
||||
->label(__('admin.tenants.fields.owner'))
|
||||
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
|
||||
TextEntry::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer ID')
|
||||
TextEntry::make('paddle_customer_id')
|
||||
->label('Paddle Customer ID')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('total_revenue')
|
||||
->label(__('admin.tenants.fields.total_revenue'))
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use App\Models\AiEditingSetting;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class AiEditingSettingsPage extends Page
|
||||
{
|
||||
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected string $view = 'filament.super-admin.pages.ai-editing-settings-page';
|
||||
|
||||
protected static null|string|\UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 30;
|
||||
|
||||
public static function getNavigationGroup(): \UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'AI Editing Settings';
|
||||
}
|
||||
|
||||
public bool $is_enabled = true;
|
||||
|
||||
public string $default_provider = 'runware';
|
||||
|
||||
public ?string $fallback_provider = null;
|
||||
|
||||
public string $runware_mode = 'live';
|
||||
|
||||
public bool $queue_auto_dispatch = false;
|
||||
|
||||
public string $queue_name = 'default';
|
||||
|
||||
public int $queue_max_polls = 6;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $blocked_terms = [];
|
||||
|
||||
public ?string $status_message = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$settings = AiEditingSetting::current();
|
||||
|
||||
$this->is_enabled = (bool) $settings->is_enabled;
|
||||
$this->default_provider = (string) ($settings->default_provider ?: 'runware');
|
||||
$this->fallback_provider = $settings->fallback_provider ? (string) $settings->fallback_provider : null;
|
||||
$this->runware_mode = (string) ($settings->runware_mode ?: 'live');
|
||||
$this->queue_auto_dispatch = (bool) $settings->queue_auto_dispatch;
|
||||
$this->queue_name = (string) ($settings->queue_name ?: 'default');
|
||||
$this->queue_max_polls = max(1, (int) ($settings->queue_max_polls ?: 6));
|
||||
$this->blocked_terms = array_values(array_filter(array_map(
|
||||
static fn (mixed $term): string => trim((string) $term),
|
||||
(array) $settings->blocked_terms
|
||||
)));
|
||||
$this->status_message = $settings->status_message ? (string) $settings->status_message : null;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Global Availability')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('is_enabled')
|
||||
->label('Enable AI editing globally'),
|
||||
Forms\Components\Textarea::make('status_message')
|
||||
->label('Disabled message')
|
||||
->maxLength(255)
|
||||
->rows(2)
|
||||
->helperText('Shown to guest and tenant clients when the feature is disabled.')
|
||||
->nullable(),
|
||||
]),
|
||||
Section::make('Provider')
|
||||
->schema([
|
||||
Forms\Components\Select::make('default_provider')
|
||||
->label('Default provider')
|
||||
->options([
|
||||
'runware' => 'runware.ai',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('fallback_provider')
|
||||
->label('Fallback provider')
|
||||
->maxLength(40)
|
||||
->helperText('Reserved for provider failover.'),
|
||||
Forms\Components\Select::make('runware_mode')
|
||||
->label('Runware mode')
|
||||
->options([
|
||||
'live' => 'Live API',
|
||||
'fake' => 'Fake mode (internal testing)',
|
||||
])
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Queue Orchestration')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('queue_auto_dispatch')
|
||||
->label('Auto-dispatch jobs after request creation'),
|
||||
Forms\Components\TextInput::make('queue_name')
|
||||
->label('Queue name')
|
||||
->required()
|
||||
->maxLength(60),
|
||||
Forms\Components\TextInput::make('queue_max_polls')
|
||||
->label('Max provider polls')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->maxValue(50)
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Prompt Safety')
|
||||
->schema([
|
||||
Forms\Components\TagsInput::make('blocked_terms')
|
||||
->label('Blocked prompt terms')
|
||||
->helperText('Case-insensitive term match before queue dispatch.')
|
||||
->placeholder('Add blocked term'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$settings = AiEditingSetting::query()->firstOrNew(['id' => 1]);
|
||||
$settings->is_enabled = $this->is_enabled;
|
||||
$settings->default_provider = $this->default_provider;
|
||||
$settings->fallback_provider = $this->nullableString($this->fallback_provider);
|
||||
$settings->runware_mode = $this->runware_mode;
|
||||
$settings->queue_auto_dispatch = $this->queue_auto_dispatch;
|
||||
$settings->queue_name = $this->queue_name;
|
||||
$settings->queue_max_polls = max(1, $this->queue_max_polls);
|
||||
$settings->blocked_terms = array_values(array_filter(array_map(
|
||||
static fn (mixed $term): string => trim((string) $term),
|
||||
$this->blocked_terms
|
||||
)));
|
||||
$settings->status_message = $this->nullableString($this->status_message);
|
||||
$settings->save();
|
||||
|
||||
$changed = $settings->getChanges();
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'ai_editing.settings_updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('AI editing settings saved.')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
private function nullableString(?string $value): ?string
|
||||
{
|
||||
$trimmed = trim((string) $value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Pages;
|
||||
|
||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class InternalDocsPage extends Page
|
||||
{
|
||||
protected static ?string $cluster = RareAdminCluster::class;
|
||||
|
||||
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-book-open';
|
||||
|
||||
protected static null|string|UnitEnum $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 18;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.nav.internal_docs');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.infrastructure');
|
||||
}
|
||||
|
||||
public static function getNavigationUrl(): string
|
||||
{
|
||||
return url('/super-admin/docs');
|
||||
}
|
||||
|
||||
public static function getNavigationItemActiveRoutePattern(): string|array
|
||||
{
|
||||
return [
|
||||
static::getRouteName(),
|
||||
'filament.superadmin-kb.*',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Requests\Api\GuestAiEditStoreRequest;
|
||||
use App\Jobs\ProcessAiEditRequest;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\AiEditing\AiBudgetGuardService;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiStyleAccessService;
|
||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
||||
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
|
||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EventPublicAiEditController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventJoinTokenService $joinTokenService,
|
||||
private readonly AiSafetyPolicyService $safetyPolicy,
|
||||
private readonly AiEditingRuntimeConfig $runtimeConfig,
|
||||
private readonly AiBudgetGuardService $budgetGuard,
|
||||
private readonly AiStylingEntitlementService $entitlements,
|
||||
private readonly EventAiEditingPolicyService $eventPolicy,
|
||||
private readonly AiStyleAccessService $styleAccess,
|
||||
private readonly AiAbuseEscalationService $abuseEscalation,
|
||||
) {}
|
||||
|
||||
public function store(GuestAiEditStoreRequest $request, string $token, int $photo): JsonResponse
|
||||
{
|
||||
$event = $this->resolvePublishedEvent($token);
|
||||
if ($event instanceof JsonResponse) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$photoModel = Photo::query()
|
||||
->whereKey($photo)
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
|
||||
if (! $photoModel) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
if ($photoModel->status !== 'approved') {
|
||||
return ApiError::response(
|
||||
'photo_not_eligible',
|
||||
'Photo not eligible',
|
||||
'Only approved photos can be used for AI edits.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->runtimeConfig->isEnabled()) {
|
||||
return ApiError::response(
|
||||
'feature_disabled',
|
||||
'Feature disabled',
|
||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return ApiError::response(
|
||||
'feature_locked',
|
||||
'Feature locked',
|
||||
$this->entitlements->lockedMessage(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$policy = $this->eventPolicy->resolve($event);
|
||||
if (! $policy['enabled']) {
|
||||
return ApiError::response(
|
||||
'event_feature_disabled',
|
||||
'Feature disabled for this event',
|
||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
|
||||
if (! $budgetDecision['allowed']) {
|
||||
return ApiError::response(
|
||||
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
|
||||
'Budget limit reached',
|
||||
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'budget' => $budgetDecision['budget'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$style = $this->resolveStyleByKey($request->input('style_key'));
|
||||
if ($request->filled('style_key') && ! $style) {
|
||||
return ApiError::response(
|
||||
'style_not_found',
|
||||
'Style not found',
|
||||
'The selected style is not available.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
$style
|
||||
&& (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style))
|
||||
) {
|
||||
return ApiError::response(
|
||||
'style_not_allowed',
|
||||
'Style not allowed',
|
||||
$policy['policy_message'] ?? 'This style is not allowed for this event.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
[
|
||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$prompt = (string) ($request->input('prompt') ?: $style?->prompt_template ?: '');
|
||||
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style?->negative_prompt_template ?: '');
|
||||
$providerModel = $request->input('provider_model') ?: $style?->provider_model;
|
||||
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
|
||||
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
|
||||
$sessionId = $this->normalizeOptionalString((string) $request->input('session_id', ''));
|
||||
$scopeKey = $this->normalizeOptionalString($deviceId ?: $sessionId) ?: 'guest';
|
||||
$abuseSignal = null;
|
||||
$safetyReasons = $safetyDecision->reasonCodes;
|
||||
if ($safetyDecision->blocked) {
|
||||
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
|
||||
(int) $event->tenant_id,
|
||||
(int) $event->id,
|
||||
$scopeKey
|
||||
);
|
||||
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
|
||||
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = (array) $request->input('metadata', []);
|
||||
$styleMetadata = is_array($style?->metadata) ? $style->metadata : [];
|
||||
$styleRunwareMetadata = Arr::get($styleMetadata, 'runware');
|
||||
if (is_array($styleRunwareMetadata)) {
|
||||
$metadata['runware'] = $styleRunwareMetadata;
|
||||
}
|
||||
|
||||
if (is_array($abuseSignal)) {
|
||||
$metadata['abuse'] = $abuseSignal;
|
||||
}
|
||||
$metadata['budget'] = $budgetDecision['budget'];
|
||||
|
||||
$idempotencyKey = $this->resolveIdempotencyKey(
|
||||
$request->input('idempotency_key'),
|
||||
$request->header('X-Idempotency-Key'),
|
||||
$photoModel,
|
||||
$style,
|
||||
$prompt,
|
||||
$deviceId,
|
||||
$sessionId
|
||||
);
|
||||
|
||||
$attributes = [
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photoModel->id,
|
||||
'style_id' => $style?->id,
|
||||
'provider' => $this->runtimeConfig->defaultProvider(),
|
||||
'provider_model' => $providerModel,
|
||||
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
|
||||
'safety_state' => $safetyDecision->state,
|
||||
'prompt' => $prompt,
|
||||
'negative_prompt' => $negativePrompt,
|
||||
'input_image_path' => $photoModel->file_path,
|
||||
'requested_by_device_id' => $deviceId,
|
||||
'requested_by_session_id' => $sessionId,
|
||||
'safety_reasons' => $safetyReasons,
|
||||
'failure_code' => $safetyDecision->failureCode,
|
||||
'failure_message' => $safetyDecision->failureMessage,
|
||||
'queued_at' => now(),
|
||||
'completed_at' => $safetyDecision->blocked ? now() : null,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
|
||||
$editRequest = AiEditRequest::query()->firstOrCreate(
|
||||
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
|
||||
$attributes
|
||||
);
|
||||
|
||||
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
|
||||
$editRequest,
|
||||
$event,
|
||||
$photoModel,
|
||||
$style?->id,
|
||||
$prompt,
|
||||
$negativePrompt,
|
||||
$providerModel,
|
||||
$deviceId,
|
||||
$sessionId
|
||||
)) {
|
||||
return ApiError::response(
|
||||
'idempotency_conflict',
|
||||
'Idempotency conflict',
|
||||
'The provided idempotency key is already in use for another request.',
|
||||
Response::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
$editRequest->wasRecentlyCreated
|
||||
&& ! $safetyDecision->blocked
|
||||
&& $this->runtimeConfig->queueAutoDispatch()
|
||||
) {
|
||||
ProcessAiEditRequest::dispatch($editRequest->id)
|
||||
->onQueue($this->runtimeConfig->queueName());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
|
||||
'duplicate' => ! $editRequest->wasRecentlyCreated,
|
||||
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
|
||||
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $token, int $requestId): JsonResponse
|
||||
{
|
||||
$event = $this->resolvePublishedEvent($token);
|
||||
if ($event instanceof JsonResponse) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$editRequest = AiEditRequest::query()
|
||||
->with(['style', 'outputs'])
|
||||
->whereKey($requestId)
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
|
||||
if (! $editRequest) {
|
||||
return ApiError::response(
|
||||
'edit_request_not_found',
|
||||
'Edit request not found',
|
||||
'The specified AI edit request could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
|
||||
if ($editRequest->requested_by_device_id && $deviceId && $editRequest->requested_by_device_id !== $deviceId) {
|
||||
return ApiError::response(
|
||||
'forbidden_request_scope',
|
||||
'Forbidden',
|
||||
'This AI edit request belongs to another device.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->serializeRequest($editRequest),
|
||||
]);
|
||||
}
|
||||
|
||||
public function styles(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$event = $this->resolvePublishedEvent($token);
|
||||
if ($event instanceof JsonResponse) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
if (! $this->runtimeConfig->isEnabled()) {
|
||||
return ApiError::response(
|
||||
'feature_disabled',
|
||||
'Feature disabled',
|
||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return ApiError::response(
|
||||
'feature_locked',
|
||||
'Feature locked',
|
||||
$this->entitlements->lockedMessage(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$policy = $this->eventPolicy->resolve($event);
|
||||
if (! $policy['enabled']) {
|
||||
return ApiError::response(
|
||||
'event_feature_disabled',
|
||||
'Feature disabled for this event',
|
||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$styles = $this->eventPolicy->filterStyles(
|
||||
$event,
|
||||
AiStyle::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
);
|
||||
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
|
||||
|
||||
return response()->json([
|
||||
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
|
||||
'meta' => [
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
'allow_custom_prompt' => $policy['allow_custom_prompt'],
|
||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
||||
'policy_message' => $policy['policy_message'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolvePublishedEvent(string $token): Event|JsonResponse
|
||||
{
|
||||
$joinToken = $this->joinTokenService->findActiveToken($token);
|
||||
|
||||
if (! $joinToken) {
|
||||
return ApiError::response(
|
||||
'invalid_token',
|
||||
'Invalid token',
|
||||
'The provided event token is invalid or expired.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$event = Event::query()
|
||||
->whereKey($joinToken->event_id)
|
||||
->where('status', 'published')
|
||||
->first();
|
||||
|
||||
if (! $event) {
|
||||
return ApiError::response(
|
||||
'event_not_public',
|
||||
'Event not public',
|
||||
'This event is not publicly accessible.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function resolveStyleByKey(?string $styleKey): ?AiStyle
|
||||
{
|
||||
$key = $this->normalizeOptionalString((string) ($styleKey ?? ''));
|
||||
if (! $key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AiStyle::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function normalizeOptionalString(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
|
||||
private function resolveIdempotencyKey(
|
||||
mixed $bodyKey,
|
||||
mixed $headerKey,
|
||||
Photo $photo,
|
||||
?AiStyle $style,
|
||||
string $prompt,
|
||||
?string $deviceId,
|
||||
?string $sessionId
|
||||
): string {
|
||||
$candidate = $this->normalizeOptionalString((string) ($bodyKey ?: $headerKey ?: ''));
|
||||
if ($candidate) {
|
||||
return Str::limit($candidate, 120, '');
|
||||
}
|
||||
|
||||
return substr(hash('sha256', implode('|', [
|
||||
(string) $photo->event_id,
|
||||
(string) $photo->id,
|
||||
(string) ($style?->id ?? ''),
|
||||
trim($prompt),
|
||||
(string) ($deviceId ?? ''),
|
||||
(string) ($sessionId ?? ''),
|
||||
])), 0, 120);
|
||||
}
|
||||
|
||||
private function isIdempotencyConflict(
|
||||
AiEditRequest $request,
|
||||
Event $event,
|
||||
Photo $photo,
|
||||
?int $styleId,
|
||||
string $prompt,
|
||||
string $negativePrompt,
|
||||
?string $providerModel,
|
||||
?string $deviceId,
|
||||
?string $sessionId
|
||||
): bool {
|
||||
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) ($request->style_id ?? 0) !== (int) ($styleId ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->requested_by_device_id) !== $this->normalizeOptionalString($deviceId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->normalizeOptionalString($request->requested_by_session_id) !== $this->normalizeOptionalString($sessionId);
|
||||
}
|
||||
|
||||
private function serializeStyle(AiStyle $style): array
|
||||
{
|
||||
return [
|
||||
'id' => $style->id,
|
||||
'key' => $style->key,
|
||||
'name' => $style->name,
|
||||
'version' => $style->version,
|
||||
'category' => $style->category,
|
||||
'description' => $style->description,
|
||||
'provider' => $style->provider,
|
||||
'provider_model' => $style->provider_model,
|
||||
'requires_source_image' => $style->requires_source_image,
|
||||
'is_premium' => $style->is_premium,
|
||||
'metadata' => $style->metadata ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeRequest(AiEditRequest $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $request->id,
|
||||
'event_id' => $request->event_id,
|
||||
'photo_id' => $request->photo_id,
|
||||
'style' => $request->style ? [
|
||||
'id' => $request->style->id,
|
||||
'key' => $request->style->key,
|
||||
'name' => $request->style->name,
|
||||
] : null,
|
||||
'provider' => $request->provider,
|
||||
'provider_model' => $request->provider_model,
|
||||
'status' => $request->status,
|
||||
'safety_state' => $request->safety_state,
|
||||
'safety_reasons' => $request->safety_reasons ?? [],
|
||||
'failure_code' => $request->failure_code,
|
||||
'failure_message' => $request->failure_message,
|
||||
'queued_at' => $request->queued_at?->toIso8601String(),
|
||||
'started_at' => $request->started_at?->toIso8601String(),
|
||||
'completed_at' => $request->completed_at?->toIso8601String(),
|
||||
'outputs' => $request->outputs->map(fn ($output) => [
|
||||
'id' => $output->id,
|
||||
'storage_disk' => $output->storage_disk,
|
||||
'storage_path' => $output->storage_path,
|
||||
'provider_url' => $output->provider_url,
|
||||
'url' => $this->resolveOutputUrl(
|
||||
$output->storage_disk,
|
||||
$output->storage_path,
|
||||
$output->provider_url
|
||||
),
|
||||
'mime_type' => $output->mime_type,
|
||||
'width' => $output->width,
|
||||
'height' => $output->height,
|
||||
'is_primary' => $output->is_primary,
|
||||
'safety_state' => $output->safety_state,
|
||||
'safety_reasons' => $output->safety_reasons ?? [],
|
||||
'generated_at' => $output->generated_at?->toIso8601String(),
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
|
||||
{
|
||||
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
|
||||
if ($resolvedStoragePath !== null) {
|
||||
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
|
||||
return $resolvedStoragePath;
|
||||
}
|
||||
|
||||
$disk = $this->resolveStorageDisk($storageDisk);
|
||||
|
||||
try {
|
||||
return Storage::disk($disk)->url($resolvedStoragePath);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::debug('Falling back to raw AI output storage path', [
|
||||
'disk' => $disk,
|
||||
'path' => $resolvedStoragePath,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return '/'.ltrim($resolvedStoragePath, '/');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeOptionalString($providerUrl);
|
||||
}
|
||||
|
||||
private function resolveStorageDisk(?string $disk): string
|
||||
{
|
||||
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
|
||||
|
||||
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
|
||||
return (string) config('filesystems.default', 'public');
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,6 @@ use App\Models\GuestNotification;
|
||||
use App\Models\GuestPolicySetting;
|
||||
use App\Models\Photo;
|
||||
use App\Models\PhotoShareLink;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\EventTasksCacheService;
|
||||
@@ -44,7 +41,6 @@ use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EventPublicController extends BaseController
|
||||
@@ -53,10 +49,6 @@ class EventPublicController extends BaseController
|
||||
|
||||
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
|
||||
|
||||
private const PREVIEW_MAX_EDGE = 1920;
|
||||
|
||||
private const PREVIEW_QUALITY = 86;
|
||||
|
||||
private ?GuestPolicySetting $guestPolicy = null;
|
||||
|
||||
public function __construct(
|
||||
@@ -68,9 +60,6 @@ class EventPublicController extends BaseController
|
||||
private readonly EventTasksCacheService $eventTasksCache,
|
||||
private readonly GuestNotificationService $guestNotificationService,
|
||||
private readonly PushSubscriptionService $pushSubscriptions,
|
||||
private readonly AiEditingRuntimeConfig $aiEditingRuntimeConfig,
|
||||
private readonly AiStylingEntitlementService $aiStylingEntitlements,
|
||||
private readonly EventAiEditingPolicyService $eventAiEditingPolicy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -1065,7 +1054,6 @@ class EventPublicController extends BaseController
|
||||
* heading_font: ?string,
|
||||
* body_font: ?string,
|
||||
* font_size: string,
|
||||
* welcome_message: ?string,
|
||||
* logo_url: ?string,
|
||||
* logo_mode: string,
|
||||
* logo_value: ?string,
|
||||
@@ -1105,8 +1093,12 @@ class EventPublicController extends BaseController
|
||||
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||
|
||||
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
||||
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
|
||||
|
||||
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
||||
$sources = $brandingAllowed ? [$eventBranding] : [[]];
|
||||
$sources = $brandingAllowed
|
||||
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
|
||||
: [[]];
|
||||
|
||||
$primary = $this->normalizeHexColor(
|
||||
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
||||
@@ -1129,7 +1121,6 @@ class EventPublicController extends BaseController
|
||||
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
|
||||
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
|
||||
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size'];
|
||||
$welcomeMessage = $this->firstStringFromSources($sources, ['welcome_message', 'welcomeMessage']);
|
||||
|
||||
$logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
|
||||
if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
|
||||
@@ -1191,7 +1182,6 @@ class EventPublicController extends BaseController
|
||||
'heading_font' => $headingFont,
|
||||
'body_font' => $bodyFont,
|
||||
'font_size' => $fontSize,
|
||||
'welcome_message' => $welcomeMessage,
|
||||
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
|
||||
'logo_mode' => $logoMode,
|
||||
'logo_value' => $logoValue,
|
||||
@@ -1455,34 +1445,17 @@ class EventPublicController extends BaseController
|
||||
}
|
||||
|
||||
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
||||
{
|
||||
return $this->makeSignedGalleryDownloadUrlForId($token, (int) $photo->id);
|
||||
}
|
||||
|
||||
private function makeSignedGalleryDownloadUrlForId(string $token, int $photoId): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
'api.v1.gallery.photos.download',
|
||||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||
[
|
||||
'token' => $token,
|
||||
'photo' => $photoId,
|
||||
'photo' => $photo->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function galleryDownloadVariantPreference(Event $event): array
|
||||
{
|
||||
$settings = is_array($event->settings) ? $event->settings : [];
|
||||
$configuredVariant = Arr::get($settings, 'guest_download_variant', 'preview');
|
||||
|
||||
if ($configuredVariant === 'original') {
|
||||
return ['original'];
|
||||
}
|
||||
|
||||
return ['preview', 'original'];
|
||||
}
|
||||
|
||||
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
@@ -1491,7 +1464,8 @@ class EventPublicController extends BaseController
|
||||
[
|
||||
'slug' => $shareLink->slug,
|
||||
'variant' => $variant,
|
||||
]
|
||||
],
|
||||
absolute: false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1764,7 +1738,6 @@ class EventPublicController extends BaseController
|
||||
'name' => $event->name,
|
||||
'city' => $event->city,
|
||||
] : null,
|
||||
'branding' => $event ? $this->resolveBrandingPayload($event) : null,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
@@ -1928,12 +1901,7 @@ class EventPublicController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
return $this->streamGalleryPhoto(
|
||||
$event,
|
||||
$record,
|
||||
$this->galleryDownloadVariantPreference($event),
|
||||
'attachment'
|
||||
);
|
||||
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
|
||||
}
|
||||
|
||||
public function event(Request $request, string $token)
|
||||
@@ -1985,11 +1953,6 @@ class EventPublicController extends BaseController
|
||||
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
||||
$liveShowSettings = Arr::get($settings, 'live_show', []);
|
||||
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
|
||||
$aiStylingEntitlement = $this->aiStylingEntitlements->resolveForEvent($event);
|
||||
$aiEditingPolicy = $this->eventAiEditingPolicy->resolve($event);
|
||||
$aiStylingAvailable = $this->aiEditingRuntimeConfig->isEnabled()
|
||||
&& (bool) $aiStylingEntitlement['allowed']
|
||||
&& (bool) $aiEditingPolicy['enabled'];
|
||||
$event->loadMissing('photoboothSetting');
|
||||
$policy = $this->guestPolicy();
|
||||
|
||||
@@ -2017,58 +1980,10 @@ class EventPublicController extends BaseController
|
||||
'live_show' => [
|
||||
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
|
||||
],
|
||||
'capabilities' => [
|
||||
'ai_styling' => $aiStylingAvailable,
|
||||
'ai_styling_granted_by' => $aiStylingEntitlement['granted_by'],
|
||||
'ai_styling_required_feature' => $aiStylingEntitlement['required_feature'],
|
||||
'ai_styling_addon_keys' => $aiStylingEntitlement['addon_keys'],
|
||||
'ai_styling_event_enabled' => (bool) $aiEditingPolicy['enabled'],
|
||||
],
|
||||
'engagement_mode' => $engagementMode,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function qr(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[, $joinToken] = $result;
|
||||
|
||||
$joinTokenValue = $joinToken->token ?? $token;
|
||||
$qrCodeUrl = $joinTokenValue ? url('/e/'.$joinTokenValue) : null;
|
||||
$qrCodeDataUrl = null;
|
||||
|
||||
if ($qrCodeUrl) {
|
||||
$requestedSize = (int) $request->query('size', 360);
|
||||
$size = max(120, min($requestedSize, 640));
|
||||
|
||||
try {
|
||||
$png = QrCode::format('png')
|
||||
->size($size)
|
||||
->margin(1)
|
||||
->errorCorrection('M')
|
||||
->generate($qrCodeUrl);
|
||||
|
||||
$pngBinary = (string) $png;
|
||||
|
||||
if ($pngBinary !== '') {
|
||||
$qrCodeDataUrl = 'data:image/png;base64,'.base64_encode($pngBinary);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'url' => $qrCodeUrl,
|
||||
'qr_code_data_url' => $qrCodeDataUrl,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function package(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
@@ -2245,15 +2160,6 @@ class EventPublicController extends BaseController
|
||||
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||||
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
||||
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
|
||||
} elseif ($variant === 'preview') {
|
||||
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'preview')->first();
|
||||
$watermarked = $preferOriginals
|
||||
? null
|
||||
: EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_preview')->first();
|
||||
$fallbackAsset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
|
||||
$disk = $watermarked?->disk ?? $asset?->disk ?? $fallbackAsset?->disk;
|
||||
$path = $watermarked?->path ?? $asset?->path ?? $fallbackAsset?->path ?? ($record->file_path ?? null);
|
||||
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? $fallbackAsset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
|
||||
} else {
|
||||
$watermarked = $preferOriginals
|
||||
? null
|
||||
@@ -2694,15 +2600,6 @@ class EventPublicController extends BaseController
|
||||
->distinct('guest_name')
|
||||
->count('guest_name');
|
||||
|
||||
$guestCount = DB::table('photos')
|
||||
->where('event_id', $eventId)
|
||||
->distinct('guest_name')
|
||||
->count('guest_name');
|
||||
|
||||
$likesCount = (int) DB::table('photos')
|
||||
->where('event_id', $eventId)
|
||||
->sum('likes_count');
|
||||
|
||||
// Tasks solved as number of photos linked to a task (proxy metric).
|
||||
$tasksSolved = $engagementMode === 'photo_only'
|
||||
? 0
|
||||
@@ -2713,8 +2610,6 @@ class EventPublicController extends BaseController
|
||||
$payload = [
|
||||
'online_guests' => $onlineGuests,
|
||||
'tasks_solved' => $tasksSolved,
|
||||
'guest_count' => $guestCount,
|
||||
'likes_count' => $likesCount,
|
||||
'latest_photo_at' => $latestPhotoAt,
|
||||
'engagement_mode' => $engagementMode,
|
||||
];
|
||||
@@ -2900,14 +2795,12 @@ class EventPublicController extends BaseController
|
||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||||
|
||||
$deviceId = $this->normalizeGuestIdentifier((string) $request->header('X-Device-Id', ''));
|
||||
$deviceId = $deviceId !== '' ? $deviceId : 'anon';
|
||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||
$filter = $request->query('filter');
|
||||
|
||||
$since = $request->query('since');
|
||||
$query = DB::table('photos')
|
||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||
->leftJoin('emotions', 'photos.emotion_id', '=', 'emotions.id')
|
||||
->select([
|
||||
'photos.id',
|
||||
'photos.file_path',
|
||||
@@ -2916,14 +2809,9 @@ class EventPublicController extends BaseController
|
||||
'photos.emotion_id',
|
||||
'photos.task_id',
|
||||
'photos.guest_name',
|
||||
'photos.created_by_device_id',
|
||||
'photos.created_at',
|
||||
'photos.ingest_source',
|
||||
'tasks.title as task_title',
|
||||
'emotions.name as emotion_name',
|
||||
'emotions.icon as emotion_icon',
|
||||
'emotions.color as emotion_color',
|
||||
'emotions.id as emotion_lookup_id',
|
||||
])
|
||||
->where('photos.event_id', $eventId)
|
||||
->where('photos.status', 'approved')
|
||||
@@ -2934,50 +2822,24 @@ class EventPublicController extends BaseController
|
||||
if ($filter === 'photobooth') {
|
||||
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
||||
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
||||
$query->where(function ($inner) use ($deviceId) {
|
||||
$inner->where('created_by_device_id', $deviceId)
|
||||
->orWhere('guest_name', $deviceId);
|
||||
});
|
||||
$query->where('guest_name', $deviceId);
|
||||
}
|
||||
|
||||
if ($since) {
|
||||
$query->where('photos.created_at', '>', $since);
|
||||
}
|
||||
$rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) {
|
||||
$fullUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
||||
$rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
|
||||
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
||||
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
||||
$thumbnailUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
||||
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
||||
?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));
|
||||
$r->file_path = $fullUrl;
|
||||
$r->thumbnail_path = $thumbnailUrl;
|
||||
$r->full_url = $fullUrl;
|
||||
$r->thumbnail_url = $thumbnailUrl;
|
||||
$r->download_url = $this->makeSignedGalleryDownloadUrlForId($token, (int) $r->id);
|
||||
|
||||
// Localize task title if present
|
||||
if ($r->task_title) {
|
||||
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
|
||||
}
|
||||
|
||||
$emotion = null;
|
||||
if ($r->emotion_id) {
|
||||
$emotionName = $this->firstLocalizedValue($r->emotion_name, $fallbacks, '');
|
||||
if ($emotionName !== '') {
|
||||
$emotion = [
|
||||
'id' => (int) ($r->emotion_lookup_id ?? $r->emotion_id),
|
||||
'name' => $emotionName,
|
||||
'icon' => $r->emotion_icon ?: null,
|
||||
'color' => $r->emotion_color ?: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
$r->emotion = $emotion;
|
||||
|
||||
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
|
||||
$createdBy = $r->created_by_device_id ? $this->normalizeGuestIdentifier((string) $r->created_by_device_id) : '';
|
||||
$r->is_mine = $deviceId !== 'anon'
|
||||
&& $deviceId !== ''
|
||||
&& (($createdBy !== '' && $createdBy === $deviceId) || ($createdBy === '' && (string) $r->guest_name === $deviceId));
|
||||
|
||||
return $r;
|
||||
});
|
||||
@@ -3070,159 +2932,6 @@ class EventPublicController extends BaseController
|
||||
return response()->json(['liked' => true, 'likes_count' => $count]);
|
||||
}
|
||||
|
||||
public function unlike(Request $request, int $id)
|
||||
{
|
||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
|
||||
if ($deviceId === '') {
|
||||
$deviceId = 'anon';
|
||||
}
|
||||
|
||||
$photo = DB::table('photos')
|
||||
->join('events', 'photos.event_id', '=', 'events.id')
|
||||
->where('photos.id', $id)
|
||||
->where('events.status', 'published')
|
||||
->first(['photos.id', 'photos.event_id']);
|
||||
if (! $photo) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'Photo not found or event not public.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $id]
|
||||
);
|
||||
}
|
||||
|
||||
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
|
||||
if (! $exists) {
|
||||
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
|
||||
|
||||
return response()->json(['liked' => false, 'likes_count' => $count]);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->delete();
|
||||
DB::table('photos')->where('id', $id)->update([
|
||||
'likes_count' => DB::raw('case when likes_count > 0 then likes_count - 1 else 0 end'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::warning('unlike failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
|
||||
|
||||
return response()->json(['liked' => false, 'likes_count' => $count]);
|
||||
}
|
||||
|
||||
public function destroyPhoto(Request $request, string $token, Photo $photo): JsonResponse
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
|
||||
if ($result instanceof JsonResponse) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$event] = $result;
|
||||
$deviceId = $this->resolveDeviceIdentifier($request);
|
||||
|
||||
if ($deviceId === 'anonymous') {
|
||||
return ApiError::response(
|
||||
'photo_delete_forbidden',
|
||||
'Delete Not Allowed',
|
||||
'This photo cannot be deleted from this device.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
if ($photo->event_id !== (int) $event->id) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'Photo not found or event not public.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
$ownerId = $photo->created_by_device_id
|
||||
? $this->normalizeGuestIdentifier((string) $photo->created_by_device_id)
|
||||
: '';
|
||||
$guestName = is_string($photo->guest_name) ? $photo->guest_name : '';
|
||||
$isOwner = $ownerId !== ''
|
||||
? $ownerId === $deviceId
|
||||
: ($guestName !== '' && $guestName === $deviceId);
|
||||
|
||||
if (! $isOwner) {
|
||||
return ApiError::response(
|
||||
'photo_delete_forbidden',
|
||||
'Delete Not Allowed',
|
||||
'This photo cannot be deleted from this device.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['photo_id' => $photo->id]
|
||||
);
|
||||
}
|
||||
|
||||
$eventModel = Event::with(['eventPackage.package'])->find((int) $event->id);
|
||||
$assets = EventMediaAsset::where('photo_id', $photo->id)->get();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
if (! is_string($asset->path) || $asset->path === '') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Storage::disk($asset->disk)->delete($asset->path);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to delete guest photo asset from storage', [
|
||||
'asset_id' => $asset->id,
|
||||
'disk' => $asset->disk,
|
||||
'path' => $asset->path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($assets->isEmpty() && $eventModel) {
|
||||
$fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
|
||||
$paths = array_values(array_filter([
|
||||
is_string($photo->path ?? null) ? $photo->path : null,
|
||||
is_string($photo->thumbnail_path ?? null) ? $photo->thumbnail_path : null,
|
||||
is_string($photo->file_path ?? null) ? $photo->file_path : null,
|
||||
]));
|
||||
if (! empty($paths)) {
|
||||
Storage::disk($fallbackDisk)->delete($paths);
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($photo, $assets) {
|
||||
$photo->likes()->delete();
|
||||
PhotoShareLink::where('photo_id', $photo->id)->delete();
|
||||
if ($assets->isNotEmpty()) {
|
||||
EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete();
|
||||
}
|
||||
$photo->delete();
|
||||
});
|
||||
|
||||
$eventPackage = $eventModel?->eventPackage;
|
||||
if ($eventPackage && $eventPackage->package) {
|
||||
$previousUsed = (int) $eventPackage->used_photos;
|
||||
if ($previousUsed > 0) {
|
||||
$eventPackage->decrement('used_photos');
|
||||
$eventPackage->refresh();
|
||||
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Photo deleted successfully',
|
||||
'photo_id' => $photo->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function upload(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
@@ -3386,19 +3095,10 @@ class EventPublicController extends BaseController
|
||||
$thumbUrl = $thumbPath
|
||||
? $this->resolveDiskUrl($disk, $thumbPath)
|
||||
: $this->resolveDiskUrl($disk, $path);
|
||||
$previewRel = "events/{$eventId}/photos/previews/{$baseName}_preview.jpg";
|
||||
$previewPath = ImageHelper::makeThumbnailOnDisk(
|
||||
$disk,
|
||||
$path,
|
||||
$previewRel,
|
||||
self::PREVIEW_MAX_EDGE,
|
||||
self::PREVIEW_QUALITY
|
||||
);
|
||||
|
||||
// Create watermarked copies (non-destructive).
|
||||
$watermarkedPath = $path;
|
||||
$watermarkedThumb = $thumbPath ?: $path;
|
||||
$watermarkedPreview = $previewPath ?: $path;
|
||||
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
|
||||
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
|
||||
if ($thumbPath) {
|
||||
@@ -3411,17 +3111,6 @@ class EventPublicController extends BaseController
|
||||
} else {
|
||||
$watermarkedThumb = $watermarkedPath;
|
||||
}
|
||||
|
||||
if ($previewPath) {
|
||||
$watermarkedPreview = ImageHelper::copyWithWatermark(
|
||||
$disk,
|
||||
$previewPath,
|
||||
"events/{$eventId}/photos/watermarked/{$baseName}_preview.jpg",
|
||||
$watermarkConfig
|
||||
) ?? $previewPath;
|
||||
} else {
|
||||
$watermarkedPreview = $watermarkedPath;
|
||||
}
|
||||
}
|
||||
|
||||
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
|
||||
@@ -3535,23 +3224,6 @@ class EventPublicController extends BaseController
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($previewPath) {
|
||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $previewPath, [
|
||||
'variant' => 'preview',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'status' => 'hot',
|
||||
'processed_at' => now(),
|
||||
'photo_id' => $photoId,
|
||||
'size_bytes' => Storage::disk($disk)->exists($previewPath)
|
||||
? Storage::disk($disk)->size($previewPath)
|
||||
: null,
|
||||
'meta' => [
|
||||
'source_variant_id' => $asset->id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($watermarkedThumb !== $thumbPath) {
|
||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
|
||||
'variant' => 'watermarked_thumbnail',
|
||||
@@ -3568,22 +3240,6 @@ class EventPublicController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
if ($watermarkedPreview !== $previewPath) {
|
||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPreview, [
|
||||
'variant' => 'watermarked_preview',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'status' => 'hot',
|
||||
'processed_at' => now(),
|
||||
'photo_id' => $photoId,
|
||||
'size_bytes' => Storage::disk($disk)->exists($watermarkedPreview)
|
||||
? Storage::disk($disk)->size($watermarkedPreview)
|
||||
: null,
|
||||
'meta' => [
|
||||
'source_variant_id' => $watermarkedAsset?->id ?? $asset->id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('photos')
|
||||
->where('id', $photoId)
|
||||
->update(['media_asset_id' => $asset->id]);
|
||||
|
||||
@@ -212,10 +212,6 @@ class LiveShowController extends BaseController
|
||||
|
||||
return Event::query()
|
||||
->where('live_show_token', $token)
|
||||
->where(function (Builder $query) {
|
||||
$query->whereNull('live_show_token_expires_at')
|
||||
->orWhere('live_show_token_expires_at', '>=', now());
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ class CouponPreviewController extends Controller
|
||||
|
||||
$package = Package::findOrFail($data['package_id']);
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.package_not_configured'),
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant = Auth::user()?->tenant;
|
||||
|
||||
try {
|
||||
|
||||
@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
|
||||
|
||||
if (! $checkout['checkout_url']) {
|
||||
throw ValidationException::withMessages([
|
||||
'tier_key' => __('Unable to create checkout.'),
|
||||
'tier_key' => __('Unable to create Paddle checkout.'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,43 +46,24 @@ class GiftVoucherCheckoutController extends Controller
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
|
||||
'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
|
||||
'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
|
||||
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'],
|
||||
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
|
||||
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'],
|
||||
]);
|
||||
|
||||
$voucherQuery = GiftVoucher::query()
|
||||
->where('status', '!=', GiftVoucher::STATUS_PENDING)
|
||||
->where(function ($query) use ($data) {
|
||||
$hasCondition = false;
|
||||
$voucherQuery = GiftVoucher::query();
|
||||
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$query->where(function ($inner) use ($data) {
|
||||
$inner->where('lemonsqueezy_checkout_id', $data['checkout_id'])
|
||||
->orWhere('paypal_order_id', $data['checkout_id']);
|
||||
});
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']);
|
||||
}
|
||||
|
||||
$hasCondition = true;
|
||||
}
|
||||
if (! empty($data['transaction_id'])) {
|
||||
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']);
|
||||
}
|
||||
|
||||
if (! empty($data['order_id'])) {
|
||||
$method = $hasCondition ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$method}(function ($inner) use ($data) {
|
||||
$inner->where('lemonsqueezy_order_id', $data['order_id'])
|
||||
->orWhere('paypal_capture_id', $data['order_id'])
|
||||
->orWhere('paypal_order_id', $data['order_id']);
|
||||
});
|
||||
|
||||
$hasCondition = true;
|
||||
}
|
||||
|
||||
if (! empty($data['code'])) {
|
||||
$method = $hasCondition ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$method}('code', strtoupper($data['code']));
|
||||
}
|
||||
});
|
||||
if (! empty($data['code'])) {
|
||||
$voucherQuery->orWhere('code', strtoupper($data['code']));
|
||||
}
|
||||
|
||||
$voucher = $voucherQuery->latest()->firstOrFail();
|
||||
|
||||
|
||||
@@ -9,40 +9,37 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayPalOrderService $paypalOrders,
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->query('type', 'endcustomer');
|
||||
$provider = strtolower((string) config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL));
|
||||
$packages = Package::where('type', $type)
|
||||
->orderBy('price')
|
||||
->get();
|
||||
|
||||
$packages->each(function ($package) use ($provider) {
|
||||
$packages->each(function ($package) {
|
||||
if (is_string($package->features)) {
|
||||
$decoded = json_decode($package->features, true);
|
||||
$package->features = is_array($decoded) ? $decoded : [];
|
||||
|
||||
} elseif (! is_array($package->features)) {
|
||||
$package->features = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$package->setAttribute('checkout_provider', $provider);
|
||||
$package->setAttribute('can_checkout', $this->canCheckoutPackage($package, $provider));
|
||||
if (! is_array($package->features)) {
|
||||
$package->features = [];
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
@@ -56,7 +53,7 @@ class PackageController extends Controller
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'type' => 'required|in:endcustomer,reseller',
|
||||
'payment_method' => 'required|in:paypal',
|
||||
'payment_method' => 'required|in:paddle',
|
||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||
'success_url' => 'nullable|url',
|
||||
'return_url' => 'nullable|url',
|
||||
@@ -82,7 +79,7 @@ class PackageController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'paypal_order_id' => 'required|string',
|
||||
'paddle_transaction_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
@@ -92,14 +89,14 @@ class PackageController extends Controller
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||
}
|
||||
|
||||
$provider = 'paypal';
|
||||
$provider = 'paddle';
|
||||
|
||||
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => $provider,
|
||||
'provider_id' => $request->input('paypal_order_id'),
|
||||
'provider_id' => $request->input('paddle_transaction_id'),
|
||||
'price' => $package->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'purchased_at' => now(),
|
||||
@@ -164,14 +161,12 @@ class PackageController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function createPayPalCheckout(Request $request): JsonResponse
|
||||
public function createPaddleCheckout(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'success_url' => 'nullable|url',
|
||||
'return_url' => 'nullable|url',
|
||||
'cancel_url' => 'nullable|url',
|
||||
'locale' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->integer('package_id'));
|
||||
@@ -186,11 +181,15 @@ class PackageController extends Controller
|
||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||
}
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -202,56 +201,30 @@ class PackageController extends Controller
|
||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||
])->save();
|
||||
|
||||
$successUrl = $request->input('success_url') ?? $request->input('return_url');
|
||||
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
|
||||
$paypalReturnUrl = route('paypal.return', absolute: true);
|
||||
$payload = [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$order = $this->paypalOrders->createOrder($session, $package, [
|
||||
'return_url' => $paypalReturnUrl,
|
||||
'cancel_url' => $paypalReturnUrl,
|
||||
'locale' => $request->input('locale'),
|
||||
'request_id' => $session->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
Log::warning('PayPal order creation failed (tenant)', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'session_id' => $session->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']);
|
||||
}
|
||||
|
||||
$orderId = $order['id'] ?? null;
|
||||
if (! is_string($orderId) || $orderId === '') {
|
||||
throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']);
|
||||
}
|
||||
|
||||
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||
|
||||
$session->forceFill([
|
||||
'paypal_order_id' => $orderId,
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_status' => $order['status'] ?? null,
|
||||
'paypal_approve_url' => $approveUrl,
|
||||
'paypal_success_url' => $successUrl,
|
||||
'paypal_cancel_url' => $cancelUrl,
|
||||
'paypal_created_at' => now()->toIso8601String(),
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
$this->sessions->markRequiresCustomerAction($session, 'paypal_approval');
|
||||
|
||||
return response()->json([
|
||||
'order_id' => $orderId,
|
||||
'approve_url' => $approveUrl,
|
||||
'status' => $order['status'] ?? null,
|
||||
return response()->json(array_merge($checkout, [
|
||||
'checkout_session_id' => $session->id,
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||
@@ -266,9 +239,7 @@ class PackageController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutUrl = $session->provider === CheckoutSession::PROVIDER_PAYPAL
|
||||
? data_get($session->provider_metadata ?? [], 'paypal_approve_url')
|
||||
: data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
@@ -326,57 +297,19 @@ class PackageController extends Controller
|
||||
|
||||
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
{
|
||||
$successUrl = $request->input('success_url') ?? $request->input('return_url');
|
||||
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
|
||||
$paypalReturnUrl = route('paypal.return', absolute: true);
|
||||
|
||||
try {
|
||||
$session = $this->sessions->createOrResume($request->user(), $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
||||
|
||||
$order = $this->paypalOrders->createOrder($session, $package, [
|
||||
'return_url' => $paypalReturnUrl,
|
||||
'cancel_url' => $paypalReturnUrl,
|
||||
'locale' => $request->input('locale'),
|
||||
'request_id' => $session->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
Log::warning('PayPal order creation failed (purchase)', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']);
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$orderId = $order['id'] ?? null;
|
||||
if (! is_string($orderId) || $orderId === '') {
|
||||
throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'order_id' => $orderId,
|
||||
'approve_url' => $this->paypalOrders->resolveApproveUrl($order),
|
||||
'status' => $order['status'] ?? null,
|
||||
'return_url' => $successUrl,
|
||||
'cancel_url' => $cancelUrl,
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
'metadata' => array_filter([
|
||||
'type' => $request->input('type'),
|
||||
'event_id' => $request->input('event_id'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function canCheckoutPackage(Package $package, string $provider): bool
|
||||
{
|
||||
if ((float) $package->price <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($provider === CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
||||
return filled($package->lemonsqueezy_variant_id);
|
||||
}
|
||||
|
||||
return true;
|
||||
return response()->json($checkout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,625 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\AiEditIndexRequest;
|
||||
use App\Http\Requests\Tenant\AiEditStoreRequest;
|
||||
use App\Jobs\ProcessAiEditRequest;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiProviderRun;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\AiEditing\AiBudgetGuardService;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiStyleAccessService;
|
||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
||||
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
|
||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AiEditController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiSafetyPolicyService $safetyPolicy,
|
||||
private readonly AiEditingRuntimeConfig $runtimeConfig,
|
||||
private readonly AiBudgetGuardService $budgetGuard,
|
||||
private readonly AiStylingEntitlementService $entitlements,
|
||||
private readonly EventAiEditingPolicyService $eventPolicy,
|
||||
private readonly AiStyleAccessService $styleAccess,
|
||||
private readonly AiAbuseEscalationService $abuseEscalation,
|
||||
) {}
|
||||
|
||||
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
$perPage = (int) $request->input('per_page', 20);
|
||||
$status = (string) $request->input('status', '');
|
||||
$safetyState = (string) $request->input('safety_state', '');
|
||||
|
||||
$query = AiEditRequest::query()
|
||||
->with(['style', 'outputs'])
|
||||
->where('event_id', $event->id)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($safetyState !== '') {
|
||||
$query->where('safety_state', $safetyState);
|
||||
}
|
||||
|
||||
$requests = $query->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($requests->items())->map(fn (AiEditRequest $item) => $this->serializeRequest($item))->values(),
|
||||
'meta' => [
|
||||
'current_page' => $requests->currentPage(),
|
||||
'per_page' => $requests->perPage(),
|
||||
'total' => $requests->total(),
|
||||
'last_page' => $requests->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function styles(Request $request, string $eventSlug): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
if (! $this->runtimeConfig->isEnabled()) {
|
||||
return ApiError::response(
|
||||
'feature_disabled',
|
||||
'Feature disabled',
|
||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return ApiError::response(
|
||||
'feature_locked',
|
||||
'Feature locked',
|
||||
$this->entitlements->lockedMessage(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$styles = AiStyle::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$policy = $this->eventPolicy->resolve($event);
|
||||
$styles = $this->eventPolicy->filterStyles($event, $styles);
|
||||
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
|
||||
|
||||
return response()->json([
|
||||
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
|
||||
'meta' => [
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
'event_enabled' => $policy['enabled'],
|
||||
'allow_custom_prompt' => $policy['allow_custom_prompt'],
|
||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
||||
'policy_message' => $policy['policy_message'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function summary(Request $request, string $eventSlug): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
$periodStart = now()->startOfMonth();
|
||||
$periodEnd = now()->endOfMonth();
|
||||
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
|
||||
$statusCounts = (clone $baseQuery)
|
||||
->select('status', DB::raw('count(*) as aggregate'))
|
||||
->groupBy('status')
|
||||
->pluck('aggregate', 'status')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
$safetyCounts = (clone $baseQuery)
|
||||
->select('safety_state', DB::raw('count(*) as aggregate'))
|
||||
->groupBy('safety_state')
|
||||
->pluck('aggregate', 'safety_state')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->all();
|
||||
|
||||
$lastRequestedAt = (clone $baseQuery)->max('created_at');
|
||||
$total = array_sum($statusCounts);
|
||||
$failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0));
|
||||
$moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0);
|
||||
|
||||
$usageQuery = AiUsageLedger::query()
|
||||
->where('tenant_id', $event->tenant_id)
|
||||
->where('event_id', $event->id)
|
||||
->where('recorded_at', '>=', $periodStart)
|
||||
->where('recorded_at', '<=', $periodEnd);
|
||||
$spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0);
|
||||
$debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count());
|
||||
|
||||
$providerRunQuery = AiProviderRun::query()
|
||||
->whereHas('request', fn ($query) => $query->where('event_id', $event->id))
|
||||
->where('created_at', '>=', $periodStart)
|
||||
->where('created_at', '<=', $periodEnd);
|
||||
$providerRunTotal = (int) (clone $providerRunQuery)->count();
|
||||
$providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count();
|
||||
$averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0));
|
||||
|
||||
$failureRate = $total > 0 ? ($failedTotal / $total) : 0.0;
|
||||
$moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0;
|
||||
$providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0;
|
||||
|
||||
$failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
|
||||
$latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
|
||||
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'event_id' => $event->id,
|
||||
'total' => $total,
|
||||
'status_counts' => $statusCounts,
|
||||
'safety_counts' => $safetyCounts,
|
||||
'failed_total' => $failedTotal,
|
||||
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
|
||||
'usage' => [
|
||||
'period_start' => $periodStart->toDateString(),
|
||||
'period_end' => $periodEnd->toDateString(),
|
||||
'debit_count' => $debitCount,
|
||||
'spend_usd' => round($spendUsd, 5),
|
||||
],
|
||||
'observability' => [
|
||||
'failure_rate' => round($failureRate, 5),
|
||||
'moderation_hit_rate' => round($moderationHitRate, 5),
|
||||
'provider_runs_total' => $providerRunTotal,
|
||||
'provider_runs_failed' => $providerRunFailed,
|
||||
'provider_failure_rate' => round($providerFailureRate, 5),
|
||||
'avg_provider_latency_ms' => $averageProviderLatencyMs,
|
||||
'alerts' => [
|
||||
'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)),
|
||||
'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs,
|
||||
],
|
||||
],
|
||||
'budget' => $budgetDecision['budget'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(AiEditStoreRequest $request, string $eventSlug): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
$photo = Photo::query()
|
||||
->whereKey((int) $request->input('photo_id'))
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
|
||||
if (! $photo) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo not found',
|
||||
'The specified photo could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$style = $this->resolveStyle($request->input('style_id'), $request->input('style_key'));
|
||||
if (! $style) {
|
||||
return ApiError::response(
|
||||
'style_not_found',
|
||||
'Style not found',
|
||||
'The selected style is not available.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->runtimeConfig->isEnabled()) {
|
||||
return ApiError::response(
|
||||
'feature_disabled',
|
||||
'Feature disabled',
|
||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return ApiError::response(
|
||||
'feature_locked',
|
||||
'Feature locked',
|
||||
$this->entitlements->lockedMessage(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'required_feature' => $entitlement['required_feature'],
|
||||
'addon_keys' => $entitlement['addon_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$policy = $this->eventPolicy->resolve($event);
|
||||
if (! $policy['enabled']) {
|
||||
return ApiError::response(
|
||||
'event_feature_disabled',
|
||||
'Feature disabled for this event',
|
||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
|
||||
if (! $budgetDecision['allowed']) {
|
||||
return ApiError::response(
|
||||
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
|
||||
'Budget limit reached',
|
||||
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
[
|
||||
'budget' => $budgetDecision['budget'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
|
||||
return ApiError::response(
|
||||
'style_not_allowed',
|
||||
'Style not allowed',
|
||||
$policy['policy_message'] ?? 'This style is not allowed for this event.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
[
|
||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$prompt = (string) ($request->input('prompt') ?: $style->prompt_template ?: '');
|
||||
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style->negative_prompt_template ?: '');
|
||||
$providerModel = $request->input('provider_model') ?: $style->provider_model;
|
||||
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
|
||||
$requestedByUserId = $request->user()?->id;
|
||||
$scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user';
|
||||
$abuseSignal = null;
|
||||
$safetyReasons = $safetyDecision->reasonCodes;
|
||||
if ($safetyDecision->blocked) {
|
||||
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
|
||||
(int) $event->tenant_id,
|
||||
(int) $event->id,
|
||||
$scopeKey
|
||||
);
|
||||
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
|
||||
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = (array) $request->input('metadata', []);
|
||||
$styleMetadata = is_array($style->metadata) ? $style->metadata : [];
|
||||
$styleRunwareMetadata = Arr::get($styleMetadata, 'runware');
|
||||
if (is_array($styleRunwareMetadata)) {
|
||||
$metadata['runware'] = $styleRunwareMetadata;
|
||||
}
|
||||
|
||||
if (is_array($abuseSignal)) {
|
||||
$metadata['abuse'] = $abuseSignal;
|
||||
}
|
||||
$metadata['budget'] = $budgetDecision['budget'];
|
||||
|
||||
$idempotencyKey = $this->resolveIdempotencyKey(
|
||||
$request->input('idempotency_key'),
|
||||
$request->header('X-Idempotency-Key'),
|
||||
$event,
|
||||
$photo,
|
||||
$style,
|
||||
$prompt,
|
||||
$requestedByUserId
|
||||
);
|
||||
|
||||
$editRequest = AiEditRequest::query()->firstOrCreate(
|
||||
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'requested_by_user_id' => $requestedByUserId,
|
||||
'provider' => $this->runtimeConfig->defaultProvider(),
|
||||
'provider_model' => $providerModel,
|
||||
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
|
||||
'safety_state' => $safetyDecision->state,
|
||||
'prompt' => $prompt,
|
||||
'negative_prompt' => $negativePrompt,
|
||||
'input_image_path' => $photo->file_path,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'safety_reasons' => $safetyReasons,
|
||||
'failure_code' => $safetyDecision->failureCode,
|
||||
'failure_message' => $safetyDecision->failureMessage,
|
||||
'queued_at' => now(),
|
||||
'completed_at' => $safetyDecision->blocked ? now() : null,
|
||||
'metadata' => $metadata,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
|
||||
$editRequest,
|
||||
$event,
|
||||
$photo,
|
||||
$style,
|
||||
$prompt,
|
||||
$negativePrompt,
|
||||
$providerModel,
|
||||
$requestedByUserId
|
||||
)) {
|
||||
return ApiError::response(
|
||||
'idempotency_conflict',
|
||||
'Idempotency conflict',
|
||||
'The provided idempotency key is already in use for another request.',
|
||||
Response::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
$editRequest->wasRecentlyCreated
|
||||
&& ! $safetyDecision->blocked
|
||||
&& $this->runtimeConfig->queueAutoDispatch()
|
||||
) {
|
||||
ProcessAiEditRequest::dispatch($editRequest->id)
|
||||
->onQueue($this->runtimeConfig->queueName());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
|
||||
'duplicate' => ! $editRequest->wasRecentlyCreated,
|
||||
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
|
||||
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $eventSlug, int $aiEditRequest): JsonResponse
|
||||
{
|
||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
||||
|
||||
$editRequest = AiEditRequest::query()
|
||||
->with(['style', 'outputs'])
|
||||
->whereKey($aiEditRequest)
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
|
||||
if (! $editRequest) {
|
||||
return ApiError::response(
|
||||
'edit_request_not_found',
|
||||
'Edit request not found',
|
||||
'The specified AI edit request could not be located for this event.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->serializeRequest($editRequest),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveTenantEventOrFail(Request $request, string $eventSlug): Event
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
return Event::query()
|
||||
->where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function resolveStyle(mixed $styleId, mixed $styleKey): ?AiStyle
|
||||
{
|
||||
if ($styleId !== null) {
|
||||
return AiStyle::query()
|
||||
->whereKey((int) $styleId)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
$key = trim((string) ($styleKey ?? ''));
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AiStyle::query()
|
||||
->where('key', $key)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveIdempotencyKey(
|
||||
mixed $bodyKey,
|
||||
mixed $headerKey,
|
||||
Event $event,
|
||||
Photo $photo,
|
||||
AiStyle $style,
|
||||
string $prompt,
|
||||
mixed $requestedByUserId
|
||||
): string {
|
||||
$candidate = trim((string) ($bodyKey ?: $headerKey ?: ''));
|
||||
if ($candidate !== '') {
|
||||
return Str::limit($candidate, 120, '');
|
||||
}
|
||||
|
||||
return substr(hash('sha256', implode('|', [
|
||||
(string) $event->id,
|
||||
(string) $photo->id,
|
||||
(string) $style->id,
|
||||
trim($prompt),
|
||||
(string) ($this->normalizeUserId($requestedByUserId)),
|
||||
])), 0, 120);
|
||||
}
|
||||
|
||||
private function normalizeUserId(mixed $userId): ?string
|
||||
{
|
||||
if (! is_int($userId) && ! is_string($userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $userId);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function normalizeOptionalString(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed !== '' ? $trimmed : null;
|
||||
}
|
||||
|
||||
private function isIdempotencyConflict(
|
||||
AiEditRequest $request,
|
||||
Event $event,
|
||||
Photo $photo,
|
||||
AiStyle $style,
|
||||
string $prompt,
|
||||
string $negativePrompt,
|
||||
?string $providerModel,
|
||||
mixed $requestedByUserId
|
||||
): bool {
|
||||
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) ($request->style_id ?? 0) !== (int) $style->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->normalizeUserId($request->requested_by_user_id) !== $this->normalizeUserId($requestedByUserId);
|
||||
}
|
||||
|
||||
private function serializeStyle(AiStyle $style): array
|
||||
{
|
||||
return [
|
||||
'id' => $style->id,
|
||||
'key' => $style->key,
|
||||
'name' => $style->name,
|
||||
'version' => $style->version,
|
||||
'category' => $style->category,
|
||||
'description' => $style->description,
|
||||
'provider' => $style->provider,
|
||||
'provider_model' => $style->provider_model,
|
||||
'requires_source_image' => $style->requires_source_image,
|
||||
'is_premium' => $style->is_premium,
|
||||
'metadata' => $style->metadata ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeRequest(AiEditRequest $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $request->id,
|
||||
'event_id' => $request->event_id,
|
||||
'photo_id' => $request->photo_id,
|
||||
'style' => $request->style ? [
|
||||
'id' => $request->style->id,
|
||||
'key' => $request->style->key,
|
||||
'name' => $request->style->name,
|
||||
] : null,
|
||||
'provider' => $request->provider,
|
||||
'provider_model' => $request->provider_model,
|
||||
'status' => $request->status,
|
||||
'safety_state' => $request->safety_state,
|
||||
'safety_reasons' => $request->safety_reasons ?? [],
|
||||
'failure_code' => $request->failure_code,
|
||||
'failure_message' => $request->failure_message,
|
||||
'queued_at' => $request->queued_at?->toIso8601String(),
|
||||
'started_at' => $request->started_at?->toIso8601String(),
|
||||
'completed_at' => $request->completed_at?->toIso8601String(),
|
||||
'outputs' => $request->outputs->map(fn ($output) => [
|
||||
'id' => $output->id,
|
||||
'storage_disk' => $output->storage_disk,
|
||||
'storage_path' => $output->storage_path,
|
||||
'provider_url' => $output->provider_url,
|
||||
'url' => $this->resolveOutputUrl(
|
||||
$output->storage_disk,
|
||||
$output->storage_path,
|
||||
$output->provider_url
|
||||
),
|
||||
'mime_type' => $output->mime_type,
|
||||
'width' => $output->width,
|
||||
'height' => $output->height,
|
||||
'is_primary' => $output->is_primary,
|
||||
'safety_state' => $output->safety_state,
|
||||
'safety_reasons' => $output->safety_reasons ?? [],
|
||||
'generated_at' => $output->generated_at?->toIso8601String(),
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
|
||||
{
|
||||
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
|
||||
if ($resolvedStoragePath !== null) {
|
||||
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
|
||||
return $resolvedStoragePath;
|
||||
}
|
||||
|
||||
$disk = $this->resolveStorageDisk($storageDisk);
|
||||
|
||||
try {
|
||||
return Storage::disk($disk)->url($resolvedStoragePath);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::debug('Falling back to raw AI output storage path', [
|
||||
'disk' => $disk,
|
||||
'path' => $resolvedStoragePath,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return '/'.ltrim($resolvedStoragePath, '/');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeOptionalString($providerUrl);
|
||||
}
|
||||
|
||||
private function resolveStorageDisk(?string $disk): string
|
||||
{
|
||||
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
|
||||
|
||||
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
|
||||
return (string) config('filesystems.default', 'public');
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Services\Addons\EventAddonCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
@@ -13,25 +12,9 @@ class EventAddonCatalogController extends Controller
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$provider = config('package-addons.provider')
|
||||
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL);
|
||||
|
||||
$addons = collect($this->catalog->all())
|
||||
->map(function (array $addon, string $key) use ($provider): array {
|
||||
$priceId = $provider === CheckoutSession::PROVIDER_PAYPAL
|
||||
? ($addon['price'] ?? null ? 'paypal' : null)
|
||||
: ($addon['variant_id'] ?? null);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $addon['label'] ?? null,
|
||||
'price_id' => $priceId,
|
||||
'increments' => $addon['increments'] ?? [],
|
||||
'price' => $addon['price'] ?? null,
|
||||
'currency' => $addon['currency'] ?? 'EUR',
|
||||
];
|
||||
})
|
||||
->filter(fn (array $addon) => ! empty($addon['price_id']))
|
||||
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
|
||||
use App\Http\Requests\Tenant\EventAddonPurchaseLookupRequest;
|
||||
use App\Http\Requests\Tenant\EventAddonRequest;
|
||||
use App\Http\Resources\Tenant\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Addons\EventAddonCheckoutService;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class EventAddonController extends Controller
|
||||
{
|
||||
@@ -52,7 +52,7 @@ class EventAddonController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function purchase(EventAddonPurchaseLookupRequest $request, Event $event): JsonResponse
|
||||
public function apply(EventAddonRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
@@ -66,85 +66,49 @@ class EventAddonController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$addonIntent = trim((string) ($validated['addon_intent'] ?? ''));
|
||||
$checkoutId = trim((string) ($validated['checkout_id'] ?? ''));
|
||||
$addonKey = trim((string) ($validated['addon_key'] ?? ''));
|
||||
$eventPackage = $event->eventPackage;
|
||||
|
||||
$baseQuery = EventPackageAddon::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('event_id', $event->id)
|
||||
->with(['event:id,name,slug']);
|
||||
|
||||
$addon = null;
|
||||
|
||||
if ($addonIntent !== '') {
|
||||
$addon = (clone $baseQuery)
|
||||
->where('metadata->addon_intent', $addonIntent)
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $addon && $checkoutId !== '') {
|
||||
$addon = (clone $baseQuery)
|
||||
->where('checkout_id', $checkoutId)
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $addon && $addonKey !== '') {
|
||||
$addon = (clone $baseQuery)
|
||||
->where('addon_key', $addonKey)
|
||||
->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end")
|
||||
if (! $eventPackage && method_exists($event, 'eventPackages')) {
|
||||
$eventPackage = $event->eventPackages()
|
||||
->with('package')
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $addon) {
|
||||
$addon = (clone $baseQuery)
|
||||
->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end")
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $addon) {
|
||||
if (! $eventPackage) {
|
||||
return ApiError::response(
|
||||
'addon_not_found',
|
||||
'Add-on purchase not found',
|
||||
__('Der Add-on Kauf wurde nicht gefunden.'),
|
||||
404,
|
||||
'event_package_missing',
|
||||
'Event package missing',
|
||||
__('Kein Paket ist diesem Event zugeordnet.'),
|
||||
409,
|
||||
['event_slug' => $event->slug ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$label = Arr::get($addon->metadata ?? [], 'label') ?? $addon->addon_key;
|
||||
$data = $request->validated();
|
||||
|
||||
$eventPackage->fill([
|
||||
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($data['extra_photos'] ?? 0),
|
||||
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($data['extra_guests'] ?? 0),
|
||||
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($data['extend_gallery_days'] ?? 0),
|
||||
]);
|
||||
|
||||
if (isset($data['extend_gallery_days'])) {
|
||||
$base = $eventPackage->gallery_expires_at ?? Carbon::now();
|
||||
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) $data['extend_gallery_days']);
|
||||
}
|
||||
|
||||
$eventPackage->save();
|
||||
|
||||
$event->load([
|
||||
'eventPackage.package',
|
||||
'eventPackages.package',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'id' => $addon->id,
|
||||
'addon_key' => $addon->addon_key,
|
||||
'label' => $label,
|
||||
'quantity' => (int) ($addon->quantity ?? 1),
|
||||
'status' => $addon->status,
|
||||
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
||||
'currency' => $addon->currency,
|
||||
'extra_photos' => (int) $addon->extra_photos,
|
||||
'extra_guests' => (int) $addon->extra_guests,
|
||||
'extra_gallery_days' => (int) $addon->extra_gallery_days,
|
||||
'purchased_at' => $addon->purchased_at?->toIso8601String(),
|
||||
'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'),
|
||||
'checkout_id' => $addon->checkout_id,
|
||||
'transaction_id' => $addon->transaction_id,
|
||||
'created_at' => $addon->created_at?->toIso8601String(),
|
||||
'addon_intent' => Arr::get($addon->metadata ?? [], 'addon_intent'),
|
||||
'event' => $addon->event ? [
|
||||
'id' => $addon->event->id,
|
||||
'slug' => $addon->event->slug,
|
||||
'name' => $addon->event->name,
|
||||
] : null,
|
||||
],
|
||||
'message' => __('Add-ons applied successfully.'),
|
||||
'data' => new EventResource($event),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,14 +110,7 @@ class EventController extends Controller
|
||||
$tenantPackage = $tenant->tenantPackages()
|
||||
->with('package')
|
||||
->where('active', true)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer'))
|
||||
->withCount('eventPackages')
|
||||
->orderBy('event_packages_count')
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$package = null;
|
||||
@@ -156,7 +149,6 @@ class EventController extends Controller
|
||||
$eventServicePackage = $billingIsReseller
|
||||
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
||||
: $package;
|
||||
$sourceTenantPackage = $billingIsReseller ? $billingTenantPackage : $tenantPackage;
|
||||
|
||||
$requiresWaiver = $package->isEndcustomer();
|
||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||
@@ -224,13 +216,12 @@ class EventController extends Controller
|
||||
|
||||
$eventData = Arr::only($eventData, $allowed);
|
||||
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin, $sourceTenantPackage) {
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $eventServicePackage->id,
|
||||
'tenant_package_id' => $sourceTenantPackage?->id,
|
||||
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => $eventServicePackage->gallery_days
|
||||
@@ -256,13 +247,11 @@ class EventController extends Controller
|
||||
$tenant->refresh();
|
||||
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
||||
|
||||
$activeResellerPackage = $tenant->getActiveResellerPackage();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event created successfully',
|
||||
'data' => new EventResource($event),
|
||||
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
|
||||
'remaining_events' => $activeResellerPackage?->remaining_events ?? 0,
|
||||
'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0,
|
||||
], 201);
|
||||
}
|
||||
|
||||
@@ -893,16 +882,9 @@ class EventController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event);
|
||||
$expiresAtRules = ['nullable', 'date', 'after:now'];
|
||||
|
||||
if ($minimumExpiry) {
|
||||
$expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'label' => ['nullable', 'string', 'max:255'],
|
||||
'expires_at' => $expiresAtRules,
|
||||
'expires_at' => ['nullable', 'date', 'after:now'],
|
||||
'usage_limit' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class EventJoinTokenController extends Controller
|
||||
{
|
||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||
|
||||
$validated = $this->validatePayload($request, $event);
|
||||
$validated = $this->validatePayload($request);
|
||||
|
||||
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
|
||||
'created_by' => Auth::id(),
|
||||
@@ -52,7 +52,7 @@ class EventJoinTokenController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $this->validatePayload($request, $event, true);
|
||||
$validated = $this->validatePayload($request, true);
|
||||
|
||||
$payload = [];
|
||||
|
||||
@@ -115,18 +115,11 @@ class EventJoinTokenController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
private function validatePayload(Request $request, Event $event, bool $partial = false): array
|
||||
private function validatePayload(Request $request, bool $partial = false): array
|
||||
{
|
||||
$minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event);
|
||||
$expiresAtRules = [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'];
|
||||
|
||||
if ($minimumExpiry) {
|
||||
$expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
|
||||
'expires_at' => $expiresAtRules,
|
||||
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
|
||||
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
|
||||
'metadata.layout_customization' => ['nullable', 'array'],
|
||||
|
||||
@@ -53,7 +53,6 @@ class LiveShowLinkController extends Controller
|
||||
'url' => $url,
|
||||
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
|
||||
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
|
||||
'expires_at' => $event->live_show_token_expires_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ use App\Support\WatermarkConfigResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -116,7 +115,6 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -323,7 +321,7 @@ class PhotoController extends Controller
|
||||
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
||||
|
||||
// Generate unique filename
|
||||
$extension = $this->resolvePhotoExtension($file);
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = Str::uuid().'.'.$extension;
|
||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||
|
||||
@@ -565,7 +563,6 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -782,7 +779,6 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
$photos = Photo::where('event_id', $event->id)
|
||||
->where('status', 'pending')
|
||||
@@ -1047,23 +1043,4 @@ class PhotoController extends Controller
|
||||
|
||||
return array_values(array_unique(array_filter($candidates)));
|
||||
}
|
||||
|
||||
private function resolvePhotoExtension(UploadedFile $file): string
|
||||
{
|
||||
$extension = strtolower((string) $file->extension());
|
||||
|
||||
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
}
|
||||
|
||||
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||
$extension = match ($file->getMimeType()) {
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
default => 'jpg',
|
||||
};
|
||||
}
|
||||
|
||||
return $extension === 'jpeg' ? 'jpg' : $extension;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,8 @@ class TenantAdminTokenController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
$tenant->loadMissing('activeResellerPackage');
|
||||
|
||||
$user = $request->user();
|
||||
$abilities = $user?->currentAccessToken()?->abilities ?? [];
|
||||
|
||||
@@ -129,7 +131,7 @@ class TenantAdminTokenController extends Controller
|
||||
$fullName = trim($first.' '.$last) ?: null;
|
||||
}
|
||||
|
||||
$activePackage = $tenant->getActiveResellerPackage();
|
||||
$activePackage = $tenant->activeResellerPackage;
|
||||
|
||||
return response()->json([
|
||||
'id' => $tenant->id,
|
||||
|
||||
@@ -3,27 +3,22 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\BillingAddonHistoryRequest;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Services\Addons\EventAddonCatalog;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleCustomerPortalService;
|
||||
use App\Services\Paddle\PaddleCustomerService;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantBillingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LemonSqueezySubscriptionService $subscriptions,
|
||||
private readonly EventAddonCatalog $addonCatalog,
|
||||
private readonly PaddleTransactionService $paddleTransactions,
|
||||
private readonly PaddleCustomerService $paddleCustomers,
|
||||
private readonly PaddleCustomerPortalService $portalSessions,
|
||||
) {}
|
||||
|
||||
public function transactions(Request $request): JsonResponse
|
||||
@@ -37,49 +32,54 @@ class TenantBillingController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
|
||||
if (! $tenant->paddle_customer_id) {
|
||||
try {
|
||||
$this->paddleCustomers->ensureCustomerId($tenant);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to resolve Paddle customer for tenant', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$paginator = PackagePurchase::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->with(['package'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Failed to resolve Paddle customer.',
|
||||
], 502);
|
||||
}
|
||||
}
|
||||
|
||||
$data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) {
|
||||
$totals = $this->resolvePurchaseTotals($purchase);
|
||||
$transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey();
|
||||
$cursor = $request->query('cursor');
|
||||
$perPage = (int) $request->query('per_page', 25);
|
||||
|
||||
return [
|
||||
'id' => $purchase->getKey(),
|
||||
'status' => $purchase->refunded ? 'refunded' : 'completed',
|
||||
'amount' => $totals['total'],
|
||||
'currency' => $totals['currency'],
|
||||
'tax' => $totals['tax'],
|
||||
'provider' => $purchase->provider ?? 'paypal',
|
||||
'provider_id' => $transactionId,
|
||||
'package_name' => $this->resolvePackageName($purchase, $locale),
|
||||
'purchased_at' => $purchase->purchased_at?->toIso8601String(),
|
||||
'receipt_url' => route('api.v1.tenant.billing.transactions.receipt', [
|
||||
'purchase' => $purchase->getKey(),
|
||||
], absolute: false),
|
||||
];
|
||||
})->values();
|
||||
$query = [
|
||||
'per_page' => max(1, min($perPage, 100)),
|
||||
];
|
||||
|
||||
if ($cursor) {
|
||||
$query['after'] = $cursor;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to load Paddle transactions', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Failed to load Paddle transactions.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
'data' => $result['data'],
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function addons(BillingAddonHistoryRequest $request): JsonResponse
|
||||
public function addons(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
@@ -90,63 +90,21 @@ class TenantBillingController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
$perPage = max(1, min((int) $request->validated('per_page', 25), 100));
|
||||
$page = max(1, (int) $request->validated('page', 1));
|
||||
$eventId = $request->validated('event_id');
|
||||
$eventSlug = $request->validated('event_slug');
|
||||
$status = $request->validated('status');
|
||||
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$scopeEvent = null;
|
||||
if ($eventId !== null || $eventSlug !== null) {
|
||||
$scopeEventQuery = Event::query()
|
||||
->where('tenant_id', $tenant->id);
|
||||
|
||||
if ($eventId !== null) {
|
||||
$scopeEventQuery->whereKey((int) $eventId);
|
||||
} elseif (is_string($eventSlug) && trim($eventSlug) !== '') {
|
||||
$scopeEventQuery->where('slug', $eventSlug);
|
||||
}
|
||||
|
||||
$scopeEvent = $scopeEventQuery->first();
|
||||
|
||||
if (! $scopeEvent) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Event scope not found.',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
$query = EventPackageAddon::query()
|
||||
$paginator = EventPackageAddon::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->with(['event:id,name,slug']);
|
||||
|
||||
if ($scopeEvent) {
|
||||
$query->where('event_id', $scopeEvent->id);
|
||||
}
|
||||
|
||||
if (is_string($status) && $status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->with(['event:id,name,slug'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$addonLabels = collect($this->addonCatalog->all())
|
||||
->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null])
|
||||
->all();
|
||||
|
||||
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) use ($addonLabels) {
|
||||
$label = $addon->metadata['label']
|
||||
?? ($addonLabels[$addon->addon_key] ?? null)
|
||||
?? $addon->addon_key;
|
||||
|
||||
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
|
||||
return [
|
||||
'id' => $addon->id,
|
||||
'addon_key' => $addon->addon_key,
|
||||
'label' => $label,
|
||||
'label' => $addon->metadata['label'] ?? null,
|
||||
'quantity' => (int) ($addon->quantity ?? 1),
|
||||
'status' => $addon->status,
|
||||
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
||||
@@ -171,17 +129,6 @@ class TenantBillingController extends Controller
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'scope' => $scopeEvent ? [
|
||||
'type' => 'event',
|
||||
'event' => [
|
||||
'id' => $scopeEvent->id,
|
||||
'slug' => $scopeEvent->slug,
|
||||
'name' => $scopeEvent->name,
|
||||
],
|
||||
] : [
|
||||
'type' => 'tenant',
|
||||
'event' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -196,64 +143,68 @@ class TenantBillingController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
$subscriptionId = null;
|
||||
$customerId = null;
|
||||
|
||||
try {
|
||||
$subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
|
||||
if (! $subscriptionId) {
|
||||
return response()->json([
|
||||
'message' => 'No active subscription found.',
|
||||
], 404);
|
||||
}
|
||||
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
|
||||
|
||||
Log::debug('Fetching Lemon Squeezy subscription portal URL', [
|
||||
Log::debug('Creating Paddle customer portal session', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId,
|
||||
'paddle_customer_id' => $customerId,
|
||||
'paddle_environment' => config('paddle.environment'),
|
||||
'paddle_base_url' => config('paddle.base_url'),
|
||||
]);
|
||||
|
||||
$subscription = $this->subscriptions->retrieve($subscriptionId);
|
||||
$session = $this->portalSessions->createSession($customerId);
|
||||
} catch (\Throwable $exception) {
|
||||
$context = [
|
||||
'tenant_id' => $tenant->id,
|
||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
||||
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||
'error' => $exception->getMessage(),
|
||||
'paddle_environment' => config('paddle.environment'),
|
||||
'paddle_base_url' => config('paddle.base_url'),
|
||||
];
|
||||
|
||||
if ($exception instanceof LemonSqueezyException) {
|
||||
$context['lemonsqueezy_status'] = $exception->status();
|
||||
$context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0');
|
||||
$context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors');
|
||||
$context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id');
|
||||
if ($exception instanceof PaddleException) {
|
||||
$context['paddle_status'] = $exception->status();
|
||||
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code');
|
||||
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message');
|
||||
$context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail');
|
||||
$context['paddle_error_doc_url'] = Arr::get($exception->context(), 'error.documentation_url');
|
||||
$context['paddle_request_id'] = Arr::get($exception->context(), 'meta.request_id');
|
||||
$context['paddle_errors'] = Arr::get($exception->context(), 'error.errors');
|
||||
}
|
||||
|
||||
Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
|
||||
Log::warning('Failed to create Paddle customer portal session', [
|
||||
...$context,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
|
||||
'message' => 'Failed to create Paddle customer portal session.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
$url = $this->subscriptions->portalUrl($subscription)
|
||||
?? $this->subscriptions->updatePaymentMethodUrl($subscription);
|
||||
$url = Arr::get($session, 'data.urls.general.overview')
|
||||
?? Arr::get($session, 'data.urls.general')
|
||||
?? Arr::get($session, 'urls.general.overview')
|
||||
?? Arr::get($session, 'urls.general');
|
||||
|
||||
if (! $url) {
|
||||
$sessionData = Arr::get($subscription, 'data');
|
||||
$sessionUrls = Arr::get($subscription, 'attributes.urls');
|
||||
$sessionData = Arr::get($session, 'data');
|
||||
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls');
|
||||
|
||||
Log::warning('Lemon Squeezy subscription missing portal URL', [
|
||||
Log::warning('Paddle customer portal session missing URL', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
||||
'subscription_keys' => array_keys($subscription),
|
||||
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||
'paddle_environment' => config('paddle.environment'),
|
||||
'paddle_base_url' => config('paddle.base_url'),
|
||||
'session_keys' => array_keys($session),
|
||||
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
|
||||
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Lemon Squeezy subscription missing portal URL.',
|
||||
'message' => 'Paddle customer portal session missing URL.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
@@ -261,184 +212,4 @@ class TenantBillingController extends Controller
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function receipt(Request $request, PackagePurchase $purchase): Response
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (! $tenant || (int) $purchase->tenant_id !== (int) $tenant->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$purchase->loadMissing(['tenant.user', 'package']);
|
||||
|
||||
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
|
||||
app()->setLocale($locale);
|
||||
|
||||
$totals = $this->resolvePurchaseTotals($purchase);
|
||||
$currency = $totals['currency'];
|
||||
$total = $totals['total'];
|
||||
$tax = $totals['tax'];
|
||||
|
||||
$buyer = $purchase->tenant?->user;
|
||||
$buyerName = $buyer?->full_name ?? $buyer?->name ?? $buyer?->email ?? '';
|
||||
$buyerEmail = $buyer?->email ?? '';
|
||||
$buyerAddress = $buyer?->address ?? '';
|
||||
|
||||
$packageName = $this->resolvePackageName($purchase, $locale);
|
||||
$packageTypeLabel = $this->resolvePackageTypeLabel($purchase->package?->type);
|
||||
$providerLabel = $this->resolveProviderLabel($purchase->provider);
|
||||
|
||||
$purchaseDate = $this->formatDate($purchase->purchased_at, $locale);
|
||||
$amountFormatted = $this->formatCurrency($total, $currency, $locale);
|
||||
$taxFormatted = $tax !== null ? $this->formatCurrency($tax, $currency, $locale) : null;
|
||||
$totalFormatted = $amountFormatted;
|
||||
|
||||
$html = view('billing.receipt', [
|
||||
'receiptNumber' => (string) $purchase->getKey(),
|
||||
'purchaseDate' => $purchaseDate,
|
||||
'packageName' => $packageName,
|
||||
'packageTypeLabel' => $packageTypeLabel,
|
||||
'providerLabel' => $providerLabel,
|
||||
'orderId' => $purchase->provider_id ?? $purchase->getKey(),
|
||||
'buyerName' => $buyerName,
|
||||
'buyerEmail' => $buyerEmail,
|
||||
'buyerAddress' => $buyerAddress,
|
||||
'amountFormatted' => $amountFormatted,
|
||||
'taxFormatted' => $taxFormatted,
|
||||
'totalFormatted' => $totalFormatted,
|
||||
'currency' => $currency,
|
||||
'companyName' => config('app.name', 'Fotospiel'),
|
||||
'companyEmail' => config('mail.from.address', 'info@fotospiel.app'),
|
||||
])->render();
|
||||
|
||||
$options = new Options;
|
||||
$options->set('isHtml5ParserEnabled', true);
|
||||
$options->set('isRemoteEnabled', true);
|
||||
$options->set('defaultFont', 'Helvetica');
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->render();
|
||||
|
||||
$pdfBinary = $dompdf->output();
|
||||
$filenameStem = Str::slug($packageName ?: 'receipt');
|
||||
|
||||
return response($pdfBinary)
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'inline; filename="receipt-'.$filenameStem.'.pdf"');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{currency: string, total: float, tax: float|null}
|
||||
*/
|
||||
private function resolvePurchaseTotals(PackagePurchase $purchase): array
|
||||
{
|
||||
$metadata = $purchase->metadata ?? [];
|
||||
$totals = $metadata['paypal_totals'] ?? $metadata['lemonsqueezy_totals'] ?? [];
|
||||
|
||||
$currency = $totals['currency']
|
||||
?? $metadata['currency']
|
||||
?? $purchase->package?->currency
|
||||
?? 'EUR';
|
||||
|
||||
$total = array_key_exists('total', $totals)
|
||||
? (float) $totals['total']
|
||||
: (float) $purchase->price;
|
||||
|
||||
$tax = array_key_exists('tax', $totals) ? (float) $totals['tax'] : null;
|
||||
|
||||
return [
|
||||
'currency' => strtoupper((string) $currency),
|
||||
'total' => round($total, 2),
|
||||
'tax' => $tax !== null ? round($tax, 2) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvePackageName(PackagePurchase $purchase, string $locale): string
|
||||
{
|
||||
$package = $purchase->package;
|
||||
if (! $package) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$localized = $package->getNameForLocale($locale);
|
||||
|
||||
return $localized ?: (string) $package->name;
|
||||
}
|
||||
|
||||
private function resolveProviderLabel(?string $provider): string
|
||||
{
|
||||
$provider = $provider ?: 'paypal';
|
||||
$labelKey = 'emails.purchase.provider.'.$provider;
|
||||
$label = __($labelKey);
|
||||
|
||||
if ($label === $labelKey) {
|
||||
return ucfirst($provider);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function resolvePackageTypeLabel(?string $type): string
|
||||
{
|
||||
$type = $type ?: 'endcustomer';
|
||||
$labelKey = 'emails.purchase.package_type.'.$type;
|
||||
$label = __($labelKey);
|
||||
|
||||
if ($label === $labelKey) {
|
||||
return ucfirst($type);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function formatCurrency(float $amount, string $currency, string $locale): string
|
||||
{
|
||||
$formatter = class_exists(\NumberFormatter::class)
|
||||
? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY)
|
||||
: null;
|
||||
|
||||
if ($formatter) {
|
||||
$formatted = $formatter->formatCurrency($amount, $currency);
|
||||
if ($formatted !== false) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
$symbol = match (strtoupper($currency)) {
|
||||
'EUR' => '€',
|
||||
'USD' => '$',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amount, 2, ',', '.');
|
||||
}
|
||||
|
||||
private function formatDate(?\Carbon\CarbonInterface $date, string $locale): string
|
||||
{
|
||||
if (! $date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$localized = $date->locale($locale);
|
||||
|
||||
if (str_starts_with($locale, 'en')) {
|
||||
return $localized->translatedFormat('F j, Y');
|
||||
}
|
||||
|
||||
return $localized->translatedFormat('d. F Y');
|
||||
}
|
||||
|
||||
private function mapLocale(string $locale): string
|
||||
{
|
||||
$normalized = strtolower(str_replace('_', '-', $locale));
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($normalized, 'de') => 'de_DE',
|
||||
str_starts_with($normalized, 'en') => 'en_US',
|
||||
default => 'de_DE',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,21 +31,18 @@ class TenantPackageController extends Controller
|
||||
->get();
|
||||
|
||||
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
|
||||
$linkedEventPackages = $this->resolveLinkedEventPackages($tenant->id, $packages->pluck('id')->all());
|
||||
|
||||
$packages->each(function (TenantPackage $package) use ($usageEventPackage, $linkedEventPackages): void {
|
||||
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
|
||||
$eventPackage = $package->active ? $usageEventPackage : null;
|
||||
$this->hydratePackageSnapshot($package, $eventPackage);
|
||||
$this->attachUsageEvents($package, $linkedEventPackages);
|
||||
});
|
||||
|
||||
$activePackage = $tenant->getActiveResellerPackage();
|
||||
$activePackage = $tenant->activeResellerPackage?->load('package');
|
||||
|
||||
if (! ($activePackage instanceof TenantPackage)) {
|
||||
$activePackage = $packages->firstWhere('active', true);
|
||||
} else {
|
||||
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
||||
$this->attachUsageEvents($activePackage, $linkedEventPackages);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -55,79 +52,6 @@ class TenantPackageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $tenantPackageIds
|
||||
* @return array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}>
|
||||
*/
|
||||
private function resolveLinkedEventPackages(int $tenantId, array $tenantPackageIds): array
|
||||
{
|
||||
if ($tenantPackageIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$eventPackages = EventPackage::query()
|
||||
->whereIn('tenant_package_id', $tenantPackageIds)
|
||||
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
->with(['event:id,slug,name,date,status'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->groupBy('tenant_package_id');
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($eventPackages as $tenantPackageId => $groupedPackages) {
|
||||
$current = $groupedPackages
|
||||
->first(function (EventPackage $eventPackage) {
|
||||
return $eventPackage->gallery_expires_at && $eventPackage->gallery_expires_at->isFuture();
|
||||
});
|
||||
|
||||
$result[(int) $tenantPackageId] = [
|
||||
'current' => $current,
|
||||
'last' => $groupedPackages->first(),
|
||||
'count' => $groupedPackages->count(),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}> $linkedEventPackages
|
||||
*/
|
||||
private function attachUsageEvents(TenantPackage $package, array $linkedEventPackages): void
|
||||
{
|
||||
$usage = $linkedEventPackages[$package->id] ?? null;
|
||||
|
||||
if (! $usage) {
|
||||
$package->linked_events_count = 0;
|
||||
$package->current_event = null;
|
||||
$package->last_event = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$package->linked_events_count = $usage['count'];
|
||||
$package->current_event = $this->formatLinkedEvent($usage['current']);
|
||||
$package->last_event = $this->formatLinkedEvent($usage['last']);
|
||||
}
|
||||
|
||||
private function formatLinkedEvent(?EventPackage $eventPackage): ?array
|
||||
{
|
||||
if (! $eventPackage || ! $eventPackage->event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $eventPackage->event->id,
|
||||
'slug' => $eventPackage->event->slug,
|
||||
'name' => $eventPackage->event->name,
|
||||
'status' => $eventPackage->event->status,
|
||||
'event_date' => $eventPackage->event->date?->toIso8601String(),
|
||||
'linked_at' => $eventPackage->purchased_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
|
||||
{
|
||||
$pkg = $package->package;
|
||||
|
||||
@@ -157,10 +157,6 @@ class AuthenticatedSessionController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($candidate, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($candidate, '/')) {
|
||||
return $candidate;
|
||||
}
|
||||
@@ -174,7 +170,7 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -226,7 +222,7 @@ class AuthenticatedSessionController extends Controller
|
||||
$scheme = $parsed['scheme'] ?? null;
|
||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
|
||||
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
@@ -269,15 +265,6 @@ class AuthenticatedSessionController extends Controller
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
|
||||
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -15,8 +15,8 @@ use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
@@ -74,11 +74,9 @@ class CheckoutController extends Controller
|
||||
'error' => $facebookError,
|
||||
'profile' => $facebookProfile,
|
||||
],
|
||||
'paypal' => [
|
||||
'client_id' => config('services.paypal.client_id'),
|
||||
'currency' => config('checkout.currency', 'EUR'),
|
||||
'intent' => 'capture',
|
||||
'locale' => app()->getLocale(),
|
||||
'paddle' => [
|
||||
'environment' => config('paddle.environment'),
|
||||
'client_token' => config('paddle.client_token'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -273,9 +271,9 @@ class CheckoutController extends Controller
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
LemonSqueezyOrderService $orders,
|
||||
PaddleTransactionService $transactions,
|
||||
): JsonResponse {
|
||||
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
||||
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
||||
|
||||
$session->refresh();
|
||||
|
||||
@@ -290,56 +288,56 @@ class CheckoutController extends Controller
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
LemonSqueezyOrderService $orders,
|
||||
PaddleTransactionService $transactions,
|
||||
): JsonResponse {
|
||||
$validated = $request->validated();
|
||||
$orderId = $validated['order_id'] ?? null;
|
||||
$transactionId = $validated['transaction_id'] ?? null;
|
||||
$checkoutId = $validated['checkout_id'] ?? null;
|
||||
|
||||
$metadata = $session->provider_metadata ?? [];
|
||||
$metadataUpdated = false;
|
||||
|
||||
if ($orderId) {
|
||||
$session->lemonsqueezy_order_id = $orderId;
|
||||
$metadata['lemonsqueezy_order_id'] = $orderId;
|
||||
if ($transactionId) {
|
||||
$session->paddle_transaction_id = $transactionId;
|
||||
$metadata['paddle_transaction_id'] = $transactionId;
|
||||
$metadataUpdated = true;
|
||||
}
|
||||
|
||||
if ($checkoutId) {
|
||||
$metadata['lemonsqueezy_checkout_id'] = $checkoutId;
|
||||
$metadata['paddle_checkout_id'] = $checkoutId;
|
||||
$metadataUpdated = true;
|
||||
}
|
||||
|
||||
if ($metadataUpdated) {
|
||||
$metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String();
|
||||
$metadata['paddle_client_event_at'] = now()->toIso8601String();
|
||||
$session->provider_metadata = $metadata;
|
||||
$session->save();
|
||||
}
|
||||
|
||||
if (app()->environment('local')
|
||||
&& $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY
|
||||
&& $session->provider === CheckoutSession::PROVIDER_PADDLE
|
||||
&& ! in_array($session->status, [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_FAILED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
], true)
|
||||
&& ($orderId || $checkoutId)
|
||||
&& ($transactionId || $checkoutId)
|
||||
) {
|
||||
$sessions->markProcessing($session, array_filter([
|
||||
'lemonsqueezy_status' => 'paid',
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(),
|
||||
'paddle_status' => 'completed',
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_local_confirmed_at' => now()->toIso8601String(),
|
||||
]));
|
||||
|
||||
$assignment->finalise($session, [
|
||||
'source' => 'lemonsqueezy_local',
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
'provider_reference' => $orderId ?? $checkoutId,
|
||||
'source' => 'paddle_local',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $transactionId ?? $checkoutId,
|
||||
]);
|
||||
|
||||
$sessions->markCompleted($session);
|
||||
} else {
|
||||
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
||||
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
||||
}
|
||||
|
||||
$session->refresh();
|
||||
@@ -421,13 +419,13 @@ class CheckoutController extends Controller
|
||||
return $price <= 0;
|
||||
}
|
||||
|
||||
private function attemptLemonSqueezyRecovery(
|
||||
private function attemptPaddleRecovery(
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
LemonSqueezyOrderService $orders
|
||||
PaddleTransactionService $transactions
|
||||
): void {
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -440,7 +438,7 @@ class CheckoutController extends Controller
|
||||
}
|
||||
|
||||
$metadata = $session->provider_metadata ?? [];
|
||||
$lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null;
|
||||
$lastPollAt = $metadata['paddle_poll_at'] ?? null;
|
||||
$now = now();
|
||||
|
||||
if ($lastPollAt) {
|
||||
@@ -454,31 +452,39 @@ class CheckoutController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null;
|
||||
$orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null;
|
||||
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null;
|
||||
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null;
|
||||
|
||||
if (! $checkoutId && ! $orderId) {
|
||||
Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [
|
||||
if (! $checkoutId && ! $transactionId) {
|
||||
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$metadata['lemonsqueezy_poll_at'] = $now->toIso8601String();
|
||||
$metadata['paddle_poll_at'] = $now->toIso8601String();
|
||||
$session->forceFill([
|
||||
'provider_metadata' => $metadata,
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$order = $orderId ? $orders->retrieve($orderId) : null;
|
||||
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null;
|
||||
|
||||
if (! $order && $checkoutId) {
|
||||
$order = $orders->findByCheckoutId($checkoutId);
|
||||
if (! $transaction && $checkoutId) {
|
||||
$transaction = $transactions->findByCheckoutId($checkoutId);
|
||||
}
|
||||
} catch (LemonSqueezyException $exception) {
|
||||
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
||||
|
||||
if (! $transaction) {
|
||||
$transaction = $transactions->findByCustomData([
|
||||
'checkout_session_id' => $session->id,
|
||||
'package_id' => (string) $session->package_id,
|
||||
'tenant_id' => (string) $session->tenant_id,
|
||||
]);
|
||||
}
|
||||
} catch (PaddleException $exception) {
|
||||
Log::warning('[Checkout] Paddle recovery failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'order_id' => $orderId,
|
||||
'transaction_id' => $transactionId,
|
||||
'status' => $exception->status(),
|
||||
'message' => $exception->getMessage(),
|
||||
'context' => $exception->context(),
|
||||
@@ -486,77 +492,77 @@ class CheckoutController extends Controller
|
||||
|
||||
return;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
||||
Log::warning('[Checkout] Paddle recovery failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'order_id' => $orderId,
|
||||
'transaction_id' => $transactionId,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $order) {
|
||||
Log::info('[Checkout] Lemon Squeezy recovery: order not found', [
|
||||
if (! $transaction) {
|
||||
Log::info('[Checkout] Paddle recovery: transaction not found', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'order_id' => $orderId,
|
||||
'transaction_id' => $transactionId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$status = strtolower((string) data_get($order, 'attributes.status', ''));
|
||||
$resolvedOrderId = $orderId ?: data_get($order, 'id');
|
||||
$status = strtolower((string) ($transaction['status'] ?? ''));
|
||||
$transactionId = $transactionId ?: ($transaction['id'] ?? null);
|
||||
|
||||
if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) {
|
||||
if ($transactionId && $session->paddle_transaction_id !== $transactionId) {
|
||||
$session->forceFill([
|
||||
'lemonsqueezy_order_id' => $resolvedOrderId,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if (in_array($status, ['paid', 'completed'], true)) {
|
||||
if ($status === 'completed') {
|
||||
$sessions->markProcessing($session, [
|
||||
'lemonsqueezy_status' => $status,
|
||||
'lemonsqueezy_order_id' => $resolvedOrderId,
|
||||
'lemonsqueezy_recovered_at' => $now->toIso8601String(),
|
||||
'paddle_status' => $status,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_recovered_at' => $now->toIso8601String(),
|
||||
]);
|
||||
|
||||
$assignment->finalise($session, [
|
||||
'source' => 'lemonsqueezy_poll',
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
'provider_reference' => $resolvedOrderId,
|
||||
'payload' => $order,
|
||||
'source' => 'paddle_poll',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $transactionId,
|
||||
'payload' => $transaction,
|
||||
]);
|
||||
|
||||
$sessions->markCompleted($session, $now);
|
||||
|
||||
Log::info('[Checkout] Lemon Squeezy session recovered via API', [
|
||||
Log::info('[Checkout] Paddle session recovered via API', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
'transaction_id' => $transactionId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) {
|
||||
$sessions->markFailed($session, 'lemonsqueezy_'.$status);
|
||||
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) {
|
||||
$sessions->markFailed($session, 'paddle_'.$status);
|
||||
|
||||
Log::info('[Checkout] Lemon Squeezy order failed', [
|
||||
Log::info('[Checkout] Paddle transaction failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
'transaction_id' => $transactionId,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('[Checkout] Lemon Squeezy order pending', [
|
||||
Log::info('[Checkout] Paddle transaction pending', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
'transaction_id' => $transactionId,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
@@ -42,7 +41,7 @@ class MarketingController extends Controller
|
||||
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $checkoutSessions,
|
||||
private readonly PayPalOrderService $paypalOrders,
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CouponService $coupons,
|
||||
private readonly GiftVoucherCheckoutService $giftVouchers,
|
||||
) {}
|
||||
@@ -195,6 +194,16 @@ class MarketingController extends Controller
|
||||
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
|
||||
}
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
|
||||
|
||||
return redirect()->route('packages', [
|
||||
'locale' => app()->getLocale(),
|
||||
'highlight' => $package->slug,
|
||||
])
|
||||
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||
}
|
||||
|
||||
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
||||
CheckoutRequestContext::fromRequest($request),
|
||||
[
|
||||
@@ -202,7 +211,7 @@ class MarketingController extends Controller
|
||||
]
|
||||
));
|
||||
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -214,71 +223,52 @@ class MarketingController extends Controller
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
])->save();
|
||||
|
||||
$appliedDiscountId = null;
|
||||
|
||||
if ($couponCode) {
|
||||
try {
|
||||
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
||||
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
|
||||
$request->session()->forget('marketing.checkout.coupon');
|
||||
} catch (ValidationException $exception) {
|
||||
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
|
||||
}
|
||||
}
|
||||
|
||||
$successUrl = route('marketing.success', [
|
||||
'locale' => app()->getLocale(),
|
||||
'packageId' => $package->id,
|
||||
]);
|
||||
$cancelUrl = route('packages', [
|
||||
'locale' => app()->getLocale(),
|
||||
'highlight' => $package->slug,
|
||||
]);
|
||||
|
||||
try {
|
||||
$checkout = $this->paypalOrders->createOrder($session, $package, [
|
||||
'return_url' => route('paypal.return', absolute: true),
|
||||
'cancel_url' => route('paypal.return', absolute: true),
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||
'success_url' => route('marketing.success', [
|
||||
'locale' => app()->getLocale(),
|
||||
'request_id' => $session->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
Log::warning('PayPal checkout failed', [
|
||||
'package_id' => $package->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'paypal' => __('marketing.packages.paypal_checkout_failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
$orderId = $checkout['id'] ?? null;
|
||||
if (! is_string($orderId) || $orderId === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'paypal' => __('marketing.packages.paypal_checkout_failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
$redirectUrl = $this->paypalOrders->resolveApproveUrl($checkout);
|
||||
'packageId' => $package->id,
|
||||
]),
|
||||
'return_url' => route('packages', [
|
||||
'locale' => app()->getLocale(),
|
||||
'highlight' => $package->slug,
|
||||
]),
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'coupon_code' => $couponCode,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => (bool) $session->accepted_terms_at,
|
||||
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
|
||||
],
|
||||
'discount_id' => $appliedDiscountId,
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'paypal_order_id' => $orderId,
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_status' => $checkout['status'] ?? null,
|
||||
'paypal_approve_url' => $redirectUrl,
|
||||
'paypal_success_url' => $successUrl,
|
||||
'paypal_cancel_url' => $cancelUrl,
|
||||
'paypal_created_at' => now()->toIso8601String(),
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
$this->checkoutSessions->markRequiresCustomerAction($session, 'paypal_approval');
|
||||
$redirectUrl = $checkout['checkout_url'] ?? null;
|
||||
|
||||
if (! $redirectUrl) {
|
||||
throw ValidationException::withMessages([
|
||||
'paypal' => __('marketing.packages.paypal_checkout_failed'),
|
||||
'paddle' => __('marketing.packages.paddle_checkout_failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -419,15 +409,7 @@ class MarketingController extends Controller
|
||||
public function demo()
|
||||
{
|
||||
$event = Event::query()
|
||||
->where(function ($query) {
|
||||
$query
|
||||
->where('settings->marketing_demo', true)
|
||||
->orWhere('settings->marketing_demo', 'true')
|
||||
->orWhere('settings->marketing_demo', '1')
|
||||
->orWhere('settings->demo', true)
|
||||
->orWhere('settings->demo', 'true')
|
||||
->orWhere('settings->demo', '1');
|
||||
})
|
||||
->where('settings->marketing_demo', true)
|
||||
->latest('id')
|
||||
->first();
|
||||
$joinToken = null;
|
||||
|
||||
@@ -2,27 +2,27 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest;
|
||||
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LemonSqueezyCheckoutController extends Controller
|
||||
class PaddleCheckoutController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LemonSqueezyCheckoutService $checkout,
|
||||
private readonly PaddleCheckoutService $checkout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function create(LemonSqueezyCheckoutRequest $request): JsonResponse
|
||||
public function create(PaddleCheckoutRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
@@ -35,8 +35,8 @@ class LemonSqueezyCheckoutController extends Controller
|
||||
|
||||
$package = Package::findOrFail((int) $data['package_id']);
|
||||
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||
@@ -46,7 +46,7 @@ class LemonSqueezyCheckoutController extends Controller
|
||||
]
|
||||
));
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -59,18 +59,44 @@ class LemonSqueezyCheckoutController extends Controller
|
||||
])->save();
|
||||
|
||||
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
|
||||
$discountId = null;
|
||||
|
||||
if ($couponCode !== '') {
|
||||
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
||||
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||
$discountId = $preview['coupon']->paddle_discount_id;
|
||||
}
|
||||
|
||||
if (app()->environment('local')) {
|
||||
$checkout = $this->simulateLocalCheckout($session, $package, $couponCode);
|
||||
if ($request->boolean('inline') && $discountId === null) {
|
||||
$metadata = array_merge($session->provider_metadata ?? [], [
|
||||
'mode' => 'inline',
|
||||
]);
|
||||
|
||||
return response()->json(array_merge($checkout, [
|
||||
$session->forceFill([
|
||||
'provider_metadata' => $metadata,
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'checkout_session_id' => $session->id,
|
||||
]));
|
||||
'mode' => 'inline',
|
||||
'items' => [
|
||||
[
|
||||
'priceId' => $package->paddle_price_id,
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'custom_data' => [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
'checkout_session_id' => (string) $session->id,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => '1',
|
||||
],
|
||||
'customer' => array_filter([
|
||||
'email' => $user->email,
|
||||
'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
$checkout = $this->checkout->createCheckout($tenant, $package, [
|
||||
@@ -82,17 +108,15 @@ class LemonSqueezyCheckoutController extends Controller
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
],
|
||||
'discount_code' => $couponCode ?: null,
|
||||
'customer_email' => $user?->email,
|
||||
'customer_name' => trim(($user?->first_name ?? '').' '.($user?->last_name ?? '')) ?: ($user?->name ?? null),
|
||||
'discount_id' => $discountId,
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
||||
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
@@ -101,36 +125,6 @@ class LemonSqueezyCheckoutController extends Controller
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{checkout_url: string|null, id: string, order_id: string, simulated: bool}
|
||||
*/
|
||||
protected function simulateLocalCheckout(CheckoutSession $session, Package $package, string $couponCode): array
|
||||
{
|
||||
$checkoutId = 'chk_'.Str::uuid();
|
||||
$orderId = 'order_'.Str::uuid();
|
||||
|
||||
$session->forceFill([
|
||||
'lemonsqueezy_checkout_id' => $checkoutId,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'lemonsqueezy_checkout_id' => $checkoutId,
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
'lemonsqueezy_status' => 'paid',
|
||||
'lemonsqueezy_local_simulated_at' => now()->toIso8601String(),
|
||||
'coupon_code' => $couponCode ?: null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'checkout_url' => route('marketing.success', [
|
||||
'locale' => app()->getLocale(),
|
||||
'packageId' => $package->id,
|
||||
], absolute: true),
|
||||
'id' => $checkoutId,
|
||||
'order_id' => $orderId,
|
||||
'simulated' => true,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveLegalVersion(): string
|
||||
{
|
||||
return config('app.legal_version', now()->toDateString());
|
||||
@@ -2,32 +2,35 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LemonSqueezyReturnController extends Controller
|
||||
class PaddleReturnController extends Controller
|
||||
{
|
||||
public function __construct(private readonly LemonSqueezyOrderService $orders) {}
|
||||
public function __construct(private readonly PaddleTransactionService $transactions) {}
|
||||
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$orderId = $this->resolveOrderId($request);
|
||||
$transactionId = $this->resolveTransactionId($request);
|
||||
$fallback = $this->resolveFallbackUrl();
|
||||
|
||||
if (! $orderId) {
|
||||
if (! $transactionId) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
try {
|
||||
$order = $this->orders->retrieve($orderId);
|
||||
} catch (LemonSqueezyException $exception) {
|
||||
Log::warning('Lemon Squeezy return failed to load order', [
|
||||
'order_id' => $orderId,
|
||||
$transaction = $this->transactions->retrieve($transactionId);
|
||||
} catch (PaddleException $exception) {
|
||||
Log::warning('Paddle return failed to load transaction', [
|
||||
'transaction_id' => $transactionId,
|
||||
'error' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
@@ -35,10 +38,10 @@ class LemonSqueezyReturnController extends Controller
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$customData = $this->extractCustomData($order);
|
||||
$status = Str::lower((string) Arr::get($order, 'attributes.status', ''));
|
||||
$customData = $this->extractCustomData($transaction);
|
||||
$status = Str::lower((string) ($transaction['status'] ?? ''));
|
||||
$successUrl = $customData['success_url'] ?? null;
|
||||
$cancelUrl = $customData['return_url'] ?? null;
|
||||
$cancelUrl = $customData['cancel_url'] ?? $customData['return_url'] ?? null;
|
||||
|
||||
$target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl;
|
||||
$target = $this->resolveSafeRedirect($target, $fallback);
|
||||
@@ -46,10 +49,11 @@ class LemonSqueezyReturnController extends Controller
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
protected function resolveOrderId(Request $request): ?string
|
||||
protected function resolveTransactionId(Request $request): ?string
|
||||
{
|
||||
$candidate = $request->query('order_id')
|
||||
?? $request->query('order');
|
||||
$candidate = $request->query('_ptxn')
|
||||
?? $request->query('ptxn')
|
||||
?? $request->query('transaction_id');
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
return null;
|
||||
@@ -64,19 +68,33 @@ class LemonSqueezyReturnController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $transaction
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractCustomData(array $order): array
|
||||
protected function extractCustomData(array $transaction): array
|
||||
{
|
||||
$customData = Arr::get($order, 'attributes.custom_data', []);
|
||||
$customData = Arr::get($transaction, 'custom_data', []);
|
||||
|
||||
return is_array($customData) ? $customData : [];
|
||||
if (! is_array($customData)) {
|
||||
$customData = [];
|
||||
}
|
||||
|
||||
$legacy = Arr::get($transaction, 'customData');
|
||||
if (is_array($legacy)) {
|
||||
$customData = array_merge($customData, $legacy);
|
||||
}
|
||||
|
||||
$metadata = Arr::get($transaction, 'metadata');
|
||||
if (is_array($metadata)) {
|
||||
$customData = array_merge($customData, $metadata);
|
||||
}
|
||||
|
||||
return $customData;
|
||||
}
|
||||
|
||||
protected function isSuccessStatus(string $status): bool
|
||||
{
|
||||
return in_array($status, ['paid', 'completed'], true);
|
||||
return in_array($status, ['completed', 'paid'], true);
|
||||
}
|
||||
|
||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LemonSqueezyWebhookController extends Controller
|
||||
class PaddleWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutWebhookService $webhooks,
|
||||
@@ -22,7 +22,7 @@ class LemonSqueezyWebhookController extends Controller
|
||||
{
|
||||
try {
|
||||
if (! $this->verify($request)) {
|
||||
Log::warning('Lemon Squeezy webhook signature verification failed');
|
||||
Log::warning('Paddle webhook signature verification failed');
|
||||
|
||||
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
@@ -33,27 +33,29 @@ class LemonSqueezyWebhookController extends Controller
|
||||
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
$eventType = $payload['meta']['event_name'] ?? $request->headers->get('X-Event-Name');
|
||||
$eventId = $payload['meta']['event_id'] ?? $payload['data']['id'] ?? null;
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
|
||||
$webhookEvent = $this->recorder->recordReceived(
|
||||
'lemonsqueezy',
|
||||
'paddle',
|
||||
$eventId ? (string) $eventId : null,
|
||||
$eventType ? (string) $eventType : null,
|
||||
);
|
||||
$handled = false;
|
||||
|
||||
$this->logDev('Lemon Squeezy webhook received', [
|
||||
$this->logDev('Paddle webhook received', [
|
||||
'event_type' => $eventType,
|
||||
'order_id' => data_get($payload, 'data.id'),
|
||||
'has_signature' => (string) $request->headers->get('X-Signature', '') !== '',
|
||||
'checkout_id' => data_get($payload, 'data.checkout_id'),
|
||||
'transaction_id' => data_get($payload, 'data.id'),
|
||||
'has_billing_signature' => (string) $request->headers->get('Paddle-Signature', '') !== '',
|
||||
'has_legacy_signature' => (string) $request->headers->get('Paddle-Webhook-Signature', '') !== '',
|
||||
]);
|
||||
|
||||
if ($eventType) {
|
||||
$handled = $this->webhooks->handleLemonSqueezyEvent($payload);
|
||||
$handled = $this->webhooks->handlePaddleEvent($payload);
|
||||
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
||||
}
|
||||
|
||||
Log::info('Lemon Squeezy webhook processed', [
|
||||
Log::info('Paddle webhook processed', [
|
||||
'event_type' => $eventType,
|
||||
'handled' => $handled,
|
||||
]);
|
||||
@@ -69,13 +71,13 @@ class LemonSqueezyWebhookController extends Controller
|
||||
} catch (\Throwable $exception) {
|
||||
$eventId = $this->captureWebhookException($exception);
|
||||
|
||||
Log::error('Lemon Squeezy webhook processing failed', [
|
||||
Log::error('Paddle webhook processing failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
'event_type' => (string) data_get($request->json()->all(), 'meta.event_name'),
|
||||
'event_type' => (string) $request->json('event_type'),
|
||||
'sentry_event_id' => $eventId,
|
||||
]);
|
||||
|
||||
$this->logDev('Lemon Squeezy webhook error payload', $this->reducePayload($request->json()->all()));
|
||||
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
|
||||
|
||||
if (isset($webhookEvent)) {
|
||||
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
|
||||
@@ -87,33 +89,85 @@ class LemonSqueezyWebhookController extends Controller
|
||||
|
||||
protected function verify(Request $request): bool
|
||||
{
|
||||
$secret = config('lemonsqueezy.webhook_secret');
|
||||
$secret = config('paddle.webhook_secret');
|
||||
|
||||
if (! $secret) {
|
||||
// Allow processing in sandbox or when secret not configured
|
||||
return true;
|
||||
}
|
||||
|
||||
$signature = (string) $request->headers->get('X-Signature', '');
|
||||
$billingSignature = (string) $request->headers->get('Paddle-Signature', '');
|
||||
|
||||
if ($billingSignature !== '') {
|
||||
$parts = $this->parseSignatureHeader($billingSignature);
|
||||
$timestamp = $parts['ts'] ?? null;
|
||||
$hash = $parts['h1'] ?? null;
|
||||
|
||||
if (! $timestamp || ! $hash) {
|
||||
$this->logDev('Paddle webhook signature missing parts', [
|
||||
'has_timestamp' => (bool) $timestamp,
|
||||
'has_hash' => (bool) $hash,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$expected = hash_hmac('sha256', $timestamp.':'.$payload, $secret);
|
||||
|
||||
$valid = hash_equals($expected, $hash);
|
||||
if (! $valid) {
|
||||
$this->logDev('Paddle webhook signature mismatch (billing)', [
|
||||
'timestamp' => $timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$signature = (string) $request->headers->get('Paddle-Webhook-Signature', '');
|
||||
|
||||
if ($signature === '') {
|
||||
$this->logDev('Lemon Squeezy webhook missing signature header', [
|
||||
'header' => 'X-Signature',
|
||||
$this->logDev('Paddle webhook missing signature header', [
|
||||
'header' => 'Paddle-Webhook-Signature',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$expected = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$valid = hash_equals($expected, $signature);
|
||||
if (! $valid) {
|
||||
$this->logDev('Lemon Squeezy webhook signature mismatch', []);
|
||||
$this->logDev('Paddle webhook signature mismatch (legacy)', []);
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function parseSignatureHeader(string $header): array
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (explode(',', $header) as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '' || ! str_contains($chunk, '=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = array_map('trim', explode('=', $chunk, 2));
|
||||
if ($key !== '' && $value !== '') {
|
||||
$parts[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
@@ -123,7 +177,7 @@ class LemonSqueezyWebhookController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('[LemonSqueezyWebhook] '.$message, $context);
|
||||
Log::info('[PaddleWebhook] '.$message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,11 +186,12 @@ class LemonSqueezyWebhookController extends Controller
|
||||
protected function reducePayload(array $payload): array
|
||||
{
|
||||
return array_filter([
|
||||
'event_type' => data_get($payload, 'meta.event_name'),
|
||||
'order_id' => data_get($payload, 'data.id'),
|
||||
'status' => data_get($payload, 'data.attributes.status'),
|
||||
'customer_id' => data_get($payload, 'data.attributes.customer_id'),
|
||||
'has_custom_data' => is_array(data_get($payload, 'meta.custom_data')),
|
||||
'event_type' => $payload['event_type'] ?? null,
|
||||
'transaction_id' => data_get($payload, 'data.id'),
|
||||
'checkout_id' => data_get($payload, 'data.checkout_id'),
|
||||
'status' => data_get($payload, 'data.status'),
|
||||
'customer_id' => data_get($payload, 'data.customer_id'),
|
||||
'has_custom_data' => is_array(data_get($payload, 'data.custom_data')),
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Addons\EventAddonPurchaseService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PayPalAddonReturnController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayPalOrderService $orders,
|
||||
private readonly EventAddonPurchaseService $addons,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$orderId = $this->resolveOrderId($request);
|
||||
$fallback = $this->resolveFallbackUrl();
|
||||
|
||||
if (! $orderId) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$addon = EventPackageAddon::query()
|
||||
->where('checkout_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (! $addon) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$successUrl = Arr::get($addon->metadata ?? [], 'paypal_success_url')
|
||||
?? Arr::get($addon->metadata ?? [], 'success_url');
|
||||
$cancelUrl = Arr::get($addon->metadata ?? [], 'paypal_cancel_url')
|
||||
?? Arr::get($addon->metadata ?? [], 'cancel_url');
|
||||
|
||||
if ($addon->status === 'completed') {
|
||||
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
|
||||
}
|
||||
|
||||
try {
|
||||
$capture = $this->orders->captureOrder($orderId, [
|
||||
'request_id' => 'addon-'.$addon->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
$this->addons->fail($addon, 'paypal_capture_failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
|
||||
}
|
||||
|
||||
$captureId = $this->resolveCaptureId($capture);
|
||||
$totals = $this->resolveTotals($capture);
|
||||
|
||||
$this->addons->complete(
|
||||
$addon,
|
||||
$capture,
|
||||
$captureId,
|
||||
$orderId,
|
||||
$totals['total'] ?? null,
|
||||
$totals['currency'] ?? null,
|
||||
[
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_capture_id' => $captureId,
|
||||
'paypal_status' => $capture['status'] ?? null,
|
||||
'paypal_totals' => $totals ?: null,
|
||||
'paypal_captured_at' => now()->toIso8601String(),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
|
||||
}
|
||||
|
||||
protected function resolveOrderId(Request $request): ?string
|
||||
{
|
||||
$candidate = $request->query('token') ?? $request->query('order_id');
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function resolveFallbackUrl(): string
|
||||
{
|
||||
return rtrim((string) config('app.url', url('/')), '/') ?: url('/');
|
||||
}
|
||||
|
||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||
{
|
||||
if (! $target) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (Str::startsWith($target, ['/'])) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
$appHost = parse_url($fallback, PHP_URL_HOST);
|
||||
$targetHost = parse_url($target, PHP_URL_HOST);
|
||||
|
||||
if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $capture
|
||||
*/
|
||||
protected function resolveCaptureId(array $capture): ?string
|
||||
{
|
||||
$captureId = Arr::get($capture, 'purchase_units.0.payments.captures.0.id')
|
||||
?? Arr::get($capture, 'id');
|
||||
|
||||
return is_string($captureId) && $captureId !== '' ? $captureId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $capture
|
||||
* @return array{currency?: string, total?: float}
|
||||
*/
|
||||
protected function resolveTotals(array $capture): array
|
||||
{
|
||||
$amount = Arr::get($capture, 'purchase_units.0.payments.captures.0.amount')
|
||||
?? Arr::get($capture, 'purchase_units.0.amount');
|
||||
|
||||
if (! is_array($amount)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currency = Arr::get($amount, 'currency_code');
|
||||
$total = Arr::get($amount, 'value');
|
||||
|
||||
return array_filter([
|
||||
'currency' => is_string($currency) ? strtoupper($currency) : null,
|
||||
'total' => is_numeric($total) ? (float) $total : null,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\PayPal\PayPalCaptureRequest;
|
||||
use App\Http\Requests\PayPal\PayPalCheckoutRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PayPalCheckoutController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayPalOrderService $orders,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
private readonly CouponService $coupons,
|
||||
private readonly CouponRedemptionService $couponRedemptions,
|
||||
) {}
|
||||
|
||||
public function create(PayPalCheckoutRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$user = $request->user();
|
||||
$tenant = $user?->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail((int) $data['package_id']);
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||
CheckoutRequestContext::fromRequest($request),
|
||||
[
|
||||
'tenant' => $tenant,
|
||||
'locale' => $data['locale'] ?? null,
|
||||
]
|
||||
));
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
||||
|
||||
$now = now();
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $now,
|
||||
'accepted_privacy_at' => $now,
|
||||
'accepted_withdrawal_notice_at' => $now,
|
||||
'digital_content_waiver_at' => null,
|
||||
'legal_version' => $this->resolveLegalVersion(),
|
||||
])->save();
|
||||
|
||||
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
|
||||
|
||||
if ($couponCode !== '') {
|
||||
$preview = $this->coupons->preview($couponCode, $package, $tenant, CheckoutSession::PROVIDER_PAYPAL);
|
||||
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||
}
|
||||
|
||||
$successUrl = $data['return_url'] ?? null;
|
||||
$cancelUrl = $data['cancel_url'] ?? $successUrl;
|
||||
$paypalReturnUrl = route('paypal.return', absolute: true);
|
||||
|
||||
try {
|
||||
$order = $this->orders->createOrder($session, $package, [
|
||||
'return_url' => $paypalReturnUrl,
|
||||
'cancel_url' => $paypalReturnUrl,
|
||||
'locale' => $data['locale'] ?? $session->locale,
|
||||
'request_id' => $session->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
Log::warning('PayPal order creation failed', [
|
||||
'session_id' => $session->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
'context' => $exception->context(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'paypal' => __('marketing.packages.paypal_checkout_failed', [], app()->getLocale())
|
||||
?: 'Unable to create PayPal checkout.',
|
||||
]);
|
||||
}
|
||||
|
||||
$orderId = $order['id'] ?? null;
|
||||
if (! is_string($orderId) || $orderId === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'paypal' => 'PayPal order ID missing.',
|
||||
]);
|
||||
}
|
||||
|
||||
$approveUrl = $this->orders->resolveApproveUrl($order);
|
||||
|
||||
$session->forceFill([
|
||||
'paypal_order_id' => $orderId,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_status' => $order['status'] ?? null,
|
||||
'paypal_approve_url' => $approveUrl,
|
||||
'paypal_success_url' => $successUrl,
|
||||
'paypal_cancel_url' => $cancelUrl,
|
||||
'paypal_created_at' => now()->toIso8601String(),
|
||||
])),
|
||||
])->save();
|
||||
|
||||
$this->sessions->markRequiresCustomerAction($session, 'paypal_approval');
|
||||
|
||||
return response()->json([
|
||||
'order_id' => $orderId,
|
||||
'approve_url' => $approveUrl,
|
||||
'status' => $order['status'] ?? null,
|
||||
'checkout_session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function capture(PayPalCaptureRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$session = CheckoutSession::findOrFail($data['checkout_session_id']);
|
||||
$orderId = (string) $data['order_id'];
|
||||
|
||||
if ($session->status === CheckoutSession::STATUS_COMPLETED) {
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||
'checkout_session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) {
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
||||
}
|
||||
|
||||
try {
|
||||
$capture = $this->orders->captureOrder($orderId, [
|
||||
'request_id' => $session->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
Log::warning('PayPal capture failed', [
|
||||
'session_id' => $session->id,
|
||||
'order_id' => $orderId,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
'context' => $exception->context(),
|
||||
]);
|
||||
|
||||
$this->sessions->markFailed($session, 'paypal_capture_failed');
|
||||
$this->couponRedemptions->recordFailure($session, 'paypal_capture_failed');
|
||||
|
||||
return response()->json([
|
||||
'status' => CheckoutSession::STATUS_FAILED,
|
||||
'checkout_session_id' => $session->id,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$status = strtoupper((string) ($capture['status'] ?? ''));
|
||||
$captureId = $this->orders->resolveCaptureId($capture);
|
||||
$totals = $this->orders->resolveTotals($capture);
|
||||
|
||||
$session->forceFill([
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_capture_id' => $captureId,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_capture_id' => $captureId,
|
||||
'paypal_status' => $status ?: null,
|
||||
'paypal_totals' => $totals !== [] ? $totals : null,
|
||||
'paypal_captured_at' => now()->toIso8601String(),
|
||||
])),
|
||||
])->save();
|
||||
|
||||
if ($status === 'COMPLETED') {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paypal_status' => $status,
|
||||
'paypal_capture_id' => $captureId,
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'paypal_capture',
|
||||
'provider' => CheckoutSession::PROVIDER_PAYPAL,
|
||||
'provider_reference' => $captureId ?? $orderId,
|
||||
'payload' => $capture,
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
$this->couponRedemptions->recordSuccess($session, $capture);
|
||||
|
||||
return response()->json([
|
||||
'status' => CheckoutSession::STATUS_COMPLETED,
|
||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||
'checkout_session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($status, ['PAYER_ACTION_REQUIRED', 'PENDING'], true)) {
|
||||
$this->sessions->markRequiresCustomerAction($session, 'paypal_pending');
|
||||
|
||||
return response()->json([
|
||||
'status' => CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
||||
'checkout_session_id' => $session->id,
|
||||
], 202);
|
||||
}
|
||||
|
||||
$this->sessions->markFailed($session, 'paypal_'.$status);
|
||||
$this->couponRedemptions->recordFailure($session, 'paypal_'.$status);
|
||||
|
||||
return response()->json([
|
||||
'status' => CheckoutSession::STATUS_FAILED,
|
||||
'checkout_session_id' => $session->id,
|
||||
], 422);
|
||||
}
|
||||
|
||||
protected function resolveLegalVersion(): string
|
||||
{
|
||||
return config('app.legal_version', now()->toDateString());
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PayPalGiftVoucherReturnController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayPalOrderService $orders,
|
||||
private readonly GiftVoucherService $vouchers,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$orderId = $this->resolveOrderId($request);
|
||||
$fallback = $this->resolveFallbackUrl();
|
||||
|
||||
if (! $orderId) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$voucher = GiftVoucher::query()
|
||||
->where('paypal_order_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (! $voucher) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$successUrl = Arr::get($voucher->metadata ?? [], 'paypal_success_url')
|
||||
?? Arr::get($voucher->metadata ?? [], 'success_url');
|
||||
$cancelUrl = Arr::get($voucher->metadata ?? [], 'paypal_cancel_url')
|
||||
?? Arr::get($voucher->metadata ?? [], 'return_url');
|
||||
|
||||
if (in_array($voucher->status, [GiftVoucher::STATUS_ISSUED, GiftVoucher::STATUS_REDEEMED], true)) {
|
||||
return redirect()->to($this->resolveSafeRedirect($this->appendOrderId($successUrl, $orderId), $fallback));
|
||||
}
|
||||
|
||||
try {
|
||||
$capture = $this->orders->captureOrder($orderId, [
|
||||
'request_id' => 'gift-voucher-'.$voucher->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
|
||||
}
|
||||
|
||||
$this->vouchers->issueFromPayPal($voucher, $capture, $orderId);
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($this->appendOrderId($successUrl, $orderId), $fallback));
|
||||
}
|
||||
|
||||
protected function resolveOrderId(Request $request): ?string
|
||||
{
|
||||
$candidate = $request->query('token') ?? $request->query('order_id');
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function resolveFallbackUrl(): string
|
||||
{
|
||||
return rtrim((string) config('app.url', url('/')), '/') ?: url('/');
|
||||
}
|
||||
|
||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||
{
|
||||
if (! $target) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (Str::startsWith($target, ['/'])) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
$appHost = parse_url($fallback, PHP_URL_HOST);
|
||||
$targetHost = parse_url($target, PHP_URL_HOST);
|
||||
|
||||
if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
protected function appendOrderId(?string $url, string $orderId): ?string
|
||||
{
|
||||
if (! $url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if (! $parts) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$query = [];
|
||||
if (! empty($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
}
|
||||
|
||||
if (! isset($query['order_id'])) {
|
||||
$query['order_id'] = $orderId;
|
||||
}
|
||||
|
||||
$scheme = $parts['scheme'] ?? null;
|
||||
$host = $parts['host'] ?? null;
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
$path = $parts['path'] ?? '';
|
||||
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
|
||||
$queryString = $query ? '?'.http_build_query($query) : '';
|
||||
|
||||
if ($scheme && $host) {
|
||||
return $scheme.'://'.$host.$port.$path.$queryString.$fragment;
|
||||
}
|
||||
|
||||
return $path.$queryString.$fragment;
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\PayPal\Exceptions\PayPalException;
|
||||
use App\Services\PayPal\PayPalOrderService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PayPalReturnController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayPalOrderService $orders,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
private readonly CouponRedemptionService $couponRedemptions,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$orderId = $this->resolveOrderId($request);
|
||||
$fallback = $this->resolveFallbackUrl();
|
||||
|
||||
if (! $orderId) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$session = CheckoutSession::query()
|
||||
->where('paypal_order_id', $orderId)
|
||||
->first();
|
||||
|
||||
if (! $session) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$successUrl = data_get($session->provider_metadata ?? [], 'paypal_success_url');
|
||||
$cancelUrl = data_get($session->provider_metadata ?? [], 'paypal_cancel_url');
|
||||
|
||||
if ($session->status === CheckoutSession::STATUS_COMPLETED) {
|
||||
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
|
||||
}
|
||||
|
||||
try {
|
||||
$capture = $this->orders->captureOrder($orderId, [
|
||||
'request_id' => $session->id,
|
||||
]);
|
||||
} catch (PayPalException $exception) {
|
||||
Log::warning('PayPal return capture failed', [
|
||||
'session_id' => $session->id,
|
||||
'order_id' => $orderId,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
|
||||
$this->sessions->markFailed($session, 'paypal_capture_failed');
|
||||
$this->couponRedemptions->recordFailure($session, 'paypal_capture_failed');
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
|
||||
}
|
||||
|
||||
$status = strtoupper((string) ($capture['status'] ?? ''));
|
||||
$captureId = $this->orders->resolveCaptureId($capture);
|
||||
$totals = $this->orders->resolveTotals($capture);
|
||||
|
||||
$session->forceFill([
|
||||
'paypal_capture_id' => $captureId,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paypal_status' => $status ?: null,
|
||||
'paypal_capture_id' => $captureId,
|
||||
'paypal_totals' => $totals !== [] ? $totals : null,
|
||||
'paypal_captured_at' => now()->toIso8601String(),
|
||||
])),
|
||||
])->save();
|
||||
|
||||
if ($status === 'COMPLETED') {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paypal_status' => $status,
|
||||
'paypal_capture_id' => $captureId,
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'paypal_return',
|
||||
'provider' => CheckoutSession::PROVIDER_PAYPAL,
|
||||
'provider_reference' => $captureId ?? $orderId,
|
||||
'payload' => $capture,
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
$this->couponRedemptions->recordSuccess($session, $capture);
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
|
||||
}
|
||||
|
||||
$this->sessions->markFailed($session, 'paypal_'.$status);
|
||||
$this->couponRedemptions->recordFailure($session, 'paypal_'.$status);
|
||||
|
||||
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
|
||||
}
|
||||
|
||||
protected function resolveOrderId(Request $request): ?string
|
||||
{
|
||||
$candidate = $request->query('token') ?? $request->query('order_id');
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function resolveFallbackUrl(): string
|
||||
{
|
||||
return rtrim((string) config('app.url', url('/')), '/') ?: url('/');
|
||||
}
|
||||
|
||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||
{
|
||||
if (! $target) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (Str::startsWith($target, ['/'])) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
$appHost = parse_url($fallback, PHP_URL_HOST);
|
||||
$targetHost = parse_url($target, PHP_URL_HOST);
|
||||
|
||||
if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Integrations\IntegrationWebhookRecorder;
|
||||
use App\Services\PayPal\PayPalAddonWebhookService;
|
||||
use App\Services\PayPal\PayPalGiftVoucherWebhookService;
|
||||
use App\Services\PayPal\PayPalWebhookService;
|
||||
use App\Services\PayPal\PayPalWebhookVerifier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PayPalWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayPalWebhookVerifier $verifier,
|
||||
private readonly PayPalWebhookService $webhooks,
|
||||
private readonly PayPalAddonWebhookService $addonWebhooks,
|
||||
private readonly PayPalGiftVoucherWebhookService $giftVoucherWebhooks,
|
||||
private readonly IntegrationWebhookRecorder $recorder,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$payload = $this->decodePayload($request);
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
if (! $this->verifier->verify($request, $payload)) {
|
||||
Log::warning('PayPal webhook signature verification failed');
|
||||
|
||||
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$eventId = $payload['id'] ?? null;
|
||||
|
||||
$webhookEvent = $this->recorder->recordReceived(
|
||||
'paypal',
|
||||
is_string($eventId) ? $eventId : null,
|
||||
is_string($eventType) ? $eventType : null,
|
||||
);
|
||||
|
||||
$handled = false;
|
||||
|
||||
if (is_string($eventType)) {
|
||||
$handled = $this->webhooks->handle($payload) || $handled;
|
||||
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
||||
$handled = $this->giftVoucherWebhooks->handle($payload) || $handled;
|
||||
}
|
||||
|
||||
Log::info('PayPal webhook processed', [
|
||||
'event_type' => $eventType,
|
||||
'handled' => $handled,
|
||||
]);
|
||||
|
||||
if ($handled) {
|
||||
$this->recorder->markProcessed($webhookEvent, ['handled' => true]);
|
||||
} else {
|
||||
$this->recorder->markIgnored($webhookEvent, ['handled' => false]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $handled ? 'processed' : 'ignored',
|
||||
], $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED);
|
||||
} catch (\Throwable $exception) {
|
||||
$eventId = $this->captureWebhookException($exception);
|
||||
|
||||
Log::error('PayPal webhook processing failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
'event_type' => (string) data_get($request->json()->all(), 'event_type'),
|
||||
'sentry_event_id' => $eventId,
|
||||
]);
|
||||
|
||||
if (isset($webhookEvent)) {
|
||||
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function decodePayload(Request $request): ?array
|
||||
{
|
||||
$payload = $request->getContent();
|
||||
|
||||
if (! is_string($payload) || $payload === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($payload, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
protected function captureWebhookException(\Throwable $exception): ?string
|
||||
{
|
||||
report($exception);
|
||||
|
||||
if (! app()->bound('sentry') || empty(config('sentry.dsn'))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$eventId = app('sentry')->captureException($exception);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $eventId ? (string) $eventId : null;
|
||||
}
|
||||
}
|
||||
@@ -100,30 +100,13 @@ class TenantAdminFacebookController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($decoded, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($decoded, '/')) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,30 +100,13 @@ class TenantAdminGoogleController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($decoded, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($decoded, '/')) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class TestCheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function simulateLemonSqueezy(
|
||||
public function simulatePaddle(
|
||||
Request $request,
|
||||
CheckoutWebhookService $webhooks,
|
||||
CheckoutSession $session
|
||||
@@ -70,13 +70,13 @@ class TestCheckoutController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'event_type' => ['nullable', 'string'],
|
||||
'order_id' => ['nullable', 'string'],
|
||||
'transaction_id' => ['nullable', 'string'],
|
||||
'status' => ['nullable', 'string'],
|
||||
'checkout_id' => ['nullable', 'string'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$eventType = $validated['event_type'] ?? 'order_created';
|
||||
$eventType = $validated['event_type'] ?? 'transaction.completed';
|
||||
$metadata = array_merge([
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'package_id' => $session->package_id,
|
||||
@@ -84,21 +84,16 @@ class TestCheckoutController extends Controller
|
||||
], $validated['metadata'] ?? []);
|
||||
|
||||
$payload = [
|
||||
'meta' => [
|
||||
'event_name' => $eventType,
|
||||
'custom_data' => $metadata,
|
||||
],
|
||||
'event_type' => $eventType,
|
||||
'data' => array_filter([
|
||||
'id' => $validated['order_id'] ?? ('order_'.Str::uuid()),
|
||||
'attributes' => array_filter([
|
||||
'status' => $validated['status'] ?? 'paid',
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['lemonsqueezy_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
'custom_data' => $metadata,
|
||||
]),
|
||||
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
|
||||
'status' => $validated['status'] ?? 'completed',
|
||||
'custom_data' => $metadata,
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
]),
|
||||
];
|
||||
|
||||
$handled = $webhooks->handleLemonSqueezyEvent($payload);
|
||||
$handled = $webhooks->handlePaddleEvent($payload);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Models\EventPackage;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\Customer\WithdrawalConfirmed;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -36,7 +36,7 @@ class WithdrawalController extends Controller
|
||||
|
||||
public function confirm(
|
||||
WithdrawalConfirmRequest $request,
|
||||
LemonSqueezyOrderService $orders,
|
||||
PaddleTransactionService $transactions,
|
||||
string $locale
|
||||
): RedirectResponse {
|
||||
$user = $request->user();
|
||||
@@ -60,10 +60,10 @@ class WithdrawalController extends Controller
|
||||
->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale));
|
||||
}
|
||||
|
||||
$orderId = $this->resolveOrderId($purchase);
|
||||
$transactionId = $this->resolveTransactionId($purchase);
|
||||
|
||||
if (! $orderId) {
|
||||
Log::warning('Withdrawal missing Lemon Squeezy order reference.', [
|
||||
if (! $transactionId) {
|
||||
Log::warning('Withdrawal missing Paddle transaction reference.', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'provider' => $purchase->provider,
|
||||
]);
|
||||
@@ -74,11 +74,11 @@ class WithdrawalController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$orders->refund($orderId, ['reason' => 'withdrawal']);
|
||||
$transactions->refund($transactionId, ['reason' => 'withdrawal']);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Withdrawal refund failed', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'order_id' => $orderId,
|
||||
'transaction_id' => $transactionId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
@@ -94,13 +94,13 @@ class WithdrawalController extends Controller
|
||||
$withdrawalMeta = array_merge($withdrawalMeta, [
|
||||
'confirmed_at' => $confirmedAt->toIso8601String(),
|
||||
'confirmed_by' => $user?->id,
|
||||
'order_id' => $orderId,
|
||||
'transaction_id' => $transactionId,
|
||||
]);
|
||||
|
||||
$metadata['withdrawal'] = $withdrawalMeta;
|
||||
|
||||
$purchase->forceFill([
|
||||
'provider_id' => $orderId,
|
||||
'provider_id' => $transactionId,
|
||||
'refunded' => true,
|
||||
'metadata' => $metadata,
|
||||
])->save();
|
||||
@@ -127,7 +127,7 @@ class WithdrawalController extends Controller
|
||||
->with('package')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'endcustomer_event')
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('provider', 'paddle')
|
||||
->where('refunded', false)
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
@@ -151,7 +151,7 @@ class WithdrawalController extends Controller
|
||||
$reasons[] = 'type';
|
||||
}
|
||||
|
||||
if ($purchase->provider !== 'lemonsqueezy') {
|
||||
if ($purchase->provider !== 'paddle') {
|
||||
$reasons[] = 'provider';
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class WithdrawalController extends Controller
|
||||
$reasons[] = 'refunded';
|
||||
}
|
||||
|
||||
if (! $this->resolveOrderId($purchase)) {
|
||||
if (! $this->resolveTransactionId($purchase)) {
|
||||
$reasons[] = 'missing_reference';
|
||||
}
|
||||
|
||||
@@ -224,13 +224,13 @@ class WithdrawalController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveOrderId(PackagePurchase $purchase): ?string
|
||||
private function resolveTransactionId(PackagePurchase $purchase): ?string
|
||||
{
|
||||
if ($purchase->provider === 'lemonsqueezy' && $purchase->provider_id) {
|
||||
if ($purchase->provider === 'paddle' && $purchase->provider_id) {
|
||||
return (string) $purchase->provider_id;
|
||||
}
|
||||
|
||||
return data_get($purchase->metadata, 'lemonsqueezy_order_id');
|
||||
return data_get($purchase->metadata, 'paddle_transaction_id');
|
||||
}
|
||||
|
||||
private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void
|
||||
|
||||
@@ -37,7 +37,7 @@ class ContentSecurityPolicy
|
||||
$scriptSources = [
|
||||
"'self'",
|
||||
"'nonce-{$scriptNonce}'",
|
||||
'https://app.lemonsqueezy.com',
|
||||
'https://cdn.paddle.com',
|
||||
'https://global.localizecdn.com',
|
||||
];
|
||||
|
||||
@@ -49,16 +49,21 @@ class ContentSecurityPolicy
|
||||
|
||||
$connectSources = [
|
||||
"'self'",
|
||||
'https://api.lemonsqueezy.com',
|
||||
'https://app.lemonsqueezy.com',
|
||||
'https://fotospiel.lemonsqueezy.com',
|
||||
'https://api.paddle.com',
|
||||
'https://sandbox-api.paddle.com',
|
||||
'https://checkout.paddle.com',
|
||||
'https://sandbox-checkout.paddle.com',
|
||||
'https://checkout-service.paddle.com',
|
||||
'https://sandbox-checkout-service.paddle.com',
|
||||
'https://global.localizecdn.com',
|
||||
];
|
||||
|
||||
$frameSources = [
|
||||
"'self'",
|
||||
'https://app.lemonsqueezy.com',
|
||||
'https://fotospiel.lemonsqueezy.com',
|
||||
'https://checkout.paddle.com',
|
||||
'https://sandbox-checkout.paddle.com',
|
||||
'https://checkout-service.paddle.com',
|
||||
'https://sandbox-checkout-service.paddle.com',
|
||||
];
|
||||
|
||||
$imgSources = [
|
||||
@@ -81,23 +86,6 @@ class ContentSecurityPolicy
|
||||
'https:',
|
||||
];
|
||||
|
||||
$workerSources = [
|
||||
"'self'",
|
||||
'blob:',
|
||||
];
|
||||
|
||||
$paypalSources = [
|
||||
'https://www.paypal.com',
|
||||
'https://www.paypalobjects.com',
|
||||
'https://*.paypal.com',
|
||||
'https://*.paypalobjects.com',
|
||||
];
|
||||
|
||||
$scriptSources = array_merge($scriptSources, $paypalSources);
|
||||
$connectSources = array_merge($connectSources, $paypalSources);
|
||||
$frameSources = array_merge($frameSources, $paypalSources);
|
||||
$imgSources = array_merge($imgSources, $paypalSources);
|
||||
|
||||
if ($matomoOrigin) {
|
||||
$scriptSources[] = $matomoOrigin;
|
||||
$connectSources[] = $matomoOrigin;
|
||||
@@ -107,18 +95,6 @@ class ContentSecurityPolicy
|
||||
$isDev = app()->environment(['local', 'development']) || config('app.debug');
|
||||
|
||||
if ($isDev) {
|
||||
$paypalSandboxSources = [
|
||||
'https://www.sandbox.paypal.com',
|
||||
'https://www.sandbox.paypalobjects.com',
|
||||
'https://*.sandbox.paypal.com',
|
||||
'https://*.sandbox.paypalobjects.com',
|
||||
];
|
||||
|
||||
$scriptSources = array_merge($scriptSources, $paypalSandboxSources);
|
||||
$connectSources = array_merge($connectSources, $paypalSandboxSources);
|
||||
$frameSources = array_merge($frameSources, $paypalSandboxSources);
|
||||
$imgSources = array_merge($imgSources, $paypalSandboxSources);
|
||||
|
||||
$devHosts = [
|
||||
'http://fotospiel-app.test:5173',
|
||||
'http://127.0.0.1:5173',
|
||||
@@ -158,7 +134,6 @@ class ContentSecurityPolicy
|
||||
'font-src' => array_unique($fontSources),
|
||||
'connect-src' => array_unique($connectSources),
|
||||
'media-src' => array_unique($mediaSources),
|
||||
'worker-src' => array_unique($workerSources),
|
||||
'frame-src' => array_unique($frameSources),
|
||||
'form-action' => ["'self'"],
|
||||
'base-uri' => ["'self'"],
|
||||
|
||||
@@ -14,7 +14,6 @@ class VerifyCsrfToken extends Middleware
|
||||
protected $except = [
|
||||
'api/v1/photos/*/like',
|
||||
'api/v1/events/*/upload',
|
||||
'lemonsqueezy/webhook*',
|
||||
'paypal/webhook*',
|
||||
'paddle/webhook*',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GuestAiEditStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'style_key' => ['nullable', 'string', 'max:120', 'required_without:prompt'],
|
||||
'prompt' => ['nullable', 'string', 'max:2000', 'required_without:style_key'],
|
||||
'negative_prompt' => ['nullable', 'string', 'max:2000'],
|
||||
'provider_model' => ['nullable', 'string', 'max:120'],
|
||||
'idempotency_key' => ['nullable', 'string', 'max:120'],
|
||||
'session_id' => ['nullable', 'string', 'max:191'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -35,16 +35,16 @@ class CheckoutSessionConfirmRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'order_id' => ['nullable', 'string', 'required_without:checkout_id'],
|
||||
'checkout_id' => ['nullable', 'string', 'required_without:order_id'],
|
||||
'transaction_id' => ['nullable', 'string', 'required_without:checkout_id'],
|
||||
'checkout_id' => ['nullable', 'string', 'required_without:transaction_id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'order_id.required_without' => 'Order ID oder Checkout ID fehlt.',
|
||||
'checkout_id.required_without' => 'Checkout ID oder Order ID fehlt.',
|
||||
'transaction_id.required_without' => 'Transaction ID oder Checkout ID fehlt.',
|
||||
'checkout_id.required_without' => 'Checkout ID oder Transaction ID fehlt.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\LemonSqueezy;
|
||||
namespace App\Http\Requests\Paddle;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LemonSqueezyCheckoutRequest extends FormRequest
|
||||
class PaddleCheckoutRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,11 +25,15 @@ class LemonSqueezyCheckoutRequest extends FormRequest
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom validation messages.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\PayPal;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PayPalCaptureRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sessionId = $this->input('checkout_session_id');
|
||||
|
||||
if (! is_string($sessionId) || $sessionId === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$session = CheckoutSession::find($sessionId);
|
||||
|
||||
if (! $session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $session->user_id === (int) $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'checkout_session_id' => ['required', 'uuid', 'exists:checkout_sessions,id'],
|
||||
'order_id' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'checkout_session_id.required' => 'Checkout-Session fehlt.',
|
||||
'order_id.required' => 'Order ID fehlt.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\PayPal;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PayPalCheckoutRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'cancel_url' => ['nullable', 'url'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||
'locale' => ['nullable', 'string', 'max:10'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
|
||||
'accepted_terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
|
||||
Rule::unique('tenants', 'slug')->ignore($tenantId),
|
||||
],
|
||||
'contact_email' => ['sometimes', 'email', 'max:255'],
|
||||
'lemonsqueezy_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'is_suspended' => ['sometimes', 'boolean'],
|
||||
'features' => ['sometimes', 'array'],
|
||||
@@ -31,7 +31,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
|
||||
return [
|
||||
'slug',
|
||||
'contact_email',
|
||||
'lemonsqueezy_customer_id',
|
||||
'paddle_customer_id',
|
||||
'is_active',
|
||||
'is_suspended',
|
||||
'features',
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AiEditIndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'safety_state' => ['nullable', 'string', 'max:30'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AiEditStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'photo_id' => ['required', 'integer', 'exists:photos,id'],
|
||||
'style_id' => ['nullable', 'integer', 'exists:ai_styles,id', 'required_without:style_key'],
|
||||
'style_key' => ['nullable', 'string', 'max:120', 'required_without:style_id'],
|
||||
'prompt' => ['nullable', 'string', 'max:2000'],
|
||||
'negative_prompt' => ['nullable', 'string', 'max:2000'],
|
||||
'provider_model' => ['nullable', 'string', 'max:120'],
|
||||
'idempotency_key' => ['nullable', 'string', 'max:120'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BillingAddonHistoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'event_id' => ['nullable', 'integer', 'min:1'],
|
||||
'event_slug' => ['nullable', 'string', 'max:191'],
|
||||
'status' => ['nullable', 'in:pending,completed,failed'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class EventAddonPurchaseLookupRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'addon_intent' => ['nullable', 'string', 'max:191'],
|
||||
'checkout_id' => ['nullable', 'string', 'max:191'],
|
||||
'addon_key' => ['nullable', 'string', 'max:191'],
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Http/Requests/Tenant/EventAddonRequest.php
Normal file
40
app/Http/Requests/Tenant/EventAddonRequest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventAddonRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'extra_photos' => ['nullable', 'integer', 'min:1'],
|
||||
'extra_guests' => ['nullable', 'integer', 'min:1'],
|
||||
'extend_gallery_days' => ['nullable', 'integer', 'min:1'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function passedValidation(): void
|
||||
{
|
||||
if (
|
||||
$this->input('extra_photos') === null
|
||||
&& $this->input('extra_guests') === null
|
||||
&& $this->input('extend_gallery_days') === null
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'addons' => __('Please provide at least one add-on to apply.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ class EventStoreRequest extends FormRequest
|
||||
'settings.branding.*' => ['nullable'],
|
||||
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
||||
'settings.guest_downloads_enabled' => ['nullable', 'boolean'],
|
||||
'settings.guest_download_variant' => ['nullable', Rule::in(['preview', 'original'])],
|
||||
'settings.guest_sharing_enabled' => ['nullable', 'boolean'],
|
||||
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
|
||||
'settings.live_show' => ['nullable', 'array'],
|
||||
@@ -84,16 +83,6 @@ class EventStoreRequest extends FormRequest
|
||||
'settings.control_room.force_review_uploaders' => ['nullable', 'array'],
|
||||
'settings.control_room.force_review_uploaders.*.device_id' => ['required', 'string', 'max:120'],
|
||||
'settings.control_room.force_review_uploaders.*.label' => ['nullable', 'string', 'max:80'],
|
||||
'settings.ai_editing' => ['nullable', 'array'],
|
||||
'settings.ai_editing.enabled' => ['nullable', 'boolean'],
|
||||
'settings.ai_editing.allow_custom_prompt' => ['nullable', 'boolean'],
|
||||
'settings.ai_editing.allowed_style_keys' => ['nullable', 'array'],
|
||||
'settings.ai_editing.allowed_style_keys.*' => [
|
||||
'string',
|
||||
'max:120',
|
||||
Rule::exists('ai_styles', 'key')->where('is_active', true),
|
||||
],
|
||||
'settings.ai_editing.policy_message' => ['nullable', 'string', 'max:280'],
|
||||
'settings.watermark' => ['nullable', 'array'],
|
||||
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
|
||||
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use App\Models\WatermarkSetting;
|
||||
use App\Services\Addons\EventAddonCatalog;
|
||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use App\Support\WatermarkConfigResolver;
|
||||
@@ -52,8 +49,6 @@ class EventResource extends JsonResource
|
||||
if ($eventPackage) {
|
||||
$limitEvaluator = app()->make(PackageLimitEvaluator::class);
|
||||
}
|
||||
$aiStylingEntitlement = app()->make(AiStylingEntitlementService::class)->resolveForEvent($this->resource);
|
||||
$aiEditingPolicy = app()->make(EventAiEditingPolicyService::class)->resolve($this->resource);
|
||||
|
||||
$settings['watermark_removal_allowed'] = WatermarkConfigResolver::determineRemovalAllowed($this->resource);
|
||||
|
||||
@@ -94,29 +89,17 @@ class EventResource extends JsonResource
|
||||
'qr_code_url' => null,
|
||||
'package' => $eventPackage ? [
|
||||
'id' => $eventPackage->package_id,
|
||||
'tenant_package_id' => $eventPackage->tenant_package_id,
|
||||
'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name,
|
||||
'price' => $eventPackage->purchased_price,
|
||||
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
|
||||
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
||||
'branding_allowed' => (bool) optional($eventPackage->package)->branding_allowed,
|
||||
'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed,
|
||||
'features' => optional($eventPackage->package)->features ?? [],
|
||||
] : null,
|
||||
'limits' => $eventPackage && $limitEvaluator
|
||||
? $limitEvaluator->summarizeEventPackage($eventPackage, $this->resolveTasksUsed())
|
||||
: null,
|
||||
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
||||
'capabilities' => [
|
||||
'ai_styling' => (bool) $aiStylingEntitlement['allowed'],
|
||||
'ai_styling_granted_by' => $aiStylingEntitlement['granted_by'],
|
||||
'ai_styling_required_feature' => $aiStylingEntitlement['required_feature'],
|
||||
'ai_styling_addon_keys' => $aiStylingEntitlement['addon_keys'],
|
||||
'ai_styling_event_enabled' => (bool) $aiEditingPolicy['enabled'],
|
||||
'ai_styling_allow_custom_prompt' => (bool) $aiEditingPolicy['allow_custom_prompt'],
|
||||
'ai_styling_allowed_style_keys' => $aiEditingPolicy['allowed_style_keys'],
|
||||
'ai_styling_policy_message' => $aiEditingPolicy['policy_message'],
|
||||
],
|
||||
'member_permissions' => $memberPermissions,
|
||||
];
|
||||
}
|
||||
@@ -223,19 +206,13 @@ class EventResource extends JsonResource
|
||||
? $eventPackage->addons
|
||||
: $eventPackage->addons()->latest()->take(10)->get();
|
||||
|
||||
$addonLabels = collect(app(EventAddonCatalog::class)->all())
|
||||
->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null])
|
||||
->all();
|
||||
|
||||
return $addons->map(function ($addon) use ($addonLabels) {
|
||||
return $addons->map(function ($addon) {
|
||||
return [
|
||||
'id' => $addon->id,
|
||||
'key' => $addon->addon_key,
|
||||
'label' => $addon->metadata['label']
|
||||
?? ($addonLabels[$addon->addon_key] ?? null)
|
||||
?? $addon->addon_key,
|
||||
'label' => $addon->metadata['label'] ?? null,
|
||||
'status' => $addon->status,
|
||||
'variant_id' => $addon->variant_id,
|
||||
'price_id' => $addon->price_id,
|
||||
'transaction_id' => $addon->transaction_id,
|
||||
'extra_photos' => (int) $addon->extra_photos,
|
||||
'extra_guests' => (int) $addon->extra_guests,
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AiEditOutput;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiProviderRun;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiEditOutputStorageService;
|
||||
use App\Services\AiEditing\AiImageProviderManager;
|
||||
use App\Services\AiEditing\AiObservabilityService;
|
||||
use App\Services\AiEditing\AiStatusNotificationService;
|
||||
use App\Services\AiEditing\AiUsageLedgerService;
|
||||
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
|
||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class PollAiEditRequest implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public array $backoff = [20, 60, 120];
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $requestId,
|
||||
private readonly string $providerTaskId,
|
||||
private readonly int $pollAttempt = 1,
|
||||
) {
|
||||
$this->onQueue((string) config('ai-editing.queue.name', 'default'));
|
||||
}
|
||||
|
||||
public function handle(
|
||||
AiImageProviderManager $providers,
|
||||
AiSafetyPolicyService $safetyPolicy,
|
||||
AiAbuseEscalationService $abuseEscalation,
|
||||
AiObservabilityService $observability,
|
||||
AiStatusNotificationService $statusNotifications,
|
||||
AiEditOutputStorageService $outputStorage,
|
||||
AiEditingRuntimeConfig $runtimeConfig,
|
||||
AiUsageLedgerService $usageLedger
|
||||
): void {
|
||||
$request = AiEditRequest::query()->with('outputs')->find($this->requestId);
|
||||
if (! $request || $request->status !== AiEditRequest::STATUS_PROCESSING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run = AiProviderRun::query()->create([
|
||||
'request_id' => $request->id,
|
||||
'provider' => $request->provider,
|
||||
'attempt' => ((int) $request->providerRuns()->max('attempt')) + 1,
|
||||
'provider_task_id' => $this->providerTaskId,
|
||||
'status' => AiProviderRun::STATUS_RUNNING,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$result = $providers->forProvider($request->provider)->poll($request, $this->providerTaskId);
|
||||
|
||||
$run->forceFill([
|
||||
'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED),
|
||||
'http_status' => $result->httpStatus,
|
||||
'finished_at' => $result->status === 'processing' ? null : now(),
|
||||
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
|
||||
'cost_usd' => $result->costUsd,
|
||||
'request_payload' => $result->requestPayload,
|
||||
'response_payload' => $result->responsePayload,
|
||||
'error_message' => $result->failureMessage,
|
||||
])->save();
|
||||
|
||||
if ($result->status === 'succeeded') {
|
||||
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
|
||||
if ($outputDecision->blocked) {
|
||||
$abuseSignal = $abuseEscalation->recordOutputBlock(
|
||||
(int) $request->tenant_id,
|
||||
(int) $request->event_id,
|
||||
'provider:'.$request->provider
|
||||
);
|
||||
$safetyReasons = $outputDecision->reasonCodes;
|
||||
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
|
||||
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
|
||||
}
|
||||
$metadata = (array) ($request->metadata ?? []);
|
||||
$metadata['abuse'] = $abuseSignal;
|
||||
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_BLOCKED,
|
||||
'safety_state' => $outputDecision->state,
|
||||
'safety_reasons' => $safetyReasons,
|
||||
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
|
||||
'failure_message' => $outputDecision->failureMessage,
|
||||
'metadata' => $metadata,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$observability->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_BLOCKED,
|
||||
$run->duration_ms,
|
||||
true,
|
||||
'poll'
|
||||
);
|
||||
$statusNotifications->notifyTerminalOutcome($request->fresh());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($result->outputs as $output) {
|
||||
$persistedOutput = $outputStorage->persist($request, is_array($output) ? $output : []);
|
||||
AiEditOutput::query()->updateOrCreate(
|
||||
[
|
||||
'request_id' => $request->id,
|
||||
'provider_asset_id' => (string) Arr::get($persistedOutput, 'provider_asset_id', $this->providerTaskId),
|
||||
],
|
||||
[
|
||||
'storage_disk' => Arr::get($persistedOutput, 'storage_disk'),
|
||||
'storage_path' => Arr::get($persistedOutput, 'storage_path'),
|
||||
'provider_url' => Arr::get($persistedOutput, 'provider_url'),
|
||||
'mime_type' => Arr::get($persistedOutput, 'mime_type'),
|
||||
'width' => Arr::get($persistedOutput, 'width'),
|
||||
'height' => Arr::get($persistedOutput, 'height'),
|
||||
'bytes' => Arr::get($persistedOutput, 'bytes'),
|
||||
'checksum' => Arr::get($persistedOutput, 'checksum'),
|
||||
'is_primary' => true,
|
||||
'safety_state' => 'passed',
|
||||
'safety_reasons' => [],
|
||||
'generated_at' => now(),
|
||||
'metadata' => array_merge(
|
||||
['provider' => $request->provider],
|
||||
is_array(Arr::get($persistedOutput, 'metadata'))
|
||||
? Arr::get($persistedOutput, 'metadata')
|
||||
: []
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'safety_reasons' => [],
|
||||
'failure_code' => null,
|
||||
'failure_message' => null,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$usageLedger->recordDebitForRequest($request->fresh(), $result->costUsd, [
|
||||
'source' => 'poll_job',
|
||||
'poll_attempt' => $this->pollAttempt,
|
||||
]);
|
||||
|
||||
$observability->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_SUCCEEDED,
|
||||
$run->duration_ms,
|
||||
false,
|
||||
'poll'
|
||||
);
|
||||
$statusNotifications->notifyTerminalOutcome($request->fresh());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'processing') {
|
||||
$maxPolls = $runtimeConfig->maxPolls();
|
||||
if ($this->pollAttempt < $maxPolls) {
|
||||
self::dispatch($request->id, $this->providerTaskId, $this->pollAttempt + 1)
|
||||
->delay(now()->addSeconds(20))
|
||||
->onQueue($runtimeConfig->queueName());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run->forceFill([
|
||||
'status' => AiProviderRun::STATUS_FAILED,
|
||||
'finished_at' => now(),
|
||||
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
|
||||
'error_message' => sprintf('Polling exhausted after %d attempt(s).', $maxPolls),
|
||||
])->save();
|
||||
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'failure_code' => 'provider_poll_timeout',
|
||||
'failure_message' => sprintf('Polling timed out after %d attempt(s).', $maxPolls),
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$observability->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_FAILED,
|
||||
$run->duration_ms,
|
||||
false,
|
||||
'poll'
|
||||
);
|
||||
$statusNotifications->notifyTerminalOutcome($request->fresh());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
|
||||
'safety_state' => $result->safetyState ?? $request->safety_state,
|
||||
'safety_reasons' => $result->safetyReasons !== [] ? $result->safetyReasons : $request->safety_reasons,
|
||||
'failure_code' => $result->failureCode,
|
||||
'failure_message' => $result->failureMessage,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$observability->recordTerminalOutcome(
|
||||
$request,
|
||||
$result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
|
||||
$run->duration_ms,
|
||||
$result->status === 'blocked',
|
||||
'poll'
|
||||
);
|
||||
$statusNotifications->notifyTerminalOutcome($request->fresh());
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$request = AiEditRequest::query()->find($this->requestId);
|
||||
if (! $request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = trim($exception->getMessage());
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'failure_code' => 'queue_job_failed',
|
||||
'failure_message' => $message !== ''
|
||||
? Str::limit($message, 500, '')
|
||||
: 'AI edit polling failed in queue.',
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
app(AiObservabilityService::class)->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_FAILED,
|
||||
null,
|
||||
false,
|
||||
'poll_failed_hook'
|
||||
);
|
||||
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh());
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AiEditOutput;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiProviderRun;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiEditOutputStorageService;
|
||||
use App\Services\AiEditing\AiImageProviderManager;
|
||||
use App\Services\AiEditing\AiObservabilityService;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use App\Services\AiEditing\AiStatusNotificationService;
|
||||
use App\Services\AiEditing\AiUsageLedgerService;
|
||||
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
|
||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class ProcessAiEditRequest implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public array $backoff = [30, 120, 300];
|
||||
|
||||
public int $timeout = 90;
|
||||
|
||||
public function __construct(private readonly int $requestId)
|
||||
{
|
||||
$queue = (string) config('ai-editing.queue.name', 'default');
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
|
||||
public function handle(
|
||||
AiImageProviderManager $providers,
|
||||
AiSafetyPolicyService $safetyPolicy,
|
||||
AiAbuseEscalationService $abuseEscalation,
|
||||
AiObservabilityService $observability,
|
||||
AiStatusNotificationService $statusNotifications,
|
||||
AiEditOutputStorageService $outputStorage,
|
||||
AiEditingRuntimeConfig $runtimeConfig,
|
||||
AiUsageLedgerService $usageLedger
|
||||
): void {
|
||||
$request = AiEditRequest::query()->with('style')->find($this->requestId);
|
||||
if (! $request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->status === AiEditRequest::STATUS_QUEUED) {
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
'started_at' => $request->started_at ?: now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$attempt = ((int) $request->providerRuns()->max('attempt')) + 1;
|
||||
$providerRun = AiProviderRun::query()->create([
|
||||
'request_id' => $request->id,
|
||||
'provider' => $request->provider,
|
||||
'attempt' => $attempt,
|
||||
'status' => AiProviderRun::STATUS_RUNNING,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$result = $providers->forProvider($request->provider)->submit($request);
|
||||
|
||||
$this->finalizeProviderRun($providerRun, $result);
|
||||
$this->applyProviderResult(
|
||||
$request->fresh(['outputs']),
|
||||
$providerRun,
|
||||
$result,
|
||||
$safetyPolicy,
|
||||
$abuseEscalation,
|
||||
$observability,
|
||||
$statusNotifications,
|
||||
$outputStorage,
|
||||
$runtimeConfig,
|
||||
$usageLedger
|
||||
);
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$request = AiEditRequest::query()->find($this->requestId);
|
||||
if (! $request) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = trim($exception->getMessage());
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'failure_code' => 'queue_job_failed',
|
||||
'failure_message' => $message !== ''
|
||||
? Str::limit($message, 500, '')
|
||||
: 'AI edit processing failed in queue.',
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
app(AiObservabilityService::class)->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_FAILED,
|
||||
null,
|
||||
false,
|
||||
'process_failed_hook'
|
||||
);
|
||||
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh());
|
||||
}
|
||||
|
||||
private function finalizeProviderRun(AiProviderRun $run, AiProviderResult $result): void
|
||||
{
|
||||
$missingTaskId = $result->status === 'processing'
|
||||
&& (! is_string($result->providerTaskId) || trim($result->providerTaskId) === '');
|
||||
|
||||
$status = $missingTaskId
|
||||
? AiProviderRun::STATUS_FAILED
|
||||
: ($result->status === 'succeeded'
|
||||
? AiProviderRun::STATUS_SUCCEEDED
|
||||
: ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED));
|
||||
|
||||
$run->forceFill([
|
||||
'provider_task_id' => $result->providerTaskId,
|
||||
'status' => $status,
|
||||
'http_status' => $result->httpStatus,
|
||||
'finished_at' => $status === AiProviderRun::STATUS_RUNNING ? null : now(),
|
||||
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
|
||||
'cost_usd' => $result->costUsd,
|
||||
'request_payload' => $result->requestPayload,
|
||||
'response_payload' => $result->responsePayload,
|
||||
'error_message' => $missingTaskId
|
||||
? 'Provider returned processing state without task identifier.'
|
||||
: $result->failureMessage,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function applyProviderResult(
|
||||
AiEditRequest $request,
|
||||
AiProviderRun $providerRun,
|
||||
AiProviderResult $result,
|
||||
AiSafetyPolicyService $safetyPolicy,
|
||||
AiAbuseEscalationService $abuseEscalation,
|
||||
AiObservabilityService $observability,
|
||||
AiStatusNotificationService $statusNotifications,
|
||||
AiEditOutputStorageService $outputStorage,
|
||||
AiEditingRuntimeConfig $runtimeConfig,
|
||||
AiUsageLedgerService $usageLedger
|
||||
): void {
|
||||
if ($result->status === 'succeeded') {
|
||||
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
|
||||
if ($outputDecision->blocked) {
|
||||
$abuseSignal = $abuseEscalation->recordOutputBlock(
|
||||
(int) $request->tenant_id,
|
||||
(int) $request->event_id,
|
||||
'provider:'.$request->provider
|
||||
);
|
||||
$safetyReasons = $outputDecision->reasonCodes;
|
||||
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
|
||||
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
|
||||
}
|
||||
$metadata = (array) ($request->metadata ?? []);
|
||||
$metadata['abuse'] = $abuseSignal;
|
||||
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_BLOCKED,
|
||||
'safety_state' => $outputDecision->state,
|
||||
'safety_reasons' => $safetyReasons,
|
||||
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
|
||||
'failure_message' => $outputDecision->failureMessage,
|
||||
'metadata' => $metadata,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$observability->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_BLOCKED,
|
||||
$providerRun->duration_ms,
|
||||
true,
|
||||
'process'
|
||||
);
|
||||
$statusNotifications->notifyTerminalOutcome($request->fresh());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($request, $result, $outputStorage): void {
|
||||
foreach ($result->outputs as $output) {
|
||||
$persistedOutput = $outputStorage->persist($request, is_array($output) ? $output : []);
|
||||
AiEditOutput::query()->updateOrCreate(
|
||||
[
|
||||
'request_id' => $request->id,
|
||||
'provider_asset_id' => (string) Arr::get($persistedOutput, 'provider_asset_id', ''),
|
||||
],
|
||||
[
|
||||
'storage_disk' => Arr::get($persistedOutput, 'storage_disk'),
|
||||
'storage_path' => Arr::get($persistedOutput, 'storage_path'),
|
||||
'provider_url' => Arr::get($persistedOutput, 'provider_url'),
|
||||
'mime_type' => Arr::get($persistedOutput, 'mime_type'),
|
||||
'width' => Arr::get($persistedOutput, 'width'),
|
||||
'height' => Arr::get($persistedOutput, 'height'),
|
||||
'bytes' => Arr::get($persistedOutput, 'bytes'),
|
||||
'checksum' => Arr::get($persistedOutput, 'checksum'),
|
||||
'is_primary' => true,
|
||||
'safety_state' => 'passed',
|
||||
'safety_reasons' => [],
|
||||
'generated_at' => now(),
|
||||
'metadata' => array_merge(
|
||||
['provider' => $request->provider],
|
||||
is_array(Arr::get($persistedOutput, 'metadata'))
|
||||
? Arr::get($persistedOutput, 'metadata')
|
||||
: []
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'safety_reasons' => [],
|
||||
'failure_code' => null,
|
||||
'failure_message' => null,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
$usageLedger->recordDebitForRequest($request->fresh(), $result->costUsd, [
|
||||
'source' => 'process_job',
|
||||
]);
|
||||
|
||||
$observability->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_SUCCEEDED,
|
||||
$providerRun->duration_ms,
|
||||
false,
|
||||
'process'
|
||||
);
|
||||
$statusNotifications->notifyTerminalOutcome($request->fresh());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result->status === 'processing') {
|
||||
$providerTaskId = trim((string) ($result->providerTaskId ?? ''));
|
||||
if ($providerTaskId === '') {
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'failure_code' => 'provider_task_id_missing',
|
||||
'failure_message' => 'Provider returned processing state without a task identifier.',
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$observability->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_FAILED,
|
||||
$providerRun->duration_ms,
|
||||
false,
|
||||
'process'
|
||||
);
|
||||
$statusNotifications->notifyTerminalOutcome($request->fresh());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
'failure_code' => null,
|
||||
'failure_message' => null,
|
||||
])->save();
|
||||
|
||||
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)
|
||||
->delay(now()->addSeconds(20))
|
||||
->onQueue($runtimeConfig->queueName());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request->forceFill([
|
||||
'status' => $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
|
||||
'safety_state' => $result->safetyState ?? $request->safety_state,
|
||||
'safety_reasons' => $result->safetyReasons !== [] ? $result->safetyReasons : $request->safety_reasons,
|
||||
'failure_code' => $result->failureCode,
|
||||
'failure_message' => $result->failureMessage,
|
||||
'completed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$observability->recordTerminalOutcome(
|
||||
$request,
|
||||
$result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
|
||||
$providerRun->duration_ms,
|
||||
$result->status === 'blocked',
|
||||
'process'
|
||||
);
|
||||
$statusNotifications->notifyTerminalOutcome($request->fresh());
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
|
||||
use App\Services\Paddle\PaddleCatalogService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -13,7 +13,7 @@ use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class PullPackageFromLemonSqueezy implements ShouldQueue
|
||||
class PullPackageFromPaddle implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
@@ -22,7 +22,7 @@ class PullPackageFromLemonSqueezy implements ShouldQueue
|
||||
|
||||
public function __construct(private readonly int $packageId) {}
|
||||
|
||||
public function handle(LemonSqueezyCatalogService $catalog): void
|
||||
public function handle(PaddleCatalogService $catalog): void
|
||||
{
|
||||
$package = Package::query()->find($this->packageId);
|
||||
|
||||
@@ -30,8 +30,8 @@ class PullPackageFromLemonSqueezy implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $package->lemonsqueezy_product_id && ! $package->lemonsqueezy_variant_id) {
|
||||
Log::channel('lemonsqueezy-sync')->warning('Lemon Squeezy pull skipped for package without linkage', [
|
||||
if (! $package->paddle_product_id && ! $package->paddle_price_id) {
|
||||
Log::channel('paddle-sync')->warning('Paddle pull skipped for package without linkage', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
@@ -39,41 +39,41 @@ class PullPackageFromLemonSqueezy implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
$product = $package->lemonsqueezy_product_id ? $catalog->fetchProduct($package->lemonsqueezy_product_id) : null;
|
||||
$price = $package->lemonsqueezy_variant_id ? $catalog->fetchPrice($package->lemonsqueezy_variant_id) : null;
|
||||
$product = $package->paddle_product_id ? $catalog->fetchProduct($package->paddle_product_id) : null;
|
||||
$price = $package->paddle_price_id ? $catalog->fetchPrice($package->paddle_price_id) : null;
|
||||
|
||||
$snapshot = $package->lemonsqueezy_snapshot ?? [];
|
||||
$snapshot = $package->paddle_snapshot ?? [];
|
||||
$snapshot['remote'] = array_filter([
|
||||
'product' => $product,
|
||||
'price' => $price,
|
||||
], static fn ($value) => $value !== null);
|
||||
|
||||
$package->forceFill([
|
||||
'lemonsqueezy_sync_status' => 'pulled',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => $snapshot,
|
||||
'paddle_sync_status' => 'pulled',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => $snapshot,
|
||||
])->save();
|
||||
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package pull completed', [
|
||||
Log::channel('paddle-sync')->info('Paddle package pull completed', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package pull failed', [
|
||||
Log::channel('paddle-sync')->error('Paddle package pull failed', [
|
||||
'package_id' => $package->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
$snapshot = $package->lemonsqueezy_snapshot ?? [];
|
||||
$snapshot = $package->paddle_snapshot ?? [];
|
||||
$snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [
|
||||
'message' => $exception->getMessage(),
|
||||
'class' => $exception::class,
|
||||
]);
|
||||
|
||||
$package->forceFill([
|
||||
'lemonsqueezy_sync_status' => 'pull-failed',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => $snapshot,
|
||||
'paddle_sync_status' => 'pull-failed',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => $snapshot,
|
||||
])->save();
|
||||
|
||||
throw $exception;
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Coupon;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyDiscountService;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleDiscountService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SyncCouponToLemonSqueezy implements ShouldQueue
|
||||
class SyncCouponToPaddle implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
@@ -24,16 +24,16 @@ class SyncCouponToLemonSqueezy implements ShouldQueue
|
||||
public bool $archive = false,
|
||||
) {}
|
||||
|
||||
public function handle(LemonSqueezyDiscountService $discounts): void
|
||||
public function handle(PaddleDiscountService $discounts): void
|
||||
{
|
||||
try {
|
||||
if ($this->archive) {
|
||||
$discounts->archiveDiscount($this->coupon);
|
||||
|
||||
$this->coupon->forceFill([
|
||||
'lemonsqueezy_discount_id' => null,
|
||||
'lemonsqueezy_snapshot' => null,
|
||||
'lemonsqueezy_last_synced_at' => now(),
|
||||
'paddle_discount_id' => null,
|
||||
'paddle_snapshot' => null,
|
||||
'paddle_last_synced_at' => now(),
|
||||
])->save();
|
||||
|
||||
return;
|
||||
@@ -42,12 +42,12 @@ class SyncCouponToLemonSqueezy implements ShouldQueue
|
||||
$data = $discounts->updateDiscount($this->coupon);
|
||||
|
||||
$this->coupon->forceFill([
|
||||
'lemonsqueezy_discount_id' => $data['id'] ?? $this->coupon->lemonsqueezy_discount_id,
|
||||
'lemonsqueezy_snapshot' => $data,
|
||||
'lemonsqueezy_last_synced_at' => now(),
|
||||
'paddle_discount_id' => $data['id'] ?? $this->coupon->paddle_discount_id,
|
||||
'paddle_snapshot' => $data,
|
||||
'paddle_last_synced_at' => now(),
|
||||
])->save();
|
||||
} catch (LemonSqueezyException $exception) {
|
||||
Log::channel('lemonsqueezy-sync')->error('Failed syncing coupon to Lemon Squeezy', [
|
||||
} catch (PaddleException $exception) {
|
||||
Log::channel('paddle-sync')->error('Failed syncing coupon to Paddle', [
|
||||
'coupon_id' => $this->coupon->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
@@ -55,7 +55,7 @@ class SyncCouponToLemonSqueezy implements ShouldQueue
|
||||
]);
|
||||
|
||||
$this->coupon->forceFill([
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'paddle_snapshot' => [
|
||||
'error' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
'context' => $exception->context(),
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyAddonCatalogService;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleAddonCatalogService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||
class SyncPackageAddonToPaddle implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
@@ -26,7 +26,7 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||
*/
|
||||
public function __construct(private readonly int $addonId, private readonly array $options = []) {}
|
||||
|
||||
public function handle(LemonSqueezyAddonCatalogService $catalog): void
|
||||
public function handle(PaddleAddonCatalogService $catalog): void
|
||||
{
|
||||
$addon = PackageAddon::query()->find($this->addonId);
|
||||
|
||||
@@ -39,7 +39,7 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||
$priceOverrides = Arr::get($this->options, 'price', []);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->storeDryRunSnapshot($addon, $productOverrides, $priceOverrides);
|
||||
$this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -47,41 +47,41 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||
// Mark syncing (metadata)
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'lemonsqueezy_sync_status' => 'syncing',
|
||||
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||
'paddle_sync_status' => 'syncing',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
]),
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
||||
|
||||
$productResponse = $addon->metadata['lemonsqueezy_product_id'] ?? null
|
||||
? $catalog->updateProduct($addon->metadata['lemonsqueezy_product_id'], $addon, $payloadOverrides['product'])
|
||||
$productResponse = $addon->metadata['paddle_product_id'] ?? null
|
||||
? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product'])
|
||||
: $catalog->createProduct($addon, $payloadOverrides['product']);
|
||||
|
||||
$productId = (string) ($productResponse['id'] ?? $addon->metadata['lemonsqueezy_product_id'] ?? null);
|
||||
$productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null);
|
||||
|
||||
if (! $productId) {
|
||||
throw new LemonSqueezyException('Lemon Squeezy product ID missing after addon sync.');
|
||||
throw new PaddleException('Paddle product ID missing after addon sync.');
|
||||
}
|
||||
|
||||
$priceResponse = $addon->variant_id
|
||||
? $catalog->updatePrice($addon->variant_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
|
||||
$priceResponse = $addon->price_id
|
||||
? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
|
||||
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
|
||||
|
||||
$priceId = (string) ($priceResponse['id'] ?? $addon->variant_id);
|
||||
$priceId = (string) ($priceResponse['id'] ?? $addon->price_id);
|
||||
|
||||
if (! $priceId) {
|
||||
throw new LemonSqueezyException('Lemon Squeezy variant ID missing after addon sync.');
|
||||
throw new PaddleException('Paddle price ID missing after addon sync.');
|
||||
}
|
||||
|
||||
$addon->forceFill([
|
||||
'variant_id' => $priceId,
|
||||
'price_id' => $priceId,
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'lemonsqueezy_sync_status' => 'synced',
|
||||
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_product_id' => $productId,
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'paddle_sync_status' => 'synced',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_product_id' => $productId,
|
||||
'paddle_snapshot' => [
|
||||
'product' => $productResponse,
|
||||
'price' => $priceResponse,
|
||||
'payload' => $payloadOverrides,
|
||||
@@ -89,7 +89,7 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||
]),
|
||||
])->save();
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy addon sync failed', [
|
||||
Log::channel('paddle-sync')->error('Paddle addon sync failed', [
|
||||
'addon_id' => $addon->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
@@ -97,9 +97,9 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'lemonsqueezy_sync_status' => 'failed',
|
||||
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_error' => [
|
||||
'paddle_sync_status' => 'failed',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_error' => [
|
||||
'message' => $exception->getMessage(),
|
||||
'class' => $exception::class,
|
||||
],
|
||||
@@ -145,22 +145,22 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||
* @param array<string, mixed> $productOverrides
|
||||
* @param array<string, mixed> $priceOverrides
|
||||
*/
|
||||
protected function storeDryRunSnapshot(PackageAddon $addon, array $productOverrides, array $priceOverrides): void
|
||||
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void
|
||||
{
|
||||
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
||||
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'lemonsqueezy_sync_status' => 'dry-run',
|
||||
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'paddle_sync_status' => 'dry-run',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_snapshot' => [
|
||||
'dry_run' => true,
|
||||
'payload' => $payloadOverrides,
|
||||
],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy addon dry-run snapshot generated', [
|
||||
Log::channel('paddle-sync')->info('Paddle addon dry-run snapshot generated', [
|
||||
'addon_id' => $addon->id,
|
||||
]);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleCatalogService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class SyncPackageToLemonSqueezy implements ShouldQueue
|
||||
class SyncPackageToPaddle implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
@@ -26,7 +26,7 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
|
||||
*/
|
||||
public function __construct(private readonly int $packageId, private readonly array $options = []) {}
|
||||
|
||||
public function handle(LemonSqueezyCatalogService $catalog): void
|
||||
public function handle(PaddleCatalogService $catalog): void
|
||||
{
|
||||
$package = Package::query()->find($this->packageId);
|
||||
|
||||
@@ -45,37 +45,37 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
|
||||
}
|
||||
|
||||
$package->forceFill([
|
||||
'lemonsqueezy_sync_status' => 'syncing',
|
||||
'paddle_sync_status' => 'syncing',
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$productResponse = $package->lemonsqueezy_product_id
|
||||
? $catalog->updateProduct($package->lemonsqueezy_product_id, $package, $productOverrides)
|
||||
$productResponse = $package->paddle_product_id
|
||||
? $catalog->updateProduct($package->paddle_product_id, $package, $productOverrides)
|
||||
: $catalog->createProduct($package, $productOverrides);
|
||||
|
||||
$productId = (string) ($productResponse['id'] ?? $package->lemonsqueezy_product_id);
|
||||
$productId = (string) ($productResponse['id'] ?? $package->paddle_product_id);
|
||||
|
||||
if (! $productId) {
|
||||
throw new LemonSqueezyException('Lemon Squeezy product ID missing after sync.');
|
||||
throw new PaddleException('Paddle product ID missing after sync.');
|
||||
}
|
||||
|
||||
$package->lemonsqueezy_product_id = $productId;
|
||||
$package->paddle_product_id = $productId;
|
||||
|
||||
$priceResponse = $package->lemonsqueezy_variant_id
|
||||
? $catalog->updatePrice($package->lemonsqueezy_variant_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
|
||||
$priceResponse = $package->paddle_price_id
|
||||
? $catalog->updatePrice($package->paddle_price_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
|
||||
: $catalog->createPrice($package, $productId, $priceOverrides);
|
||||
|
||||
$priceId = (string) ($priceResponse['id'] ?? $package->lemonsqueezy_variant_id);
|
||||
$priceId = (string) ($priceResponse['id'] ?? $package->paddle_price_id);
|
||||
|
||||
if (! $priceId) {
|
||||
throw new LemonSqueezyException('Lemon Squeezy variant ID missing after sync.');
|
||||
throw new PaddleException('Paddle price ID missing after sync.');
|
||||
}
|
||||
|
||||
$package->forceFill([
|
||||
'lemonsqueezy_variant_id' => $priceId,
|
||||
'lemonsqueezy_sync_status' => 'synced',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'paddle_price_id' => $priceId,
|
||||
'paddle_sync_status' => 'synced',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => [
|
||||
'product' => $productResponse,
|
||||
'price' => $priceResponse,
|
||||
'payload' => [
|
||||
@@ -85,16 +85,16 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
|
||||
],
|
||||
])->save();
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package sync failed', [
|
||||
Log::channel('paddle-sync')->error('Paddle package sync failed', [
|
||||
'package_id' => $package->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
$package->forceFill([
|
||||
'lemonsqueezy_sync_status' => 'failed',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => array_merge($package->lemonsqueezy_snapshot ?? [], [
|
||||
'paddle_sync_status' => 'failed',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => array_merge($package->paddle_snapshot ?? [], [
|
||||
'error' => [
|
||||
'message' => $exception->getMessage(),
|
||||
'class' => $exception::class,
|
||||
@@ -110,19 +110,19 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
|
||||
* @param array<string, mixed> $productOverrides
|
||||
* @param array<string, mixed> $priceOverrides
|
||||
*/
|
||||
protected function storeDryRunSnapshot(LemonSqueezyCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
|
||||
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
|
||||
{
|
||||
$productPayload = $catalog->buildProductPayload($package, $productOverrides);
|
||||
$pricePayload = $catalog->buildPricePayload(
|
||||
$package,
|
||||
$package->lemonsqueezy_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
|
||||
$package->paddle_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
|
||||
$priceOverrides
|
||||
);
|
||||
|
||||
$package->forceFill([
|
||||
'lemonsqueezy_sync_status' => 'dry-run',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'paddle_sync_status' => 'dry-run',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => [
|
||||
'dry_run' => true,
|
||||
'payload' => [
|
||||
'product' => $productPayload,
|
||||
@@ -131,7 +131,7 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
|
||||
],
|
||||
])->save();
|
||||
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package dry-run snapshot generated', [
|
||||
Log::channel('paddle-sync')->info('Paddle package dry-run snapshot generated', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class PurchaseConfirmation extends Mailable
|
||||
|
||||
private function formattedTotal(): string
|
||||
{
|
||||
$totals = $this->purchase->metadata['lemonsqueezy_totals'] ?? [];
|
||||
$totals = $this->purchase->metadata['paddle_totals'] ?? [];
|
||||
$currency = $totals['currency']
|
||||
?? $this->purchase->metadata['currency']
|
||||
?? $this->purchase->package?->currency
|
||||
@@ -113,7 +113,7 @@ class PurchaseConfirmation extends Mailable
|
||||
|
||||
private function providerLabel(): string
|
||||
{
|
||||
$provider = $this->purchase->provider ?? 'lemonsqueezy';
|
||||
$provider = $this->purchase->provider ?? 'paddle';
|
||||
$labelKey = 'emails.purchase.provider.'.$provider;
|
||||
$label = __($labelKey);
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiEditOutput extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'request_id',
|
||||
'photo_id',
|
||||
'storage_disk',
|
||||
'storage_path',
|
||||
'mime_type',
|
||||
'width',
|
||||
'height',
|
||||
'bytes',
|
||||
'checksum',
|
||||
'provider_asset_id',
|
||||
'provider_url',
|
||||
'is_primary',
|
||||
'safety_state',
|
||||
'safety_reasons',
|
||||
'generated_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_primary' => 'boolean',
|
||||
'safety_reasons' => 'array',
|
||||
'metadata' => 'array',
|
||||
'generated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function request(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiEditRequest::class, 'request_id');
|
||||
}
|
||||
|
||||
public function photo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Photo::class);
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AiEditRequest extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_SUCCEEDED = 'succeeded';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_BLOCKED = 'blocked';
|
||||
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'photo_id',
|
||||
'style_id',
|
||||
'requested_by_user_id',
|
||||
'provider',
|
||||
'provider_model',
|
||||
'status',
|
||||
'safety_state',
|
||||
'prompt',
|
||||
'negative_prompt',
|
||||
'input_image_path',
|
||||
'requested_by_device_id',
|
||||
'requested_by_session_id',
|
||||
'idempotency_key',
|
||||
'safety_reasons',
|
||||
'failure_code',
|
||||
'failure_message',
|
||||
'queued_at',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'expires_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'safety_reasons' => 'array',
|
||||
'metadata' => 'array',
|
||||
'queued_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function photo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Photo::class);
|
||||
}
|
||||
|
||||
public function style(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiStyle::class, 'style_id');
|
||||
}
|
||||
|
||||
public function requestedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requested_by_user_id');
|
||||
}
|
||||
|
||||
public function outputs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiEditOutput::class, 'request_id');
|
||||
}
|
||||
|
||||
public function providerRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiProviderRun::class, 'request_id');
|
||||
}
|
||||
|
||||
public function usageLedgers(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiUsageLedger::class, 'request_id');
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class AiEditingSetting extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_enabled' => 'boolean',
|
||||
'queue_auto_dispatch' => 'boolean',
|
||||
'queue_max_polls' => 'integer',
|
||||
'blocked_terms' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn () => static::flushCache());
|
||||
static::deleted(fn () => static::flushCache());
|
||||
}
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
/** @var self */
|
||||
return Cache::remember('ai_editing.settings', now()->addMinutes(10), static function (): self {
|
||||
try {
|
||||
return static::query()->firstOrCreate(['id' => 1], static::defaults());
|
||||
} catch (Throwable) {
|
||||
return new static(static::defaults());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'is_enabled' => true,
|
||||
'default_provider' => (string) config('ai-editing.default_provider', 'runware'),
|
||||
'fallback_provider' => null,
|
||||
'runware_mode' => (string) config('ai-editing.providers.runware.mode', 'live'),
|
||||
'queue_auto_dispatch' => (bool) config('ai-editing.queue.auto_dispatch', false),
|
||||
'queue_name' => (string) config('ai-editing.queue.name', 'default'),
|
||||
'queue_max_polls' => max(1, (int) config('ai-editing.queue.max_polls', 6)),
|
||||
'blocked_terms' => array_values(array_filter((array) config('ai-editing.safety.prompt.blocked_terms', []))),
|
||||
'status_message' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function flushCache(): void
|
||||
{
|
||||
Cache::forget('ai_editing.settings');
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiProviderRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCEEDED = 'succeeded';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'request_id',
|
||||
'provider',
|
||||
'attempt',
|
||||
'provider_task_id',
|
||||
'status',
|
||||
'http_status',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'duration_ms',
|
||||
'cost_usd',
|
||||
'tokens_input',
|
||||
'tokens_output',
|
||||
'request_payload',
|
||||
'response_payload',
|
||||
'error_message',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'request_payload' => 'array',
|
||||
'response_payload' => 'array',
|
||||
'metadata' => 'array',
|
||||
'cost_usd' => 'decimal:5',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function request(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiEditRequest::class, 'request_id');
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AiStyle extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $style): void {
|
||||
if ((int) ($style->version ?? 0) < 1) {
|
||||
$style->version = 1;
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function (self $style): void {
|
||||
$versionedFields = [
|
||||
'prompt_template',
|
||||
'negative_prompt_template',
|
||||
'provider',
|
||||
'provider_model',
|
||||
'metadata',
|
||||
'is_premium',
|
||||
'requires_source_image',
|
||||
];
|
||||
|
||||
if ($style->isDirty($versionedFields)) {
|
||||
$current = max(1, (int) ($style->getOriginal('version') ?? 1));
|
||||
$requested = max(1, (int) ($style->version ?? 0));
|
||||
$style->version = max($requested, $current + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'name',
|
||||
'version',
|
||||
'category',
|
||||
'description',
|
||||
'prompt_template',
|
||||
'negative_prompt_template',
|
||||
'provider',
|
||||
'provider_model',
|
||||
'requires_source_image',
|
||||
'is_premium',
|
||||
'is_active',
|
||||
'sort',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'requires_source_image' => 'boolean',
|
||||
'is_premium' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'version' => 'integer',
|
||||
'sort' => 'integer',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function editRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiEditRequest::class, 'style_id');
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiUsageLedger extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_DEBIT = 'debit';
|
||||
|
||||
public const TYPE_CREDIT = 'credit';
|
||||
|
||||
public const TYPE_REFUND = 'refund';
|
||||
|
||||
public const TYPE_ADJUSTMENT = 'adjustment';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'request_id',
|
||||
'entry_type',
|
||||
'quantity',
|
||||
'unit_cost_usd',
|
||||
'amount_usd',
|
||||
'currency',
|
||||
'package_context',
|
||||
'notes',
|
||||
'recorded_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'unit_cost_usd' => 'decimal:5',
|
||||
'amount_usd' => 'decimal:5',
|
||||
'recorded_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function request(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiEditRequest::class, 'request_id');
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,7 @@ class CheckoutSession extends Model
|
||||
|
||||
public const PROVIDER_NONE = 'none';
|
||||
|
||||
public const PROVIDER_LEMONSQUEEZY = 'lemonsqueezy';
|
||||
|
||||
public const PROVIDER_PAYPAL = 'paypal';
|
||||
public const PROVIDER_PADDLE = 'paddle';
|
||||
|
||||
public const PROVIDER_FREE = 'free';
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user