Compare commits
377 Commits
4b1785fb85
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9409e961d | ||
|
|
1f9a43806a | ||
|
|
e3bb1642db | ||
|
|
83cf863548 | ||
|
|
6cc463fc70 | ||
|
|
c0c082975e | ||
|
|
64b3bf3ed4 | ||
|
|
922da46331 | ||
|
|
ddbfa38db1 | ||
|
|
3ba4d11d92 | ||
|
|
d2808ffa4f | ||
|
|
8cc0918881 | ||
|
|
fb45d1f6ab | ||
|
|
1d2242fb4d | ||
|
|
36bed12ff9 | ||
|
|
df00deb0df | ||
|
|
0291d537fb | ||
|
|
fa114ac0dc | ||
|
|
61d1bbc707 | ||
|
|
0a08f2704f | ||
|
|
b14435df8b | ||
|
|
18b4f36fcf | ||
|
|
c6aaf859f5 | ||
|
|
ba56cb4e61 | ||
|
|
5f75c7ca6a | ||
|
|
4e0d156065 | ||
|
|
fa630e335d | ||
|
|
6eafec2128 | ||
|
|
04c399aeb6 | ||
|
|
7262617897 | ||
|
|
0d7a861875 | ||
|
|
c43327af74 | ||
|
|
e911c2bd16 | ||
|
|
beaff1c4e9 | ||
|
|
eee58f2d0c | ||
|
|
1cb150b819 | ||
|
|
17025df47b | ||
|
|
7025418d9e | ||
|
|
5c78ac00dd | ||
|
|
66c7131d79 | ||
|
|
239f55f9c5 | ||
|
|
fc5dfb272c | ||
|
|
56a39d0535 | ||
|
|
0eacb5646c | ||
|
|
b6d7118772 | ||
|
|
8d7a1d80c2 | ||
|
|
35f68e68d8 | ||
|
|
4be01d77ee | ||
|
|
197e9c988b | ||
|
|
ba8890839b | ||
|
|
6c12d73d68 | ||
|
|
c59f9ae994 | ||
|
|
f2d7ed6646 | ||
|
|
17d979e3c3 | ||
|
|
fc1149118c | ||
|
|
8cee7ef004 | ||
|
|
1b131851fb | ||
|
|
7980980bed | ||
|
|
c6a7f31ea0 | ||
|
|
c4ccfa1353 | ||
|
|
b38b63e04a | ||
|
|
25e3d5ef7d | ||
|
|
1119adcfe9 | ||
|
|
c91480a870 | ||
|
|
7f1e6c06fb | ||
|
|
a820ef2e8b | ||
|
|
a0ef90e13a | ||
|
|
2f4ebfefd4 | ||
|
|
f161366119 | ||
|
|
6bc73637b1 | ||
|
|
7ee56cefe4 | ||
|
|
38f89be99e | ||
|
|
6e19c3d7b6 | ||
|
|
b3bf45482a | ||
|
|
d4162dd105 | ||
|
|
2270462ecb | ||
|
|
af4685f703 | ||
|
|
43dc71815f | ||
|
|
f5f6555b09 | ||
|
|
0e2638c0f8 | ||
|
|
798fa5e9f5 | ||
|
|
802a3fe132 | ||
|
|
303a33dd2e | ||
|
|
074cd0f431 | ||
|
|
bca2e90b54 | ||
|
|
bd3a96a58a | ||
|
|
876731b051 | ||
|
|
93efb78091 | ||
|
|
68f2eb871b | ||
|
|
1ca2cdf2a8 | ||
|
|
0ad8b9d8a9 | ||
|
|
779dd520ad | ||
|
|
061ad6cf24 | ||
|
|
f707feb45e | ||
|
|
93ea12fa04 | ||
|
|
f56b2b81ed | ||
|
|
4ce64f0a35 | ||
|
|
dd2997808b | ||
|
|
19f5e60870 | ||
|
|
b4a2e39903 | ||
|
|
d8f67522ee | ||
|
|
e2948c0388 | ||
|
|
594c3b1772 | ||
|
|
59cedf216a | ||
|
|
4e2ab9e589 | ||
|
|
6c857b5765 | ||
|
|
4e65fe1d5f | ||
|
|
86b7eddd47 | ||
|
|
975e257c44 | ||
|
|
3115a6461d | ||
|
|
d93e6475a4 | ||
|
|
a9c7242e15 | ||
|
|
8887d8e16c | ||
|
|
684f54f58f | ||
|
|
b17dd655db | ||
|
|
3255917201 | ||
|
|
29453089cc | ||
|
|
0f4d7450ff | ||
|
|
6c83f4ee4e | ||
|
|
e8bd962a55 | ||
|
|
7f7bebcfde | ||
|
|
d9aea9a6ca | ||
|
|
9ab230f5b7 | ||
|
|
7ee91ff7d7 | ||
|
|
6a056b199c | ||
|
|
6701b48cc8 | ||
|
|
0caed3cc56 | ||
|
|
ab7881077b | ||
|
|
67948cb4f8 | ||
|
|
8c507b8b13 | ||
|
|
f19a83d4ee | ||
|
|
531c666cf0 | ||
|
|
d9c842a7d4 | ||
|
|
c6293628e7 | ||
|
|
d5a447fb28 | ||
|
|
352cfdb69c | ||
|
|
c234b1a1cc | ||
|
|
233a2b224c | ||
|
|
bb462a1709 | ||
|
|
8d140ff86f | ||
|
|
d7a8120ffe | ||
|
|
335087b20f | ||
|
|
564e1dbcf4 | ||
|
|
a35b32892a | ||
|
|
86971bbf0e | ||
|
|
a8edba85df | ||
|
|
1089b09412 | ||
|
|
06f8d892ef | ||
|
|
d47b1deada | ||
|
|
0b9c1a520d | ||
|
|
cedfa28d00 | ||
|
|
f861ea6b07 | ||
|
|
b81be20d60 | ||
|
|
7cde7293cb | ||
|
|
6f9bfd0601 | ||
|
|
8ddc361c91 | ||
|
|
2c55857699 | ||
|
|
458d39a41f | ||
|
|
ff84f9c1e9 | ||
|
|
0207056ec7 | ||
|
|
026259c199 | ||
|
|
e175cc7dde | ||
|
|
086b12b4a5 | ||
|
|
3d59e73b5f | ||
|
|
606c152603 | ||
|
|
bc73884a6d | ||
|
|
b1a7cdbe09 | ||
|
|
34bc7f25d3 | ||
|
|
d773aafdcc | ||
|
|
ab8bd6b10b | ||
|
|
9b5c71cd8b | ||
|
|
cf04988fc2 | ||
|
|
7ed4e581f7 | ||
|
|
20f60e9277 | ||
|
|
549611fb1c | ||
|
|
eb8201ec56 | ||
|
|
91bb09248a | ||
|
|
b6e0005734 | ||
|
|
42b61b122f | ||
|
|
66eb4edd2c | ||
|
|
bc62154bfb | ||
|
|
ddf0c4c389 | ||
|
|
0b352eba12 | ||
|
|
9d2294de5a | ||
|
|
effddf5ab0 | ||
|
|
e709337df2 | ||
|
|
0a03ea71a1 | ||
|
|
3a81c0991b | ||
|
|
32f3696ffb | ||
|
|
901419798d | ||
|
|
82e41790d9 | ||
|
|
ff34175dc3 | ||
|
|
80dd12bb92 | ||
|
|
a01a7ec399 | ||
|
|
5eb0941512 | ||
|
|
e6d1414353 | ||
|
|
9313605c20 | ||
|
|
47fcd72cce | ||
|
|
6481e980c8 | ||
|
|
7e488a865e | ||
|
|
39016f669a | ||
|
|
b39bbfad87 | ||
|
|
683939a354 | ||
|
|
13af8005b8 | ||
|
|
aac4744cb3 | ||
|
|
ffc7a4e80d | ||
|
|
3b35024d23 | ||
|
|
df11a5e37c | ||
|
|
9b7dd0b8ef | ||
|
|
a4abe51c2a | ||
|
|
d186dbad4d | ||
|
|
b6fa8f437f | ||
|
|
09132125c6 | ||
|
|
a70a842778 | ||
|
|
417c9d2615 | ||
|
|
1ce0fad720 | ||
|
|
620dfa415a | ||
|
|
8a456d2c0d | ||
|
|
ddd61e8165 | ||
|
|
0ef8508414 | ||
|
|
3761c92c3f | ||
|
|
1b80c7b3ee | ||
|
|
acd19ccfa0 | ||
|
|
d365536b7d | ||
|
|
d03f7252df | ||
|
|
790ffc5157 | ||
|
|
cb9d40055d | ||
|
|
a7df624865 | ||
|
|
c46f113011 | ||
|
|
845471394c | ||
|
|
43d78dc37d | ||
|
|
7ac33769d2 | ||
|
|
edd17f17e3 | ||
|
|
8141518cfa | ||
|
|
b6102c4ec3 | ||
|
|
cb8e119ef1 | ||
|
|
5a974b25b6 | ||
|
|
84777d7192 | ||
|
|
e253e47943 | ||
|
|
53319922e9 | ||
|
|
7573c58552 | ||
|
|
a59b2c30bc | ||
|
|
782712451f | ||
|
|
323ddea72d | ||
|
|
d6ee372671 | ||
|
|
b53f809769 | ||
|
|
39d568adbd | ||
|
|
6059cb85e1 | ||
|
|
d69cf0e7f4 | ||
|
|
9599a28b95 | ||
|
|
e8ea663da8 | ||
|
|
ada69133a6 | ||
|
|
c27038b741 | ||
|
|
cb9a456b49 | ||
|
|
7bb911b3eb | ||
|
|
1974e31211 | ||
|
|
3b3aee1703 | ||
|
|
bc4142de4d | ||
|
|
c124fee659 | ||
|
|
1239f2b526 | ||
|
|
8655322495 | ||
|
|
8ac0220f5d | ||
|
|
c533d43c0f | ||
|
|
8941860140 | ||
|
|
2945c710e3 | ||
|
|
7b88c1d365 | ||
|
|
b732f88b0e | ||
|
|
7095afa43e | ||
|
|
eb4ec94e01 | ||
|
|
94b736d6ae | ||
|
|
8f556a5678 | ||
|
|
6e8d871d4b | ||
|
|
caaa1eba4b | ||
|
|
9d1cf016d3 | ||
|
|
738af5f76f | ||
|
|
14acce0f4b | ||
|
|
115422e51e | ||
|
|
c7e42c1bf1 | ||
|
|
b09c80ee8d | ||
|
|
2b187e7864 | ||
|
|
b0ba29fcb6 | ||
|
|
a1f37bb491 | ||
|
|
53096fbf29 | ||
|
|
5c19cfb80a | ||
|
|
caba9e77c9 | ||
|
|
d60bdaaf01 | ||
|
|
22931f1500 | ||
|
|
d3ef54a410 | ||
|
|
318f812adf | ||
|
|
2016c32dbb | ||
|
|
00b3132d31 | ||
|
|
0486aaaf88 | ||
|
|
5388a051cb | ||
|
|
7614d896aa | ||
|
|
736ea322dd | ||
|
|
9506affdc3 | ||
|
|
c53a1448d9 | ||
|
|
99a02b991c | ||
|
|
6f49564e1b | ||
|
|
c9000f8f8c | ||
|
|
00fdfa2948 | ||
|
|
387c1cb71b | ||
|
|
7712d2940c | ||
|
|
c4d274c614 | ||
|
|
c4e155fc02 | ||
|
|
e07322d3d3 | ||
|
|
d27dd5aa48 | ||
|
|
c78ac978a1 | ||
|
|
59c7b18ccc | ||
|
|
5da553ae54 | ||
|
|
1918c1c101 | ||
|
|
e084049891 | ||
|
|
003f48addb | ||
|
|
0f9fc76711 | ||
|
|
8944710e22 | ||
|
|
92870b3b8c | ||
|
|
bd2064a19a | ||
|
|
bd804f373b | ||
|
|
249a5639a9 | ||
|
|
5f3d6af9f0 | ||
|
|
775786c6f1 | ||
|
|
3546802921 | ||
|
|
7ab6cd2125 | ||
|
|
6dddf6e2f0 | ||
|
|
1de338620c | ||
|
|
ab74ae723d | ||
|
|
412931d755 | ||
|
|
3c11aebdbc | ||
|
|
fd24014852 | ||
|
|
19c8a67ce0 | ||
|
|
8df6511b18 | ||
|
|
e668f75633 | ||
|
|
6c121fc8ba | ||
|
|
27dff67b69 | ||
|
|
c76081de05 | ||
|
|
307521c12b | ||
|
|
5d3c0f8dc9 | ||
|
|
a7abf4636f | ||
|
|
e31a581a50 | ||
|
|
f2cd027472 | ||
|
|
feff332357 | ||
|
|
678de91446 | ||
|
|
ec78da9f17 | ||
|
|
dc5e5181b6 | ||
|
|
7a205b11ec | ||
|
|
4cd9c62fb9 | ||
|
|
fc2a14d78d | ||
|
|
0a42a1b2cd | ||
|
|
2b1b9e30a3 | ||
|
|
6e5e3f5ecc | ||
|
|
6e74d8f06f | ||
|
|
2d81a3a319 | ||
|
|
c90066408d | ||
|
|
dea5656e62 | ||
|
|
37e20ed32f | ||
|
|
749cb04cec | ||
|
|
f452c486d4 | ||
|
|
9d737cd743 | ||
|
|
1ddabe4f0e | ||
|
|
bb7fc84363 | ||
|
|
253bda2f16 | ||
|
|
a251740aff | ||
|
|
47f6343347 | ||
|
|
ad336f5e18 | ||
|
|
c08fbf2e45 | ||
|
|
0f20052db7 | ||
|
|
f303e31fad | ||
|
|
9201207dc9 | ||
|
|
2070e518af | ||
|
|
66f1935c2e | ||
|
|
a860285132 | ||
|
|
b23331d069 | ||
|
|
2cb5171420 | ||
|
|
78f2dc4e74 | ||
|
|
44f4a7656b | ||
|
|
5866a0826c | ||
|
|
8e1031fff0 |
@@ -1 +0,0 @@
|
|||||||
fotospiel-app-cbnv
|
|
||||||
24
.env.example
24
.env.example
@@ -117,14 +117,22 @@ PAYPAL_CLIENT_ID=
|
|||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
PAYPAL_SANDBOX=true
|
PAYPAL_SANDBOX=true
|
||||||
|
|
||||||
# Paddle Billing
|
# Lemon Squeezy Billing
|
||||||
PADDLE_SANDBOX=true
|
LEMONSQUEEZY_STORE_ID=284860
|
||||||
PADDLE_API_KEY=
|
LEMONSQUEEZY_API_KEY=
|
||||||
PADDLE_CLIENT_ID=
|
LEMONSQUEEZY_WEBHOOK_SECRET=
|
||||||
PADDLE_WEBHOOK_SECRET=
|
LEMONSQUEEZY_WEBHOOK_EVENTS=
|
||||||
PADDLE_PUBLIC_KEY=
|
LEMONSQUEEZY_TEST_MODE=false
|
||||||
PADDLE_BASE_URL=
|
LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
|
||||||
PADDLE_CONSOLE_URL=
|
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=
|
||||||
|
|
||||||
# Sanctum / SPA auth
|
# Sanctum / SPA auth
|
||||||
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
|
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.
|
- 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.
|
- 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; 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.
|
- 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: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
|
- Payment Systems: Lemon Squeezy (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.
|
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
||||||
|
|
||||||
## Repo Structure (high-level)
|
## Repo Structure (high-level)
|
||||||
@@ -38,6 +38,9 @@ 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/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||||
- resources/js/pages/ — Inertia pages (React).
|
- resources/js/pages/ — Inertia pages (React).
|
||||||
- docs/archive/README.md — historical PRP context.
|
- 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
|
## Standard Workflows
|
||||||
- Coding tasks (Codegen Agent):
|
- Coding tasks (Codegen Agent):
|
||||||
@@ -58,7 +61,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
#### Billing & Packages
|
#### Billing & Packages
|
||||||
- package:check-status — check event package status.
|
- package:check-status — check event package status.
|
||||||
- packages:migrate-legacy — migrate legacy package purchases.
|
- packages:migrate-legacy — migrate legacy package purchases.
|
||||||
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run).
|
- lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
|
||||||
- coupons:export — export coupon redemptions.
|
- coupons:export — export coupon redemptions.
|
||||||
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
|
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
|
||||||
|
|
||||||
@@ -93,7 +96,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).
|
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
|
||||||
- inspire — inspiring quote (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.
|
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
|
||||||
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
|
- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
|
||||||
|
|
||||||
## PWA Architecture
|
## PWA Architecture
|
||||||
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
||||||
@@ -620,6 +623,134 @@ export default () => (
|
|||||||
| overflow-ellipsis | text-ellipsis |
|
| overflow-ellipsis | text-ellipsis |
|
||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| 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>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Issue Tracking
|
## Issue Tracking
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ COPY . .
|
|||||||
COPY --from=vendor /var/www/html/vendor ./vendor
|
COPY --from=vendor /var/www/html/vendor ./vendor
|
||||||
COPY --from=node_builder /var/www/html/public/build ./public/build
|
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 \
|
RUN php artisan config:clear \
|
||||||
&& php artisan config:cache \
|
&& php artisan config:cache \
|
||||||
&& php artisan route:clear \
|
&& php artisan route:clear \
|
||||||
|
|||||||
144
app/Console/Commands/AiEditsBackfillStorageCommand.php
Normal file
144
app/Console/Commands/AiEditsBackfillStorageCommand.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Console/Commands/AiEditsPruneCommand.php
Normal file
69
app/Console/Commands/AiEditsPruneCommand.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
179
app/Console/Commands/AiEditsRecoverStuckCommand.php
Normal file
179
app/Console/Commands/AiEditsRecoverStuckCommand.php
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/Console/Commands/LemonSqueezyRegisterWebhooks.php
Normal file
130
app/Console/Commands/LemonSqueezyRegisterWebhooks.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?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' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Jobs\PullPackageFromPaddle;
|
use App\Jobs\PullPackageFromLemonSqueezy;
|
||||||
use App\Jobs\SyncPackageToPaddle;
|
use App\Jobs\SyncPackageToLemonSqueezy;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class PaddleSyncPackages extends Command
|
class LemonSqueezySyncPackages extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'paddle:sync-packages
|
protected $signature = 'lemonsqueezy:sync-packages
|
||||||
{--package=* : Limit sync to the given package IDs or slugs}
|
{--package=* : Limit sync to the given package IDs or slugs}
|
||||||
{--dry-run : Generate payload snapshots without calling Paddle}
|
{--dry-run : Generate payload snapshots without calling Lemon Squeezy}
|
||||||
{--pull : Fetch remote Paddle state instead of pushing local changes}
|
{--pull : Fetch remote Lemon Squeezy state instead of pushing local changes}
|
||||||
{--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs}
|
{--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs}
|
||||||
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
|
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
|
||||||
|
|
||||||
protected $description = 'Synchronise local packages with Paddle products and prices.';
|
protected $description = 'Synchronise local packages with Lemon Squeezy products and variants.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
@@ -52,7 +52,7 @@ class PaddleSyncPackages extends Command
|
|||||||
});
|
});
|
||||||
|
|
||||||
$this->info(sprintf(
|
$this->info(sprintf(
|
||||||
'Queued %d package %s for Paddle %s.',
|
'Queued %d package %s for Lemon Squeezy %s.',
|
||||||
$packages->count(),
|
$packages->count(),
|
||||||
Str::plural('entry', $packages->count()),
|
Str::plural('entry', $packages->count()),
|
||||||
$pull ? 'pull' : 'sync'
|
$pull ? 'pull' : 'sync'
|
||||||
@@ -97,22 +97,22 @@ class PaddleSyncPackages extends Command
|
|||||||
|
|
||||||
protected function guardUnmappedPackages(Collection $packages): bool
|
protected function guardUnmappedPackages(Collection $packages): bool
|
||||||
{
|
{
|
||||||
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id));
|
$unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id));
|
||||||
|
|
||||||
if ($unmapped->isEmpty()) {
|
if ($unmapped->isEmpty()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.');
|
$this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.');
|
||||||
$this->table(
|
$this->table(
|
||||||
['ID', 'Slug', 'Missing'],
|
['ID', 'Slug', 'Missing'],
|
||||||
$unmapped->map(function (Package $package): array {
|
$unmapped->map(function (Package $package): array {
|
||||||
$missing = [];
|
$missing = [];
|
||||||
if (blank($package->paddle_product_id)) {
|
if (blank($package->lemonsqueezy_product_id)) {
|
||||||
$missing[] = 'product_id';
|
$missing[] = 'product_id';
|
||||||
}
|
}
|
||||||
if (blank($package->paddle_price_id)) {
|
if (blank($package->lemonsqueezy_variant_id)) {
|
||||||
$missing[] = 'price_id';
|
$missing[] = 'variant_id';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -133,26 +133,26 @@ class PaddleSyncPackages extends Command
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($queue) {
|
if ($queue) {
|
||||||
SyncPackageToPaddle::dispatch($package->id, $context);
|
SyncPackageToLemonSqueezy::dispatch($package->id, $context);
|
||||||
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
|
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncPackageToPaddle::dispatchSync($package->id, $context);
|
SyncPackageToLemonSqueezy::dispatchSync($package->id, $context);
|
||||||
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
|
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function dispatchPullJob(Package $package, bool $queue): void
|
protected function dispatchPullJob(Package $package, bool $queue): void
|
||||||
{
|
{
|
||||||
if ($queue) {
|
if ($queue) {
|
||||||
PullPackageFromPaddle::dispatch($package->id);
|
PullPackageFromLemonSqueezy::dispatch($package->id);
|
||||||
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
|
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PullPackageFromPaddle::dispatchSync($package->id);
|
PullPackageFromLemonSqueezy::dispatchSync($package->id);
|
||||||
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
|
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
|
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables;
|
||||||
|
|
||||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@@ -13,12 +13,9 @@ use Filament\Tables\Filters\SelectFilter;
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class TenantPaddleHealthTable
|
class TenantCheckoutHealthTable
|
||||||
{
|
{
|
||||||
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
|
|
||||||
|
|
||||||
public static function configure(Table $table): Table
|
public static function configure(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@@ -35,11 +32,6 @@ class TenantPaddleHealthTable
|
|||||||
->label(__('admin.tenants.fields.contact_email'))
|
->label(__('admin.tenants.fields.contact_email'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_customer_id')
|
|
||||||
->label('Paddle customer')
|
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
|
||||||
->copyable()
|
|
||||||
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
|
||||||
TextColumn::make('subscription_status')
|
TextColumn::make('subscription_status')
|
||||||
->label('Subscription')
|
->label('Subscription')
|
||||||
->badge()
|
->badge()
|
||||||
@@ -56,134 +48,77 @@ class TenantPaddleHealthTable
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->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')
|
IconColumn::make('status_mismatch')
|
||||||
->label('Status mismatch')
|
->label('Status mismatch')
|
||||||
->boolean()
|
->boolean()
|
||||||
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
||||||
TextColumn::make('paddle_customer_duplicates')
|
TextColumn::make('last_checkout_transaction_at')
|
||||||
->label('Paddle duplicates')
|
->label('Last transaction')
|
||||||
->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()
|
->badge()
|
||||||
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
||||||
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
|
->getStateUsing(fn (Tenant $record) => $record->last_checkout_transaction_at
|
||||||
? Carbon::parse($record->last_paddle_transaction_at)
|
? Carbon::parse($record->last_checkout_transaction_at)
|
||||||
: null)
|
: null)
|
||||||
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_transaction_count_window')
|
TextColumn::make('checkout_transaction_count_window')
|
||||||
->label('Paddle tx (30d)')
|
->label('Transactions (30d)')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('paddle_transaction_total_window')
|
TextColumn::make('checkout_transaction_total_window')
|
||||||
->label('Paddle total (30d)')
|
->label('Total (30d)')
|
||||||
->default(0)
|
->default(0)
|
||||||
->money('EUR')
|
->money('EUR')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('paddle_refund_count_window')
|
TextColumn::make('checkout_refund_count_window')
|
||||||
->label('Refunds (30d)')
|
->label('Refunds (30d)')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_refund_total_window')
|
TextColumn::make('checkout_refund_total_window')
|
||||||
->label('Refund total (30d)')
|
->label('Refund total (30d)')
|
||||||
->default(0)
|
->default(0)
|
||||||
->money('EUR')
|
->money('EUR')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_checkout_requires_action_count')
|
TextColumn::make('checkout_requires_action_count')
|
||||||
->label('Checkout action required')
|
->label('Checkout action required')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_checkout_processing_count')
|
TextColumn::make('checkout_processing_count')
|
||||||
->label('Checkout processing')
|
->label('Checkout processing')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_checkout_expired_count')
|
TextColumn::make('checkout_expired_count')
|
||||||
->label('Checkout expired')
|
->label('Checkout expired')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_transaction_count')
|
TextColumn::make('checkout_transaction_count')
|
||||||
->label('Paddle tx (all)')
|
->label('Transactions (all)')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_transaction_total')
|
TextColumn::make('checkout_transaction_total')
|
||||||
->label('Paddle total (all)')
|
->label('Total (all)')
|
||||||
->default(0)
|
->default(0)
|
||||||
->money('EUR')
|
->money('EUR')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->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')
|
Filter::make('status_mismatch')
|
||||||
->label('Status mismatch')
|
->label('Status mismatch')
|
||||||
->indicator('Status mismatch')
|
->indicator('Status mismatch')
|
||||||
@@ -205,39 +140,24 @@ class TenantPaddleHealthTable
|
|||||||
->where('is_suspended', false)
|
->where('is_suspended', false)
|
||||||
->whereNull('pending_deletion_at')
|
->whereNull('pending_deletion_at')
|
||||||
->whereNull('anonymized_at')),
|
->whereNull('anonymized_at')),
|
||||||
Filter::make('paddle_sync_failed')
|
Filter::make('checkout_transaction_stale')
|
||||||
->label('Paddle sync failed')
|
->label('Stale transactions')
|
||||||
->indicator('Paddle sync failed')
|
->indicator('Stale transactions')
|
||||||
->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 {
|
->query(function (Builder $query): Builder {
|
||||||
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||||
|
$provider = TenantCheckoutHealthResource::provider();
|
||||||
|
|
||||||
return $query
|
return $query
|
||||||
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
|
->whereHas('purchases', fn (Builder $query) => $query->where('provider', $provider))
|
||||||
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle')
|
->where('provider', $provider)
|
||||||
->where('purchased_at', '>=', $cutoff));
|
->where('purchased_at', '>=', $cutoff));
|
||||||
}),
|
}),
|
||||||
Filter::make('checkout_attention')
|
Filter::make('checkout_attention')
|
||||||
->label('Checkout attention')
|
->label('Checkout attention')
|
||||||
->indicator('Checkout attention')
|
->indicator('Checkout attention')
|
||||||
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
|
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
|
||||||
$query->where('provider', 'paddle')
|
$query->where('provider', TenantCheckoutHealthResource::provider())
|
||||||
->where(function (Builder $query) {
|
->where(function (Builder $query) {
|
||||||
$query->whereIn('status', [
|
$query->whereIn('status', [
|
||||||
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
||||||
@@ -274,10 +194,11 @@ class TenantPaddleHealthTable
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||||
|
$provider = TenantCheckoutHealthResource::provider();
|
||||||
|
|
||||||
return $query->whereHas('purchases', fn (Builder $query) => $query
|
return $query->whereHas('purchases', fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle')
|
->where('provider', $provider)
|
||||||
->where('refunded', true)
|
->where('refunded', true)
|
||||||
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
||||||
}),
|
}),
|
||||||
@@ -314,13 +235,6 @@ class TenantPaddleHealthTable
|
|||||||
return false;
|
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
|
private static function applyStatusMismatchFilter(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where(function (Builder $query) {
|
return $query->where(function (Builder $query) {
|
||||||
@@ -338,26 +252,13 @@ class TenantPaddleHealthTable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
private static function transactionAgeColor(?Carbon $state): string
|
||||||
{
|
{
|
||||||
if (! $state) {
|
if (! $state) {
|
||||||
return 'gray';
|
return 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
if ($state->lt(now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
|
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths;
|
||||||
|
|
||||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
|
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages\ListTenantCheckoutHealths;
|
||||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
|
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables\TenantCheckoutHealthTable;
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@@ -13,25 +13,25 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class TenantPaddleHealthResource extends Resource
|
class TenantCheckoutHealthResource extends Resource
|
||||||
{
|
{
|
||||||
public const STALE_SYNC_DAYS = 30;
|
|
||||||
|
|
||||||
public const TRANSACTION_WINDOW_DAYS = 30;
|
public const TRANSACTION_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
|
public const DEFAULT_PROVIDER = CheckoutSession::PROVIDER_PAYPAL;
|
||||||
|
|
||||||
protected static ?string $model = Tenant::class;
|
protected static ?string $model = Tenant::class;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
|
||||||
|
|
||||||
protected static ?string $cluster = DailyOpsCluster::class;
|
protected static ?string $cluster = DailyOpsCluster::class;
|
||||||
|
|
||||||
protected static ?string $slug = 'paddle-health';
|
protected static ?string $slug = 'checkout-health';
|
||||||
|
|
||||||
protected static ?int $navigationSort = 20;
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return TenantPaddleHealthTable::configure($table);
|
return TenantCheckoutHealthTable::configure($table);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
@@ -41,7 +41,7 @@ class TenantPaddleHealthResource extends Resource
|
|||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
return __('admin.paddle_health.navigation.label');
|
return __('admin.checkout_health.navigation.label');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
@@ -51,37 +51,32 @@ class TenantPaddleHealthResource extends Resource
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
|
$provider = static::provider();
|
||||||
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
|
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->with(['activeResellerPackage.package'])
|
->with(['activeResellerPackage.package'])
|
||||||
->withExists('activeResellerPackage as has_active_reseller_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([
|
->withCount([
|
||||||
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
|
'purchases as checkout_transaction_count' => fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle')
|
->where('provider', $provider)
|
||||||
->where('refunded', false),
|
->where('refunded', false),
|
||||||
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
|
'purchases as checkout_transaction_count_window' => fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle')
|
->where('provider', $provider)
|
||||||
->where('refunded', false)
|
->where('refunded', false)
|
||||||
->where('purchased_at', '>=', $windowStart),
|
->where('purchased_at', '>=', $windowStart),
|
||||||
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
|
'purchases as checkout_refund_count_window' => fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle')
|
->where('provider', $provider)
|
||||||
->where('refunded', true)
|
->where('refunded', true)
|
||||||
->where('purchased_at', '>=', $windowStart),
|
->where('purchased_at', '>=', $windowStart),
|
||||||
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
|
'checkoutSessions as checkout_requires_action_count' => fn (Builder $query) => $query
|
||||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
->where('provider', $provider)
|
||||||
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
||||||
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
|
'checkoutSessions as checkout_processing_count' => fn (Builder $query) => $query
|
||||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
->where('provider', $provider)
|
||||||
->where('status', CheckoutSession::STATUS_PROCESSING),
|
->where('status', CheckoutSession::STATUS_PROCESSING),
|
||||||
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
|
'checkoutSessions as checkout_expired_count' => fn (Builder $query) => $query
|
||||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
->where('provider', $provider)
|
||||||
->whereNotIn('status', [
|
->whereNotIn('status', [
|
||||||
CheckoutSession::STATUS_COMPLETED,
|
CheckoutSession::STATUS_COMPLETED,
|
||||||
CheckoutSession::STATUS_CANCELLED,
|
CheckoutSession::STATUS_CANCELLED,
|
||||||
@@ -90,32 +85,37 @@ class TenantPaddleHealthResource extends Resource
|
|||||||
->where('expires_at', '<', now()),
|
->where('expires_at', '<', now()),
|
||||||
])
|
])
|
||||||
->withSum([
|
->withSum([
|
||||||
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
|
'purchases as checkout_transaction_total' => fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle')
|
->where('provider', $provider)
|
||||||
->where('refunded', false),
|
->where('refunded', false),
|
||||||
], 'price')
|
], 'price')
|
||||||
->withSum([
|
->withSum([
|
||||||
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
|
'purchases as checkout_transaction_total_window' => fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle')
|
->where('provider', $provider)
|
||||||
->where('refunded', false)
|
->where('refunded', false)
|
||||||
->where('purchased_at', '>=', $windowStart),
|
->where('purchased_at', '>=', $windowStart),
|
||||||
], 'price')
|
], 'price')
|
||||||
->withSum([
|
->withSum([
|
||||||
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
|
'purchases as checkout_refund_total_window' => fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle')
|
->where('provider', $provider)
|
||||||
->where('refunded', true)
|
->where('refunded', true)
|
||||||
->where('purchased_at', '>=', $windowStart),
|
->where('purchased_at', '>=', $windowStart),
|
||||||
], 'price')
|
], 'price')
|
||||||
->withMax([
|
->withMax([
|
||||||
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
|
'purchases as last_checkout_transaction_at' => fn (Builder $query) => $query
|
||||||
->where('provider', 'paddle'),
|
->where('provider', $provider),
|
||||||
], 'purchased_at');
|
], 'purchased_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'index' => ListTenantPaddleHealths::route('/'),
|
'index' => ListTenantCheckoutHealths::route('/'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function provider(): string
|
||||||
|
{
|
||||||
|
return (string) config('checkout.default_provider', self::DEFAULT_PROVIDER);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
305
app/Filament/Resources/AiStyles/AiStyleResource.php
Normal file
305
app/Filament/Resources/AiStyles/AiStyleResource.php
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<?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) : '?'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php
Normal file
26
app/Filament/Resources/AiStyles/Pages/ManageAiStyles.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?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\Coupons\CouponResource;
|
||||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
use App\Jobs\SyncCouponToPaddle;
|
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||||
|
|
||||||
class CreateCoupon extends AuditedCreateRecord
|
class CreateCoupon extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
|
|||||||
{
|
{
|
||||||
parent::afterCreate();
|
parent::afterCreate();
|
||||||
|
|
||||||
SyncCouponToPaddle::dispatch($this->record);
|
SyncCouponToLemonSqueezy::dispatch($this->record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
|
|||||||
|
|
||||||
use App\Filament\Resources\Coupons\CouponResource;
|
use App\Filament\Resources\Coupons\CouponResource;
|
||||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
use App\Jobs\SyncCouponToPaddle;
|
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\ForceDeleteAction;
|
use Filament\Actions\ForceDeleteAction;
|
||||||
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
|
|||||||
source: static::class
|
source: static::class
|
||||||
);
|
);
|
||||||
|
|
||||||
SyncCouponToPaddle::dispatch($record, true);
|
SyncCouponToLemonSqueezy::dispatch($record, true);
|
||||||
}),
|
}),
|
||||||
ForceDeleteAction::make()
|
ForceDeleteAction::make()
|
||||||
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
|
|||||||
{
|
{
|
||||||
parent::afterSave();
|
parent::afterSave();
|
||||||
|
|
||||||
SyncCouponToPaddle::dispatch($this->record);
|
SyncCouponToLemonSqueezy::dispatch($this->record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class RedemptionsRelationManager extends RelationManager
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->recordTitleAttribute('paddle_transaction_id')
|
->recordTitleAttribute('lemonsqueezy_order_id')
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label(__('Tenant'))
|
->label(__('Tenant'))
|
||||||
@@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
|
|||||||
'failed' => 'danger',
|
'failed' => 'danger',
|
||||||
default => 'warning',
|
default => 'warning',
|
||||||
}),
|
}),
|
||||||
TextColumn::make('paddle_transaction_id')
|
TextColumn::make('lemonsqueezy_order_id')
|
||||||
->label(__('Transaction'))
|
->label(__('Transaction'))
|
||||||
->copyable()
|
->copyable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|||||||
@@ -123,22 +123,22 @@ class CouponForm
|
|||||||
->nullable()
|
->nullable()
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
Section::make(__('Paddle sync'))
|
Section::make(__('Lemon Squeezy sync'))
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('paddle_mode')
|
Select::make('lemonsqueezy_mode')
|
||||||
->label(__('Paddle mode'))
|
->label(__('Lemon Squeezy mode'))
|
||||||
->options([
|
->options([
|
||||||
'standard' => __('Standard'),
|
'standard' => __('Standard'),
|
||||||
'custom' => __('Custom (one-off)'),
|
'custom' => __('Custom (one-off)'),
|
||||||
])
|
])
|
||||||
->default('standard'),
|
->default('standard'),
|
||||||
Placeholder::make('paddle_discount_id')
|
Placeholder::make('lemonsqueezy_discount_id')
|
||||||
->label(__('Paddle Discount ID'))
|
->label(__('Lemon Squeezy Discount ID'))
|
||||||
->content(fn ($record) => $record?->paddle_discount_id ?? '—'),
|
->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'),
|
||||||
Placeholder::make('paddle_last_synced_at')
|
Placeholder::make('lemonsqueezy_last_synced_at')
|
||||||
->label(__('Last synced'))
|
->label(__('Last synced'))
|
||||||
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
||||||
Placeholder::make('redemptions_count')
|
Placeholder::make('redemptions_count')
|
||||||
->label(__('Total redemptions'))
|
->label(__('Total redemptions'))
|
||||||
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
|
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
|
||||||
|
|||||||
@@ -63,17 +63,17 @@ class CouponInfolist
|
|||||||
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
|
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
|
||||||
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
|
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
Section::make(__('Paddle'))
|
Section::make(__('Lemon Squeezy'))
|
||||||
->columns(3)
|
->columns(3)
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('paddle_discount_id')
|
TextEntry::make('lemonsqueezy_discount_id')
|
||||||
->label(__('Discount ID'))
|
->label(__('Discount ID'))
|
||||||
->copyable()
|
->copyable()
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('paddle_last_synced_at')
|
TextEntry::make('lemonsqueezy_last_synced_at')
|
||||||
->label(__('Last synced'))
|
->label(__('Last synced'))
|
||||||
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
||||||
TextEntry::make('paddle_mode')
|
TextEntry::make('lemonsqueezy_mode')
|
||||||
->label(__('Mode'))
|
->label(__('Mode'))
|
||||||
->badge()
|
->badge()
|
||||||
->placeholder('standard'),
|
->placeholder('standard'),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables;
|
|||||||
|
|
||||||
use App\Enums\CouponStatus;
|
use App\Enums\CouponStatus;
|
||||||
use App\Enums\CouponType;
|
use App\Enums\CouponType;
|
||||||
use App\Jobs\SyncCouponToPaddle;
|
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -105,9 +105,9 @@ class CouponsTable
|
|||||||
static::class
|
static::class
|
||||||
)),
|
)),
|
||||||
Action::make('sync')
|
Action::make('sync')
|
||||||
->label(__('Sync to Paddle'))
|
->label(__('Sync to Lemon Squeezy'))
|
||||||
->icon('heroicon-m-arrow-path')
|
->icon('heroicon-m-arrow-path')
|
||||||
->action(fn ($record) => SyncCouponToPaddle::dispatch($record))
|
->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record))
|
||||||
->requiresConfirmation(),
|
->requiresConfirmation(),
|
||||||
])
|
])
|
||||||
->toolbarActions([
|
->toolbarActions([
|
||||||
|
|||||||
@@ -6,24 +6,29 @@ use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
|||||||
use App\Filament\Resources\EventResource\Pages;
|
use App\Filament\Resources\EventResource\Pages;
|
||||||
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
|
use App\Models\EventJoinToken;
|
||||||
use App\Models\EventJoinTokenEvent;
|
use App\Models\EventJoinTokenEvent;
|
||||||
use App\Models\EventType;
|
use App\Models\EventType;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class EventResource extends Resource
|
class EventResource extends Resource
|
||||||
@@ -93,6 +98,10 @@ class EventResource extends Resource
|
|||||||
Toggle::make('is_active')
|
Toggle::make('is_active')
|
||||||
->label(__('admin.events.fields.is_active'))
|
->label(__('admin.events.fields.is_active'))
|
||||||
->default(true),
|
->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')
|
KeyValue::make('settings')
|
||||||
->label(__('admin.events.fields.settings'))
|
->label(__('admin.events.fields.settings'))
|
||||||
->keyLabel(__('admin.common.key'))
|
->keyLabel(__('admin.common.key'))
|
||||||
@@ -164,7 +173,161 @@ class EventResource extends Resource
|
|||||||
->modalHeading(__('admin.events.modal.join_link_heading'))
|
->modalHeading(__('admin.events.modal.join_link_heading'))
|
||||||
->modalSubmitActionLabel(__('admin.common.close'))
|
->modalSubmitActionLabel(__('admin.common.close'))
|
||||||
->modalWidth('xl')
|
->modalWidth('xl')
|
||||||
->modalContent(function ($record) {
|
->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) {
|
||||||
$tokens = $record->joinTokens()
|
$tokens = $record->joinTokens()
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get();
|
->get();
|
||||||
@@ -234,6 +397,7 @@ class EventResource extends Resource
|
|||||||
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
||||||
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
||||||
'is_active' => $token->isActive(),
|
'is_active' => $token->isActive(),
|
||||||
|
'demo_read_only' => (bool) Arr::get($token->metadata ?? [], 'demo_read_only', false),
|
||||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
'created_at' => optional($token->created_at)->toIso8601String(),
|
||||||
'layouts' => $layouts,
|
'layouts' => $layouts,
|
||||||
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
||||||
@@ -253,6 +417,7 @@ class EventResource extends Resource
|
|||||||
return view('filament.events.join-link', [
|
return view('filament.events.join-link', [
|
||||||
'event' => $record,
|
'event' => $record,
|
||||||
'tokens' => $tokens,
|
'tokens' => $tokens,
|
||||||
|
'action' => $action,
|
||||||
]);
|
]);
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -303,6 +468,19 @@ class EventResource extends Resource
|
|||||||
return is_string($name) ? $name : '';
|
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
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource
|
|||||||
->label('Empfänger')
|
->label('Empfänger')
|
||||||
->toggleable()
|
->toggleable()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('paddle_transaction_id')
|
TextColumn::make('lemonsqueezy_order_id')
|
||||||
->label('Paddle Tx')
|
->label('Lemon Squeezy Order')
|
||||||
->toggleable()
|
->toggleable()
|
||||||
->copyable()
|
->copyable()
|
||||||
->wrap(),
|
->wrap(),
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ class ListGiftVouchers extends ListRecords
|
|||||||
])
|
])
|
||||||
->action(function (array $data, GiftVoucherService $service): void {
|
->action(function (array $data, GiftVoucherService $service): void {
|
||||||
$payload = [
|
$payload = [
|
||||||
'id' => null,
|
'meta' => [
|
||||||
'metadata' => [
|
'custom_data' => [
|
||||||
'type' => 'gift_voucher',
|
'type' => 'gift_voucher',
|
||||||
'purchaser_email' => $data['purchaser_email'],
|
'purchaser_email' => $data['purchaser_email'],
|
||||||
'recipient_email' => $data['recipient_email'] ?? null,
|
'recipient_email' => $data['recipient_email'] ?? null,
|
||||||
@@ -55,15 +55,18 @@ class ListGiftVouchers extends ListRecords
|
|||||||
'message' => $data['message'] ?? null,
|
'message' => $data['message'] ?? null,
|
||||||
'gift_code' => $data['code'] ?? null,
|
'gift_code' => $data['code'] ?? null,
|
||||||
],
|
],
|
||||||
'currency_code' => $data['currency'] ?? 'EUR',
|
],
|
||||||
'totals' => [
|
'data' => [
|
||||||
'grand_total' => [
|
'id' => 'manual_'.Str::uuid(),
|
||||||
'amount' => (float) $data['amount'],
|
'attributes' => [
|
||||||
|
'currency' => $data['currency'] ?? 'EUR',
|
||||||
|
'total' => (float) $data['amount'] * 100,
|
||||||
|
'user_email' => $data['purchaser_email'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$voucher = $service->issueFromPaddle($payload);
|
$voucher = $service->issueFromLemonSqueezy($payload);
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
'issued',
|
'issued',
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ namespace App\Filament\Resources;
|
|||||||
|
|
||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\PackageAddonResource\Pages;
|
use App\Filament\Resources\PackageAddonResource\Pages;
|
||||||
use App\Jobs\SyncPackageAddonToPaddle;
|
use App\Jobs\SyncPackageAddonToLemonSqueezy;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\PackageAddon;
|
use App\Models\PackageAddon;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
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\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Columns\BadgeColumn;
|
use Filament\Tables\Columns\BadgeColumn;
|
||||||
@@ -50,10 +56,18 @@ class PackageAddonResource extends Resource
|
|||||||
->required()
|
->required()
|
||||||
->unique(ignoreRecord: true)
|
->unique(ignoreRecord: true)
|
||||||
->maxLength(191),
|
->maxLength(191),
|
||||||
TextInput::make('price_id')
|
TextInput::make('variant_id')
|
||||||
->label('Paddle Preis-ID')
|
->label('Lemon Squeezy Variant-ID')
|
||||||
->helperText('Paddle Billing Preis-ID für dieses Add-on')
|
->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')))
|
||||||
->maxLength(191),
|
->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')
|
TextInput::make('sort')
|
||||||
->label('Sortierung')
|
->label('Sortierung')
|
||||||
->numeric()
|
->numeric()
|
||||||
@@ -61,6 +75,23 @@ class PackageAddonResource extends Resource
|
|||||||
Toggle::make('active')
|
Toggle::make('active')
|
||||||
->label('Aktiv')
|
->label('Aktiv')
|
||||||
->default(true),
|
->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')
|
Section::make('Limits-Inkremente')
|
||||||
->columns(3)
|
->columns(3)
|
||||||
@@ -81,6 +112,30 @@ class PackageAddonResource extends Resource
|
|||||||
->minValue(0)
|
->minValue(0)
|
||||||
->default(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(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +151,33 @@ class PackageAddonResource extends Resource
|
|||||||
->label('Schlüssel')
|
->label('Schlüssel')
|
||||||
->copyable()
|
->copyable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('price_id')
|
TextColumn::make('variant_id')
|
||||||
->label('Paddle Preis-ID')
|
->label('Lemon Squeezy Variant-ID')
|
||||||
->toggleable()
|
->toggleable()
|
||||||
->copyable(),
|
->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_photos')->label('Fotos +'),
|
||||||
TextColumn::make('extra_guests')->label('Gäste +'),
|
TextColumn::make('extra_guests')->label('Gäste +'),
|
||||||
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
|
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
|
||||||
@@ -110,6 +188,14 @@ class PackageAddonResource extends Resource
|
|||||||
'danger' => false,
|
'danger' => false,
|
||||||
])
|
])
|
||||||
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
|
->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')
|
TextColumn::make('sort')
|
||||||
->label('Sort')
|
->label('Sort')
|
||||||
->sortable()
|
->sortable()
|
||||||
@@ -120,16 +206,16 @@ class PackageAddonResource extends Resource
|
|||||||
->label('Aktiv'),
|
->label('Aktiv'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('syncPaddle')
|
Actions\Action::make('syncLemonSqueezy')
|
||||||
->label('Mit Paddle synchronisieren')
|
->label('Mit Lemon Squeezy synchronisieren')
|
||||||
->icon('heroicon-o-cloud-arrow-up')
|
->icon('heroicon-o-cloud-arrow-up')
|
||||||
->action(function (PackageAddon $record) {
|
->action(function (PackageAddon $record) {
|
||||||
SyncPackageAddonToPaddle::dispatch($record->id);
|
SyncPackageAddonToLemonSqueezy::dispatch($record->id);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
->title('Paddle-Sync gestartet')
|
->title('Lemon Squeezy-Sync gestartet')
|
||||||
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
|
->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
Actions\EditAction::make()
|
Actions\EditAction::make()
|
||||||
@@ -166,4 +252,21 @@ class PackageAddonResource extends Resource
|
|||||||
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
|
'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,12 +4,9 @@ namespace App\Filament\Resources;
|
|||||||
|
|
||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\PackageResource\Pages;
|
use App\Filament\Resources\PackageResource\Pages;
|
||||||
use App\Jobs\PullPackageFromPaddle;
|
|
||||||
use App\Jobs\SyncPackageToPaddle;
|
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -26,7 +23,6 @@ use Filament\Forms\Components\Repeater;
|
|||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
||||||
@@ -172,31 +168,31 @@ class PackageResource extends Resource
|
|||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->default([]),
|
->default([]),
|
||||||
]),
|
]),
|
||||||
Section::make('Paddle Billing')
|
Section::make('Lemon Squeezy Billing')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('paddle_product_id')
|
TextInput::make('lemonsqueezy_product_id')
|
||||||
->label('Paddle Produkt-ID')
|
->label('Lemon Squeezy Produkt-ID')
|
||||||
->maxLength(191)
|
->maxLength(191)
|
||||||
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
|
->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
|
||||||
->placeholder('nicht verknüpft'),
|
->placeholder('nicht verknüpft'),
|
||||||
TextInput::make('paddle_price_id')
|
TextInput::make('lemonsqueezy_variant_id')
|
||||||
->label('Paddle Preis-ID')
|
->label('Lemon Squeezy Variant-ID')
|
||||||
->maxLength(191)
|
->maxLength(191)
|
||||||
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
|
->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
|
||||||
->placeholder('nicht verknüpft'),
|
->placeholder('nicht verknüpft'),
|
||||||
Placeholder::make('paddle_sync_status')
|
Placeholder::make('lemonsqueezy_sync_status')
|
||||||
->label('Sync-Status')
|
->label('Sync-Status')
|
||||||
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–')
|
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '–')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Placeholder::make('paddle_synced_at')
|
Placeholder::make('lemonsqueezy_synced_at')
|
||||||
->label('Zuletzt synchronisiert')
|
->label('Zuletzt synchronisiert')
|
||||||
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '–')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Placeholder::make('paddle_sync_error')
|
Placeholder::make('lemonsqueezy_sync_error')
|
||||||
->label('Letzter Fehler')
|
->label('Letzter Fehler')
|
||||||
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '–')
|
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '–')
|
||||||
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
|
->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -263,15 +259,15 @@ class PackageResource extends Resource
|
|||||||
->label('Features')
|
->label('Features')
|
||||||
->wrap()
|
->wrap()
|
||||||
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
||||||
TextColumn::make('paddle_product_id')
|
TextColumn::make('lemonsqueezy_product_id')
|
||||||
->label('Paddle Produkt')
|
->label('Lemon Squeezy Produkt')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
TextColumn::make('paddle_price_id')
|
TextColumn::make('lemonsqueezy_variant_id')
|
||||||
->label('Paddle Preis')
|
->label('Lemon Squeezy Variant')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
BadgeColumn::make('paddle_sync_status')
|
BadgeColumn::make('lemonsqueezy_sync_status')
|
||||||
->label('Sync-Status')
|
->label('Sync-Status')
|
||||||
->colors([
|
->colors([
|
||||||
'success' => 'synced',
|
'success' => 'synced',
|
||||||
@@ -281,13 +277,13 @@ class PackageResource extends Resource
|
|||||||
])
|
])
|
||||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_synced_at')
|
TextColumn::make('lemonsqueezy_synced_at')
|
||||||
->label('Sync am')
|
->label('Sync am')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('paddle_sync_error_message')
|
TextColumn::make('lemonsqueezy_sync_error_message')
|
||||||
->label('Sync-Fehler')
|
->label('Sync-Fehler')
|
||||||
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
|
->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
|
||||||
->wrap()
|
->wrap()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
@@ -301,71 +297,6 @@ class PackageResource extends Resource
|
|||||||
TrashedFilter::make(),
|
TrashedFilter::make(),
|
||||||
])
|
])
|
||||||
->actions([
|
->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(),
|
ViewAction::make(),
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
@@ -465,6 +396,7 @@ class PackageResource extends Resource
|
|||||||
'unlimited_sharing' => 'Unbegrenztes Teilen',
|
'unlimited_sharing' => 'Unbegrenztes Teilen',
|
||||||
'no_watermark' => 'Kein Wasserzeichen',
|
'no_watermark' => 'Kein Wasserzeichen',
|
||||||
'custom_branding' => 'Eigenes Branding',
|
'custom_branding' => 'Eigenes Branding',
|
||||||
|
'ai_styling' => 'AI-Styling',
|
||||||
'custom_tasks' => 'Eigene Aufgaben',
|
'custom_tasks' => 'Eigene Aufgaben',
|
||||||
'reseller_dashboard' => 'Reseller-Dashboard',
|
'reseller_dashboard' => 'Reseller-Dashboard',
|
||||||
'advanced_analytics' => 'Erweiterte Analytics',
|
'advanced_analytics' => 'Erweiterte Analytics',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use App\Models\PackagePurchase;
|
|||||||
use App\Notifications\Customer\RefundReceipt;
|
use App\Notifications\Customer\RefundReceipt;
|
||||||
use App\Notifications\Ops\RefundProcessed;
|
use App\Notifications\Ops\RefundProcessed;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -203,15 +203,15 @@ class PurchaseResource extends Resource
|
|||||||
$refundSuccess = true;
|
$refundSuccess = true;
|
||||||
$errorMessage = null;
|
$errorMessage = null;
|
||||||
|
|
||||||
if ($record->provider === 'paddle' && $record->provider_id) {
|
if ($record->provider === 'lemonsqueezy' && $record->provider_id) {
|
||||||
try {
|
try {
|
||||||
/** @var PaddleTransactionService $paddle */
|
/** @var LemonSqueezyOrderService $lemonsqueezy */
|
||||||
$paddle = App::make(PaddleTransactionService::class);
|
$lemonsqueezy = App::make(LemonSqueezyOrderService::class);
|
||||||
$paddle->refund($record->provider_id, ['reason' => $reason]);
|
$lemonsqueezy->refund($record->provider_id, ['reason' => $reason]);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$refundSuccess = false;
|
$refundSuccess = false;
|
||||||
$errorMessage = $exception->getMessage();
|
$errorMessage = $exception->getMessage();
|
||||||
Log::warning('Paddle refund failed', [
|
Log::warning('Lemon Squeezy refund failed', [
|
||||||
'purchase_id' => $record->id,
|
'purchase_id' => $record->id,
|
||||||
'provider_id' => $record->provider_id,
|
'provider_id' => $record->provider_id,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord
|
|||||||
->visible(fn ($record): bool => ! $record->refunded)
|
->visible(fn ($record): bool => ! $record->refunded)
|
||||||
->action(function ($record) {
|
->action(function ($record) {
|
||||||
$record->update(['refunded' => true]);
|
$record->update(['refunded' => true]);
|
||||||
// TODO: Call Paddle API for actual refund
|
// TODO: Call Lemon Squeezy API for actual refund
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
'purchase.refunded',
|
'purchase.refunded',
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TenantFeedbackResource extends Resource
|
|||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
{
|
{
|
||||||
return __('admin.nav.feedback_support');
|
return __('admin.nav.infrastructure');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ class TenantResource extends Resource
|
|||||||
->email()
|
->email()
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('paddle_customer_id')
|
TextInput::make('lemonsqueezy_customer_id')
|
||||||
->label('Paddle Customer ID')
|
->label('Lemon Squeezy Customer ID')
|
||||||
->maxLength(191)
|
->maxLength(191)
|
||||||
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
|
->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
|
||||||
->nullable(),
|
->nullable(),
|
||||||
TextInput::make('total_revenue')
|
TextInput::make('total_revenue')
|
||||||
->label(__('admin.tenants.fields.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'),
|
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
||||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('contact_email'),
|
Tables\Columns\TextColumn::make('contact_email'),
|
||||||
Tables\Columns\TextColumn::make('paddle_customer_id')
|
Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
|
||||||
->label('Paddle Customer')
|
->label('Lemon Squeezy Customer')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
Select::make('provider')
|
Select::make('provider')
|
||||||
->label('Anbieter')
|
->label('Anbieter')
|
||||||
->options([
|
->options([
|
||||||
'paddle' => 'Paddle',
|
'lemonsqueezy' => 'Lemon Squeezy',
|
||||||
'manual' => 'Manuell',
|
'manual' => 'Manuell',
|
||||||
'free' => 'Kostenlos',
|
'free' => 'Kostenlos',
|
||||||
])
|
])
|
||||||
@@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
TextColumn::make('provider')
|
TextColumn::make('provider')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (string $state): string => match ($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
'paddle' => 'success',
|
'lemonsqueezy' => 'success',
|
||||||
'manual' => 'gray',
|
'manual' => 'gray',
|
||||||
'free' => 'success',
|
'free' => 'success',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
@@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
]),
|
]),
|
||||||
SelectFilter::make('provider')
|
SelectFilter::make('provider')
|
||||||
->options([
|
->options([
|
||||||
'paddle' => 'Paddle',
|
'lemonsqueezy' => 'Lemon Squeezy',
|
||||||
'manual' => 'Manuell',
|
'manual' => 'Manuell',
|
||||||
'free' => 'Kostenlos',
|
'free' => 'Kostenlos',
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager
|
|||||||
DateTimePicker::make('expires_at')
|
DateTimePicker::make('expires_at')
|
||||||
->label('Ablaufdatum')
|
->label('Ablaufdatum')
|
||||||
->required(),
|
->required(),
|
||||||
TextInput::make('paddle_subscription_id')
|
TextInput::make('lemonsqueezy_subscription_id')
|
||||||
->label('Paddle Subscription ID')
|
->label('Lemon Squeezy Subscription ID')
|
||||||
->maxLength(191)
|
->maxLength(191)
|
||||||
->helperText('Abonnement-ID aus Paddle Billing.')
|
->helperText('Abonnement-ID aus Lemon Squeezy.')
|
||||||
->nullable(),
|
->nullable(),
|
||||||
Toggle::make('active')
|
Toggle::make('active')
|
||||||
->label('Aktiv'),
|
->label('Aktiv'),
|
||||||
@@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager
|
|||||||
TextColumn::make('expires_at')
|
TextColumn::make('expires_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('paddle_subscription_id')
|
TextColumn::make('lemonsqueezy_subscription_id')
|
||||||
->label('Paddle Subscription')
|
->label('Lemon Squeezy Subscription')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
IconColumn::make('active')
|
IconColumn::make('active')
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class TenantInfolist
|
|||||||
TextEntry::make('user.full_name')
|
TextEntry::make('user.full_name')
|
||||||
->label(__('admin.tenants.fields.owner'))
|
->label(__('admin.tenants.fields.owner'))
|
||||||
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
|
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
|
||||||
TextEntry::make('paddle_customer_id')
|
TextEntry::make('lemonsqueezy_customer_id')
|
||||||
->label('Paddle Customer ID')
|
->label('Lemon Squeezy Customer ID')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('total_revenue')
|
TextEntry::make('total_revenue')
|
||||||
->label(__('admin.tenants.fields.total_revenue'))
|
->label(__('admin.tenants.fields.total_revenue'))
|
||||||
|
|||||||
177
app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php
Normal file
177
app/Filament/SuperAdmin/Pages/AiEditingSettingsPage.php
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Filament/SuperAdmin/Pages/InternalDocsPage.php
Normal file
42
app/Filament/SuperAdmin/Pages/InternalDocsPage.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?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.*',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
556
app/Http/Controllers/Api/EventPublicAiEditController.php
Normal file
556
app/Http/Controllers/Api/EventPublicAiEditController.php
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
<?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,6 +16,9 @@ use App\Models\GuestNotification;
|
|||||||
use App\Models\GuestPolicySetting;
|
use App\Models\GuestPolicySetting;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\PhotoShareLink;
|
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\Analytics\JoinTokenAnalyticsRecorder;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Services\EventTasksCacheService;
|
use App\Services\EventTasksCacheService;
|
||||||
@@ -41,6 +44,7 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class EventPublicController extends BaseController
|
class EventPublicController extends BaseController
|
||||||
@@ -49,6 +53,10 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
|
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
|
private const PREVIEW_MAX_EDGE = 1920;
|
||||||
|
|
||||||
|
private const PREVIEW_QUALITY = 86;
|
||||||
|
|
||||||
private ?GuestPolicySetting $guestPolicy = null;
|
private ?GuestPolicySetting $guestPolicy = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -60,6 +68,9 @@ class EventPublicController extends BaseController
|
|||||||
private readonly EventTasksCacheService $eventTasksCache,
|
private readonly EventTasksCacheService $eventTasksCache,
|
||||||
private readonly GuestNotificationService $guestNotificationService,
|
private readonly GuestNotificationService $guestNotificationService,
|
||||||
private readonly PushSubscriptionService $pushSubscriptions,
|
private readonly PushSubscriptionService $pushSubscriptions,
|
||||||
|
private readonly AiEditingRuntimeConfig $aiEditingRuntimeConfig,
|
||||||
|
private readonly AiStylingEntitlementService $aiStylingEntitlements,
|
||||||
|
private readonly EventAiEditingPolicyService $eventAiEditingPolicy,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1054,6 +1065,7 @@ class EventPublicController extends BaseController
|
|||||||
* heading_font: ?string,
|
* heading_font: ?string,
|
||||||
* body_font: ?string,
|
* body_font: ?string,
|
||||||
* font_size: string,
|
* font_size: string,
|
||||||
|
* welcome_message: ?string,
|
||||||
* logo_url: ?string,
|
* logo_url: ?string,
|
||||||
* logo_mode: string,
|
* logo_mode: string,
|
||||||
* logo_value: ?string,
|
* logo_value: ?string,
|
||||||
@@ -1093,12 +1105,8 @@ class EventPublicController extends BaseController
|
|||||||
$brandingAllowed = $this->determineBrandingAllowed($event);
|
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||||
|
|
||||||
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
$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));
|
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
||||||
$sources = $brandingAllowed
|
$sources = $brandingAllowed ? [$eventBranding] : [[]];
|
||||||
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
|
|
||||||
: [[]];
|
|
||||||
|
|
||||||
$primary = $this->normalizeHexColor(
|
$primary = $this->normalizeHexColor(
|
||||||
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
||||||
@@ -1121,6 +1129,7 @@ class EventPublicController extends BaseController
|
|||||||
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
|
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
|
||||||
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
|
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
|
||||||
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $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']);
|
$logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
|
||||||
if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
|
if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
|
||||||
@@ -1182,6 +1191,7 @@ class EventPublicController extends BaseController
|
|||||||
'heading_font' => $headingFont,
|
'heading_font' => $headingFont,
|
||||||
'body_font' => $bodyFont,
|
'body_font' => $bodyFont,
|
||||||
'font_size' => $fontSize,
|
'font_size' => $fontSize,
|
||||||
|
'welcome_message' => $welcomeMessage,
|
||||||
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
|
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
|
||||||
'logo_mode' => $logoMode,
|
'logo_mode' => $logoMode,
|
||||||
'logo_value' => $logoValue,
|
'logo_value' => $logoValue,
|
||||||
@@ -1445,17 +1455,34 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
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(
|
return URL::temporarySignedRoute(
|
||||||
'api.v1.gallery.photos.download',
|
'api.v1.gallery.photos.download',
|
||||||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||||
[
|
[
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'photo' => $photo->id,
|
'photo' => $photoId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
|
||||||
{
|
{
|
||||||
return URL::temporarySignedRoute(
|
return URL::temporarySignedRoute(
|
||||||
@@ -1464,8 +1491,7 @@ class EventPublicController extends BaseController
|
|||||||
[
|
[
|
||||||
'slug' => $shareLink->slug,
|
'slug' => $shareLink->slug,
|
||||||
'variant' => $variant,
|
'variant' => $variant,
|
||||||
],
|
]
|
||||||
absolute: false
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1738,6 +1764,7 @@ class EventPublicController extends BaseController
|
|||||||
'name' => $event->name,
|
'name' => $event->name,
|
||||||
'city' => $event->city,
|
'city' => $event->city,
|
||||||
] : null,
|
] : null,
|
||||||
|
'branding' => $event ? $this->resolveBrandingPayload($event) : null,
|
||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1901,7 +1928,12 @@ class EventPublicController extends BaseController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
|
return $this->streamGalleryPhoto(
|
||||||
|
$event,
|
||||||
|
$record,
|
||||||
|
$this->galleryDownloadVariantPreference($event),
|
||||||
|
'attachment'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function event(Request $request, string $token)
|
public function event(Request $request, string $token)
|
||||||
@@ -1953,6 +1985,11 @@ class EventPublicController extends BaseController
|
|||||||
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
||||||
$liveShowSettings = Arr::get($settings, 'live_show', []);
|
$liveShowSettings = Arr::get($settings, 'live_show', []);
|
||||||
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
|
$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');
|
$event->loadMissing('photoboothSetting');
|
||||||
$policy = $this->guestPolicy();
|
$policy = $this->guestPolicy();
|
||||||
|
|
||||||
@@ -1980,10 +2017,58 @@ class EventPublicController extends BaseController
|
|||||||
'live_show' => [
|
'live_show' => [
|
||||||
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
|
'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,
|
'engagement_mode' => $engagementMode,
|
||||||
])->header('Cache-Control', 'no-store');
|
])->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)
|
public function package(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
@@ -2160,6 +2245,15 @@ class EventPublicController extends BaseController
|
|||||||
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||||||
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
||||||
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
|
$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 {
|
} else {
|
||||||
$watermarked = $preferOriginals
|
$watermarked = $preferOriginals
|
||||||
? null
|
? null
|
||||||
@@ -2600,6 +2694,15 @@ class EventPublicController extends BaseController
|
|||||||
->distinct('guest_name')
|
->distinct('guest_name')
|
||||||
->count('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).
|
// Tasks solved as number of photos linked to a task (proxy metric).
|
||||||
$tasksSolved = $engagementMode === 'photo_only'
|
$tasksSolved = $engagementMode === 'photo_only'
|
||||||
? 0
|
? 0
|
||||||
@@ -2610,6 +2713,8 @@ class EventPublicController extends BaseController
|
|||||||
$payload = [
|
$payload = [
|
||||||
'online_guests' => $onlineGuests,
|
'online_guests' => $onlineGuests,
|
||||||
'tasks_solved' => $tasksSolved,
|
'tasks_solved' => $tasksSolved,
|
||||||
|
'guest_count' => $guestCount,
|
||||||
|
'likes_count' => $likesCount,
|
||||||
'latest_photo_at' => $latestPhotoAt,
|
'latest_photo_at' => $latestPhotoAt,
|
||||||
'engagement_mode' => $engagementMode,
|
'engagement_mode' => $engagementMode,
|
||||||
];
|
];
|
||||||
@@ -2795,12 +2900,14 @@ class EventPublicController extends BaseController
|
|||||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||||||
|
|
||||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
$deviceId = $this->normalizeGuestIdentifier((string) $request->header('X-Device-Id', ''));
|
||||||
|
$deviceId = $deviceId !== '' ? $deviceId : 'anon';
|
||||||
$filter = $request->query('filter');
|
$filter = $request->query('filter');
|
||||||
|
|
||||||
$since = $request->query('since');
|
$since = $request->query('since');
|
||||||
$query = DB::table('photos')
|
$query = DB::table('photos')
|
||||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||||
|
->leftJoin('emotions', 'photos.emotion_id', '=', 'emotions.id')
|
||||||
->select([
|
->select([
|
||||||
'photos.id',
|
'photos.id',
|
||||||
'photos.file_path',
|
'photos.file_path',
|
||||||
@@ -2809,9 +2916,14 @@ class EventPublicController extends BaseController
|
|||||||
'photos.emotion_id',
|
'photos.emotion_id',
|
||||||
'photos.task_id',
|
'photos.task_id',
|
||||||
'photos.guest_name',
|
'photos.guest_name',
|
||||||
|
'photos.created_by_device_id',
|
||||||
'photos.created_at',
|
'photos.created_at',
|
||||||
'photos.ingest_source',
|
'photos.ingest_source',
|
||||||
'tasks.title as task_title',
|
'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.event_id', $eventId)
|
||||||
->where('photos.status', 'approved')
|
->where('photos.status', 'approved')
|
||||||
@@ -2822,24 +2934,50 @@ class EventPublicController extends BaseController
|
|||||||
if ($filter === 'photobooth') {
|
if ($filter === 'photobooth') {
|
||||||
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
||||||
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
||||||
$query->where('guest_name', $deviceId);
|
$query->where(function ($inner) use ($deviceId) {
|
||||||
|
$inner->where('created_by_device_id', $deviceId)
|
||||||
|
->orWhere('guest_name', $deviceId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($since) {
|
if ($since) {
|
||||||
$query->where('photos.created_at', '>', $since);
|
$query->where('photos.created_at', '>', $since);
|
||||||
}
|
}
|
||||||
$rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
|
$rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) {
|
||||||
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
$fullUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
||||||
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
||||||
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
$thumbnailUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
||||||
?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));
|
?? $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
|
// Localize task title if present
|
||||||
if ($r->task_title) {
|
if ($r->task_title) {
|
||||||
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
|
$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;
|
$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;
|
return $r;
|
||||||
});
|
});
|
||||||
@@ -2932,6 +3070,159 @@ class EventPublicController extends BaseController
|
|||||||
return response()->json(['liked' => true, 'likes_count' => $count]);
|
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)
|
public function upload(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
@@ -3095,10 +3386,19 @@ class EventPublicController extends BaseController
|
|||||||
$thumbUrl = $thumbPath
|
$thumbUrl = $thumbPath
|
||||||
? $this->resolveDiskUrl($disk, $thumbPath)
|
? $this->resolveDiskUrl($disk, $thumbPath)
|
||||||
: $this->resolveDiskUrl($disk, $path);
|
: $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).
|
// Create watermarked copies (non-destructive).
|
||||||
$watermarkedPath = $path;
|
$watermarkedPath = $path;
|
||||||
$watermarkedThumb = $thumbPath ?: $path;
|
$watermarkedThumb = $thumbPath ?: $path;
|
||||||
|
$watermarkedPreview = $previewPath ?: $path;
|
||||||
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
|
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;
|
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
|
||||||
if ($thumbPath) {
|
if ($thumbPath) {
|
||||||
@@ -3111,6 +3411,17 @@ class EventPublicController extends BaseController
|
|||||||
} else {
|
} else {
|
||||||
$watermarkedThumb = $watermarkedPath;
|
$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);
|
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
|
||||||
@@ -3224,6 +3535,23 @@ 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) {
|
if ($watermarkedThumb !== $thumbPath) {
|
||||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
|
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
|
||||||
'variant' => 'watermarked_thumbnail',
|
'variant' => 'watermarked_thumbnail',
|
||||||
@@ -3240,6 +3568,22 @@ 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')
|
DB::table('photos')
|
||||||
->where('id', $photoId)
|
->where('id', $photoId)
|
||||||
->update(['media_asset_id' => $asset->id]);
|
->update(['media_asset_id' => $asset->id]);
|
||||||
|
|||||||
@@ -212,6 +212,10 @@ class LiveShowController extends BaseController
|
|||||||
|
|
||||||
return Event::query()
|
return Event::query()
|
||||||
->where('live_show_token', $token)
|
->where('live_show_token', $token)
|
||||||
|
->where(function (Builder $query) {
|
||||||
|
$query->whereNull('live_show_token_expires_at')
|
||||||
|
->orWhere('live_show_token_expires_at', '>=', now());
|
||||||
|
})
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ class CouponPreviewController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail($data['package_id']);
|
$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;
|
$tenant = Auth::user()?->tenant;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
|
|||||||
|
|
||||||
if (! $checkout['checkout_url']) {
|
if (! $checkout['checkout_url']) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'tier_key' => __('Unable to create Paddle checkout.'),
|
'tier_key' => __('Unable to create checkout.'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,24 +46,43 @@ class GiftVoucherCheckoutController extends Controller
|
|||||||
public function show(Request $request): JsonResponse
|
public function show(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'],
|
'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
|
||||||
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
|
'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
|
||||||
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'],
|
'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$voucherQuery = GiftVoucher::query();
|
$voucherQuery = GiftVoucher::query()
|
||||||
|
->where('status', '!=', GiftVoucher::STATUS_PENDING)
|
||||||
|
->where(function ($query) use ($data) {
|
||||||
|
$hasCondition = false;
|
||||||
|
|
||||||
if (! empty($data['checkout_id'])) {
|
if (! empty($data['checkout_id'])) {
|
||||||
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']);
|
$query->where(function ($inner) use ($data) {
|
||||||
|
$inner->where('lemonsqueezy_checkout_id', $data['checkout_id'])
|
||||||
|
->orWhere('paypal_order_id', $data['checkout_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$hasCondition = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($data['transaction_id'])) {
|
if (! empty($data['order_id'])) {
|
||||||
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_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'])) {
|
if (! empty($data['code'])) {
|
||||||
$voucherQuery->orWhere('code', strtoupper($data['code']));
|
$method = $hasCondition ? 'orWhere' : 'where';
|
||||||
|
|
||||||
|
$query->{$method}('code', strtoupper($data['code']));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$voucher = $voucherQuery->latest()->firstOrFail();
|
$voucher = $voucherQuery->latest()->firstOrFail();
|
||||||
|
|
||||||
|
|||||||
@@ -9,37 +9,40 @@ use App\Models\Package;
|
|||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\PayPal\Exceptions\PayPalException;
|
||||||
|
use App\Services\PayPal\PayPalOrderService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PackageController extends Controller
|
class PackageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PaddleCheckoutService $paddleCheckout,
|
private readonly PayPalOrderService $paypalOrders,
|
||||||
private readonly CheckoutSessionService $sessions,
|
private readonly CheckoutSessionService $sessions,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$type = $request->query('type', 'endcustomer');
|
$type = $request->query('type', 'endcustomer');
|
||||||
|
$provider = strtolower((string) config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL));
|
||||||
$packages = Package::where('type', $type)
|
$packages = Package::where('type', $type)
|
||||||
->orderBy('price')
|
->orderBy('price')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$packages->each(function ($package) {
|
$packages->each(function ($package) use ($provider) {
|
||||||
if (is_string($package->features)) {
|
if (is_string($package->features)) {
|
||||||
$decoded = json_decode($package->features, true);
|
$decoded = json_decode($package->features, true);
|
||||||
$package->features = is_array($decoded) ? $decoded : [];
|
$package->features = is_array($decoded) ? $decoded : [];
|
||||||
|
|
||||||
return;
|
} elseif (! is_array($package->features)) {
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($package->features)) {
|
|
||||||
$package->features = [];
|
$package->features = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$package->setAttribute('checkout_provider', $provider);
|
||||||
|
$package->setAttribute('can_checkout', $this->canCheckoutPackage($package, $provider));
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -53,7 +56,7 @@ class PackageController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'type' => 'required|in:endcustomer,reseller',
|
'type' => 'required|in:endcustomer,reseller',
|
||||||
'payment_method' => 'required|in:paddle',
|
'payment_method' => 'required|in:paypal',
|
||||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||||
'success_url' => 'nullable|url',
|
'success_url' => 'nullable|url',
|
||||||
'return_url' => 'nullable|url',
|
'return_url' => 'nullable|url',
|
||||||
@@ -79,7 +82,7 @@ class PackageController extends Controller
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'paddle_transaction_id' => 'required|string',
|
'paypal_order_id' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$package = Package::findOrFail($request->package_id);
|
$package = Package::findOrFail($request->package_id);
|
||||||
@@ -89,14 +92,14 @@ class PackageController extends Controller
|
|||||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$provider = 'paddle';
|
$provider = 'paypal';
|
||||||
|
|
||||||
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
||||||
PackagePurchase::create([
|
PackagePurchase::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
'provider_id' => $request->input('paddle_transaction_id'),
|
'provider_id' => $request->input('paypal_order_id'),
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'type' => 'endcustomer_event',
|
'type' => 'endcustomer_event',
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
@@ -161,12 +164,14 @@ class PackageController extends Controller
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createPaddleCheckout(Request $request): JsonResponse
|
public function createPayPalCheckout(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'success_url' => 'nullable|url',
|
'success_url' => 'nullable|url',
|
||||||
'return_url' => 'nullable|url',
|
'return_url' => 'nullable|url',
|
||||||
|
'cancel_url' => 'nullable|url',
|
||||||
|
'locale' => 'nullable|string|max:10',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$package = Package::findOrFail($request->integer('package_id'));
|
$package = Package::findOrFail($request->integer('package_id'));
|
||||||
@@ -181,15 +186,11 @@ class PackageController extends Controller
|
|||||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
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, [
|
$session = $this->sessions->createOrResume($user, $package, [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
@@ -201,30 +202,56 @@ class PackageController extends Controller
|
|||||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$payload = [
|
$successUrl = $request->input('success_url') ?? $request->input('return_url');
|
||||||
'success_url' => $request->input('success_url'),
|
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
|
||||||
'return_url' => $request->input('return_url'),
|
$paypalReturnUrl = route('paypal.return', absolute: true);
|
||||||
'metadata' => [
|
|
||||||
'checkout_session_id' => $session->id,
|
|
||||||
'legal_version' => $session->legal_version,
|
|
||||||
'accepted_terms' => true,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
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);
|
||||||
|
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
'paypal_order_id' => $orderId,
|
||||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
'paypal_order_id' => $orderId,
|
||||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
'paypal_status' => $order['status'] ?? null,
|
||||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
'paypal_approve_url' => $approveUrl,
|
||||||
|
'paypal_success_url' => $successUrl,
|
||||||
|
'paypal_cancel_url' => $cancelUrl,
|
||||||
|
'paypal_created_at' => now()->toIso8601String(),
|
||||||
])),
|
])),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
return response()->json(array_merge($checkout, [
|
$this->sessions->markRequiresCustomerAction($session, 'paypal_approval');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'approve_url' => $approveUrl,
|
||||||
|
'status' => $order['status'] ?? null,
|
||||||
'checkout_session_id' => $session->id,
|
'checkout_session_id' => $session->id,
|
||||||
]));
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||||
@@ -239,7 +266,9 @@ class PackageController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
$checkoutUrl = $session->provider === CheckoutSession::PROVIDER_PAYPAL
|
||||||
|
? data_get($session->provider_metadata ?? [], 'paypal_approve_url')
|
||||||
|
: data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => $session->status,
|
'status' => $session->status,
|
||||||
@@ -297,19 +326,57 @@ class PackageController extends Controller
|
|||||||
|
|
||||||
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
if (! $package->paddle_price_id) {
|
$successUrl = $request->input('success_url') ?? $request->input('return_url');
|
||||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
|
||||||
}
|
$paypalReturnUrl = route('paypal.return', absolute: true);
|
||||||
|
|
||||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
try {
|
||||||
'success_url' => $request->input('success_url'),
|
$session = $this->sessions->createOrResume($request->user(), $package, [
|
||||||
'return_url' => $request->input('return_url'),
|
'tenant' => $tenant,
|
||||||
'metadata' => array_filter([
|
]);
|
||||||
'type' => $request->input('type'),
|
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
||||||
'event_id' => $request->input('event_id'),
|
|
||||||
]),
|
$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(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($checkout);
|
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.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'approve_url' => $this->paypalOrders->resolveApproveUrl($order),
|
||||||
|
'status' => $order['status'] ?? null,
|
||||||
|
'return_url' => $successUrl,
|
||||||
|
'cancel_url' => $cancelUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
625
app/Http/Controllers/Api/Tenant/AiEditController.php
Normal file
625
app/Http/Controllers/Api/Tenant/AiEditController.php
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
<?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,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api\Tenant;
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
use App\Services\Addons\EventAddonCatalog;
|
use App\Services\Addons\EventAddonCatalog;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
@@ -12,9 +13,25 @@ class EventAddonCatalogController extends Controller
|
|||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
|
$provider = config('package-addons.provider')
|
||||||
|
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL);
|
||||||
|
|
||||||
$addons = collect($this->catalog->all())
|
$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']))
|
->filter(fn (array $addon) => ! empty($addon['price_id']))
|
||||||
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
|
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ namespace App\Http\Controllers\Api\Tenant;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
|
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
|
||||||
use App\Http\Requests\Tenant\EventAddonRequest;
|
use App\Http\Requests\Tenant\EventAddonPurchaseLookupRequest;
|
||||||
use App\Http\Resources\Tenant\EventResource;
|
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
|
use App\Models\EventPackageAddon;
|
||||||
use App\Services\Addons\EventAddonCheckoutService;
|
use App\Services\Addons\EventAddonCheckoutService;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class EventAddonController extends Controller
|
class EventAddonController extends Controller
|
||||||
{
|
{
|
||||||
@@ -52,7 +52,7 @@ class EventAddonController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function apply(EventAddonRequest $request, Event $event): JsonResponse
|
public function purchase(EventAddonPurchaseLookupRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
@@ -66,49 +66,85 @@ class EventAddonController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$eventPackage = $event->eventPackage;
|
$validated = $request->validated();
|
||||||
|
$addonIntent = trim((string) ($validated['addon_intent'] ?? ''));
|
||||||
|
$checkoutId = trim((string) ($validated['checkout_id'] ?? ''));
|
||||||
|
$addonKey = trim((string) ($validated['addon_key'] ?? ''));
|
||||||
|
|
||||||
if (! $eventPackage && method_exists($event, 'eventPackages')) {
|
$baseQuery = EventPackageAddon::query()
|
||||||
$eventPackage = $event->eventPackages()
|
->where('tenant_id', $tenantId)
|
||||||
->with('package')
|
->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")
|
||||||
->orderByDesc('purchased_at')
|
->orderByDesc('purchased_at')
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $eventPackage) {
|
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) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
'event_package_missing',
|
'addon_not_found',
|
||||||
'Event package missing',
|
'Add-on purchase not found',
|
||||||
__('Kein Paket ist diesem Event zugeordnet.'),
|
__('Der Add-on Kauf wurde nicht gefunden.'),
|
||||||
409,
|
404,
|
||||||
['event_slug' => $event->slug ?? null]
|
['event_slug' => $event->slug ?? null]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $request->validated();
|
$label = Arr::get($addon->metadata ?? [], 'label') ?? $addon->addon_key;
|
||||||
|
|
||||||
$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([
|
return response()->json([
|
||||||
'message' => __('Add-ons applied successfully.'),
|
'data' => [
|
||||||
'data' => new EventResource($event),
|
'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,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,14 @@ class EventController extends Controller
|
|||||||
$tenantPackage = $tenant->tenantPackages()
|
$tenantPackage = $tenant->tenantPackages()
|
||||||
->with('package')
|
->with('package')
|
||||||
->where('active', true)
|
->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('purchased_at')
|
||||||
|
->orderByDesc('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$package = null;
|
$package = null;
|
||||||
@@ -149,6 +156,7 @@ class EventController extends Controller
|
|||||||
$eventServicePackage = $billingIsReseller
|
$eventServicePackage = $billingIsReseller
|
||||||
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
||||||
: $package;
|
: $package;
|
||||||
|
$sourceTenantPackage = $billingIsReseller ? $billingTenantPackage : $tenantPackage;
|
||||||
|
|
||||||
$requiresWaiver = $package->isEndcustomer();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
@@ -216,12 +224,13 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$eventData = Arr::only($eventData, $allowed);
|
||||||
|
|
||||||
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
|
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin, $sourceTenantPackage) {
|
||||||
$event = Event::create($eventData);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $eventServicePackage->id,
|
'package_id' => $eventServicePackage->id,
|
||||||
|
'tenant_package_id' => $sourceTenantPackage?->id,
|
||||||
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'gallery_expires_at' => $eventServicePackage->gallery_days
|
'gallery_expires_at' => $eventServicePackage->gallery_days
|
||||||
@@ -247,11 +256,13 @@ class EventController extends Controller
|
|||||||
$tenant->refresh();
|
$tenant->refresh();
|
||||||
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
||||||
|
|
||||||
|
$activeResellerPackage = $tenant->getActiveResellerPackage();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Event created successfully',
|
'message' => 'Event created successfully',
|
||||||
'data' => new EventResource($event),
|
'data' => new EventResource($event),
|
||||||
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
|
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
|
||||||
'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0,
|
'remaining_events' => $activeResellerPackage?->remaining_events ?? 0,
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,9 +893,16 @@ 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([
|
$validated = $request->validate([
|
||||||
'label' => ['nullable', 'string', 'max:255'],
|
'label' => ['nullable', 'string', 'max:255'],
|
||||||
'expires_at' => ['nullable', 'date', 'after:now'],
|
'expires_at' => $expiresAtRules,
|
||||||
'usage_limit' => ['nullable', 'integer', 'min:1'],
|
'usage_limit' => ['nullable', 'integer', 'min:1'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class EventJoinTokenController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$validated = $this->validatePayload($request);
|
$validated = $this->validatePayload($request, $event);
|
||||||
|
|
||||||
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
|
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
|
||||||
'created_by' => Auth::id(),
|
'created_by' => Auth::id(),
|
||||||
@@ -52,7 +52,7 @@ class EventJoinTokenController extends Controller
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $this->validatePayload($request, true);
|
$validated = $this->validatePayload($request, $event, true);
|
||||||
|
|
||||||
$payload = [];
|
$payload = [];
|
||||||
|
|
||||||
@@ -115,11 +115,18 @@ class EventJoinTokenController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePayload(Request $request, bool $partial = false): array
|
private function validatePayload(Request $request, Event $event, 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 = [
|
$rules = [
|
||||||
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
|
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
|
||||||
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
|
'expires_at' => $expiresAtRules,
|
||||||
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
|
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
|
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
|
||||||
'metadata.layout_customization' => ['nullable', 'array'],
|
'metadata.layout_customization' => ['nullable', 'array'],
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class LiveShowLinkController extends Controller
|
|||||||
'url' => $url,
|
'url' => $url,
|
||||||
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
|
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
|
||||||
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
|
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
|
||||||
|
'expires_at' => $event->live_show_token_expires_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Support\WatermarkConfigResolver;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -115,6 +116,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -321,7 +323,7 @@ class PhotoController extends Controller
|
|||||||
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
$extension = $file->getClientOriginalExtension();
|
$extension = $this->resolvePhotoExtension($file);
|
||||||
$filename = Str::uuid().'.'.$extension;
|
$filename = Str::uuid().'.'.$extension;
|
||||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||||
|
|
||||||
@@ -563,6 +565,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -779,6 +782,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
$photos = Photo::where('event_id', $event->id)
|
$photos = Photo::where('event_id', $event->id)
|
||||||
->where('status', 'pending')
|
->where('status', 'pending')
|
||||||
@@ -1043,4 +1047,23 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
return array_values(array_unique(array_filter($candidates)));
|
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,8 +119,6 @@ class TenantAdminTokenController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant->loadMissing('activeResellerPackage');
|
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$abilities = $user?->currentAccessToken()?->abilities ?? [];
|
$abilities = $user?->currentAccessToken()?->abilities ?? [];
|
||||||
|
|
||||||
@@ -131,7 +129,7 @@ class TenantAdminTokenController extends Controller
|
|||||||
$fullName = trim($first.' '.$last) ?: null;
|
$fullName = trim($first.' '.$last) ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$activePackage = $tenant->activeResellerPackage;
|
$activePackage = $tenant->getActiveResellerPackage();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'id' => $tenant->id,
|
'id' => $tenant->id,
|
||||||
|
|||||||
@@ -3,22 +3,27 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Tenant\BillingAddonHistoryRequest;
|
||||||
|
use App\Models\Event;
|
||||||
use App\Models\EventPackageAddon;
|
use App\Models\EventPackageAddon;
|
||||||
use App\Services\Paddle\Exceptions\PaddleException;
|
use App\Models\PackagePurchase;
|
||||||
use App\Services\Paddle\PaddleCustomerPortalService;
|
use App\Services\Addons\EventAddonCatalog;
|
||||||
use App\Services\Paddle\PaddleCustomerService;
|
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
||||||
|
use Dompdf\Dompdf;
|
||||||
|
use Dompdf\Options;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class TenantBillingController extends Controller
|
class TenantBillingController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PaddleTransactionService $paddleTransactions,
|
private readonly LemonSqueezySubscriptionService $subscriptions,
|
||||||
private readonly PaddleCustomerService $paddleCustomers,
|
private readonly EventAddonCatalog $addonCatalog,
|
||||||
private readonly PaddleCustomerPortalService $portalSessions,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function transactions(Request $request): JsonResponse
|
public function transactions(Request $request): JsonResponse
|
||||||
@@ -32,54 +37,49 @@ class TenantBillingController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant->paddle_customer_id) {
|
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
||||||
try {
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$this->paddleCustomers->ensureCustomerId($tenant);
|
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
Log::warning('Failed to resolve Paddle customer for tenant', [
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
$paginator = PackagePurchase::query()
|
||||||
'data' => [],
|
->where('tenant_id', $tenant->id)
|
||||||
'message' => 'Failed to resolve Paddle customer.',
|
->with(['package'])
|
||||||
], 502);
|
->orderByDesc('purchased_at')
|
||||||
}
|
->orderByDesc('id')
|
||||||
}
|
->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
|
||||||
$cursor = $request->query('cursor');
|
$data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) {
|
||||||
$perPage = (int) $request->query('per_page', 25);
|
$totals = $this->resolvePurchaseTotals($purchase);
|
||||||
|
$transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey();
|
||||||
|
|
||||||
$query = [
|
return [
|
||||||
'per_page' => max(1, min($perPage, 100)),
|
'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();
|
||||||
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([
|
return response()->json([
|
||||||
'data' => [],
|
'data' => $data,
|
||||||
'message' => 'Failed to load Paddle transactions.',
|
'meta' => [
|
||||||
], 502);
|
'current_page' => $paginator->currentPage(),
|
||||||
}
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
return response()->json([
|
'total' => $paginator->total(),
|
||||||
'data' => $result['data'],
|
],
|
||||||
'meta' => $result['meta'],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addons(Request $request): JsonResponse
|
public function addons(BillingAddonHistoryRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
|
|
||||||
@@ -90,21 +90,63 @@ class TenantBillingController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
$perPage = max(1, min((int) $request->validated('per_page', 25), 100));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->validated('page', 1));
|
||||||
|
$eventId = $request->validated('event_id');
|
||||||
|
$eventSlug = $request->validated('event_slug');
|
||||||
|
$status = $request->validated('status');
|
||||||
|
|
||||||
$paginator = EventPackageAddon::query()
|
$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()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->with(['event:id,name,slug'])
|
->with(['event:id,name,slug']);
|
||||||
|
|
||||||
|
if ($scopeEvent) {
|
||||||
|
$query->where('event_id', $scopeEvent->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($status) && $status !== '') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $query
|
||||||
->orderByDesc('purchased_at')
|
->orderByDesc('purchased_at')
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->paginate($perPage, ['*'], 'page', $page);
|
->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
|
||||||
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
|
$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;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $addon->id,
|
'id' => $addon->id,
|
||||||
'addon_key' => $addon->addon_key,
|
'addon_key' => $addon->addon_key,
|
||||||
'label' => $addon->metadata['label'] ?? null,
|
'label' => $label,
|
||||||
'quantity' => (int) ($addon->quantity ?? 1),
|
'quantity' => (int) ($addon->quantity ?? 1),
|
||||||
'status' => $addon->status,
|
'status' => $addon->status,
|
||||||
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
||||||
@@ -129,6 +171,17 @@ class TenantBillingController extends Controller
|
|||||||
'last_page' => $paginator->lastPage(),
|
'last_page' => $paginator->lastPage(),
|
||||||
'per_page' => $paginator->perPage(),
|
'per_page' => $paginator->perPage(),
|
||||||
'total' => $paginator->total(),
|
'total' => $paginator->total(),
|
||||||
|
'scope' => $scopeEvent ? [
|
||||||
|
'type' => 'event',
|
||||||
|
'event' => [
|
||||||
|
'id' => $scopeEvent->id,
|
||||||
|
'slug' => $scopeEvent->slug,
|
||||||
|
'name' => $scopeEvent->name,
|
||||||
|
],
|
||||||
|
] : [
|
||||||
|
'type' => 'tenant',
|
||||||
|
'event' => null,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -143,68 +196,64 @@ class TenantBillingController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$customerId = null;
|
$subscriptionId = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
|
$subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
|
||||||
|
if (! $subscriptionId) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'No active subscription found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
Log::debug('Creating Paddle customer portal session', [
|
Log::debug('Fetching Lemon Squeezy subscription portal URL', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'paddle_customer_id' => $customerId,
|
'lemonsqueezy_subscription_id' => $subscriptionId,
|
||||||
'paddle_environment' => config('paddle.environment'),
|
|
||||||
'paddle_base_url' => config('paddle.base_url'),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$session = $this->portalSessions->createSession($customerId);
|
$subscription = $this->subscriptions->retrieve($subscriptionId);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$context = [
|
$context = [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
||||||
|
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
'paddle_environment' => config('paddle.environment'),
|
|
||||||
'paddle_base_url' => config('paddle.base_url'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($exception instanceof PaddleException) {
|
if ($exception instanceof LemonSqueezyException) {
|
||||||
$context['paddle_status'] = $exception->status();
|
$context['lemonsqueezy_status'] = $exception->status();
|
||||||
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code');
|
$context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0');
|
||||||
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message');
|
$context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors');
|
||||||
$context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail');
|
$context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id');
|
||||||
$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 create Paddle customer portal session', [
|
Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
|
||||||
...$context,
|
...$context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Failed to create Paddle customer portal session.',
|
'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
|
||||||
], 502);
|
], 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = Arr::get($session, 'data.urls.general.overview')
|
$url = $this->subscriptions->portalUrl($subscription)
|
||||||
?? Arr::get($session, 'data.urls.general')
|
?? $this->subscriptions->updatePaymentMethodUrl($subscription);
|
||||||
?? Arr::get($session, 'urls.general.overview')
|
|
||||||
?? Arr::get($session, 'urls.general');
|
|
||||||
|
|
||||||
if (! $url) {
|
if (! $url) {
|
||||||
$sessionData = Arr::get($session, 'data');
|
$sessionData = Arr::get($subscription, 'data');
|
||||||
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls');
|
$sessionUrls = Arr::get($subscription, 'attributes.urls');
|
||||||
|
|
||||||
Log::warning('Paddle customer portal session missing URL', [
|
Log::warning('Lemon Squeezy subscription missing portal URL', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
||||||
'paddle_environment' => config('paddle.environment'),
|
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
||||||
'paddle_base_url' => config('paddle.base_url'),
|
'subscription_keys' => array_keys($subscription),
|
||||||
'session_keys' => array_keys($session),
|
|
||||||
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
|
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
|
||||||
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
|
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Paddle customer portal session missing URL.',
|
'message' => 'Lemon Squeezy subscription missing portal URL.',
|
||||||
], 502);
|
], 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,4 +261,184 @@ class TenantBillingController extends Controller
|
|||||||
'url' => $url,
|
'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,18 +31,21 @@ class TenantPackageController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
|
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
|
||||||
|
$linkedEventPackages = $this->resolveLinkedEventPackages($tenant->id, $packages->pluck('id')->all());
|
||||||
|
|
||||||
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
|
$packages->each(function (TenantPackage $package) use ($usageEventPackage, $linkedEventPackages): void {
|
||||||
$eventPackage = $package->active ? $usageEventPackage : null;
|
$eventPackage = $package->active ? $usageEventPackage : null;
|
||||||
$this->hydratePackageSnapshot($package, $eventPackage);
|
$this->hydratePackageSnapshot($package, $eventPackage);
|
||||||
|
$this->attachUsageEvents($package, $linkedEventPackages);
|
||||||
});
|
});
|
||||||
|
|
||||||
$activePackage = $tenant->activeResellerPackage?->load('package');
|
$activePackage = $tenant->getActiveResellerPackage();
|
||||||
|
|
||||||
if (! ($activePackage instanceof TenantPackage)) {
|
if (! ($activePackage instanceof TenantPackage)) {
|
||||||
$activePackage = $packages->firstWhere('active', true);
|
$activePackage = $packages->firstWhere('active', true);
|
||||||
} else {
|
} else {
|
||||||
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
||||||
|
$this->attachUsageEvents($activePackage, $linkedEventPackages);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -52,6 +55,79 @@ 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
|
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
|
||||||
{
|
{
|
||||||
$pkg = $package->package;
|
$pkg = $package->package;
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ class AuthenticatedSessionController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($candidate, '//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (str_starts_with($candidate, '/')) {
|
if (str_starts_with($candidate, '/')) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
@@ -170,7 +174,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
|
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +226,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
$scheme = $parsed['scheme'] ?? null;
|
$scheme = $parsed['scheme'] ?? null;
|
||||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
|
||||||
return '/event-admin/dashboard';
|
return '/event-admin/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +269,15 @@ class AuthenticatedSessionController extends Controller
|
|||||||
return $decoded;
|
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
|
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use App\Models\Tenant;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Checkout\CheckoutAssignmentService;
|
use App\Services\Checkout\CheckoutAssignmentService;
|
||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Paddle\Exceptions\PaddleException;
|
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||||
use App\Support\CheckoutRequestContext;
|
use App\Support\CheckoutRequestContext;
|
||||||
use App\Support\CheckoutRoutes;
|
use App\Support\CheckoutRoutes;
|
||||||
use App\Support\Concerns\PresentsPackages;
|
use App\Support\Concerns\PresentsPackages;
|
||||||
@@ -74,9 +74,11 @@ class CheckoutController extends Controller
|
|||||||
'error' => $facebookError,
|
'error' => $facebookError,
|
||||||
'profile' => $facebookProfile,
|
'profile' => $facebookProfile,
|
||||||
],
|
],
|
||||||
'paddle' => [
|
'paypal' => [
|
||||||
'environment' => config('paddle.environment'),
|
'client_id' => config('services.paypal.client_id'),
|
||||||
'client_token' => config('paddle.client_token'),
|
'currency' => config('checkout.currency', 'EUR'),
|
||||||
|
'intent' => 'capture',
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -271,9 +273,9 @@ class CheckoutController extends Controller
|
|||||||
CheckoutSession $session,
|
CheckoutSession $session,
|
||||||
CheckoutSessionService $sessions,
|
CheckoutSessionService $sessions,
|
||||||
CheckoutAssignmentService $assignment,
|
CheckoutAssignmentService $assignment,
|
||||||
PaddleTransactionService $transactions,
|
LemonSqueezyOrderService $orders,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
||||||
|
|
||||||
$session->refresh();
|
$session->refresh();
|
||||||
|
|
||||||
@@ -288,56 +290,56 @@ class CheckoutController extends Controller
|
|||||||
CheckoutSession $session,
|
CheckoutSession $session,
|
||||||
CheckoutSessionService $sessions,
|
CheckoutSessionService $sessions,
|
||||||
CheckoutAssignmentService $assignment,
|
CheckoutAssignmentService $assignment,
|
||||||
PaddleTransactionService $transactions,
|
LemonSqueezyOrderService $orders,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$transactionId = $validated['transaction_id'] ?? null;
|
$orderId = $validated['order_id'] ?? null;
|
||||||
$checkoutId = $validated['checkout_id'] ?? null;
|
$checkoutId = $validated['checkout_id'] ?? null;
|
||||||
|
|
||||||
$metadata = $session->provider_metadata ?? [];
|
$metadata = $session->provider_metadata ?? [];
|
||||||
$metadataUpdated = false;
|
$metadataUpdated = false;
|
||||||
|
|
||||||
if ($transactionId) {
|
if ($orderId) {
|
||||||
$session->paddle_transaction_id = $transactionId;
|
$session->lemonsqueezy_order_id = $orderId;
|
||||||
$metadata['paddle_transaction_id'] = $transactionId;
|
$metadata['lemonsqueezy_order_id'] = $orderId;
|
||||||
$metadataUpdated = true;
|
$metadataUpdated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($checkoutId) {
|
if ($checkoutId) {
|
||||||
$metadata['paddle_checkout_id'] = $checkoutId;
|
$metadata['lemonsqueezy_checkout_id'] = $checkoutId;
|
||||||
$metadataUpdated = true;
|
$metadataUpdated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($metadataUpdated) {
|
if ($metadataUpdated) {
|
||||||
$metadata['paddle_client_event_at'] = now()->toIso8601String();
|
$metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String();
|
||||||
$session->provider_metadata = $metadata;
|
$session->provider_metadata = $metadata;
|
||||||
$session->save();
|
$session->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app()->environment('local')
|
if (app()->environment('local')
|
||||||
&& $session->provider === CheckoutSession::PROVIDER_PADDLE
|
&& $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY
|
||||||
&& ! in_array($session->status, [
|
&& ! in_array($session->status, [
|
||||||
CheckoutSession::STATUS_COMPLETED,
|
CheckoutSession::STATUS_COMPLETED,
|
||||||
CheckoutSession::STATUS_FAILED,
|
CheckoutSession::STATUS_FAILED,
|
||||||
CheckoutSession::STATUS_CANCELLED,
|
CheckoutSession::STATUS_CANCELLED,
|
||||||
], true)
|
], true)
|
||||||
&& ($transactionId || $checkoutId)
|
&& ($orderId || $checkoutId)
|
||||||
) {
|
) {
|
||||||
$sessions->markProcessing($session, array_filter([
|
$sessions->markProcessing($session, array_filter([
|
||||||
'paddle_status' => 'completed',
|
'lemonsqueezy_status' => 'paid',
|
||||||
'paddle_transaction_id' => $transactionId,
|
'lemonsqueezy_order_id' => $orderId,
|
||||||
'paddle_local_confirmed_at' => now()->toIso8601String(),
|
'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$assignment->finalise($session, [
|
$assignment->finalise($session, [
|
||||||
'source' => 'paddle_local',
|
'source' => 'lemonsqueezy_local',
|
||||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||||
'provider_reference' => $transactionId ?? $checkoutId,
|
'provider_reference' => $orderId ?? $checkoutId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$sessions->markCompleted($session);
|
$sessions->markCompleted($session);
|
||||||
} else {
|
} else {
|
||||||
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
||||||
}
|
}
|
||||||
|
|
||||||
$session->refresh();
|
$session->refresh();
|
||||||
@@ -419,13 +421,13 @@ class CheckoutController extends Controller
|
|||||||
return $price <= 0;
|
return $price <= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function attemptPaddleRecovery(
|
private function attemptLemonSqueezyRecovery(
|
||||||
CheckoutSession $session,
|
CheckoutSession $session,
|
||||||
CheckoutSessionService $sessions,
|
CheckoutSessionService $sessions,
|
||||||
CheckoutAssignmentService $assignment,
|
CheckoutAssignmentService $assignment,
|
||||||
PaddleTransactionService $transactions
|
LemonSqueezyOrderService $orders
|
||||||
): void {
|
): void {
|
||||||
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +440,7 @@ class CheckoutController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$metadata = $session->provider_metadata ?? [];
|
$metadata = $session->provider_metadata ?? [];
|
||||||
$lastPollAt = $metadata['paddle_poll_at'] ?? null;
|
$lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null;
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
if ($lastPollAt) {
|
if ($lastPollAt) {
|
||||||
@@ -452,39 +454,31 @@ class CheckoutController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null;
|
$checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null;
|
||||||
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null;
|
$orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null;
|
||||||
|
|
||||||
if (! $checkoutId && ! $transactionId) {
|
if (! $checkoutId && ! $orderId) {
|
||||||
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [
|
Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$metadata['paddle_poll_at'] = $now->toIso8601String();
|
$metadata['lemonsqueezy_poll_at'] = $now->toIso8601String();
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'provider_metadata' => $metadata,
|
'provider_metadata' => $metadata,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null;
|
$order = $orderId ? $orders->retrieve($orderId) : null;
|
||||||
|
|
||||||
if (! $transaction && $checkoutId) {
|
if (! $order && $checkoutId) {
|
||||||
$transaction = $transactions->findByCheckoutId($checkoutId);
|
$order = $orders->findByCheckoutId($checkoutId);
|
||||||
}
|
}
|
||||||
|
} catch (LemonSqueezyException $exception) {
|
||||||
if (! $transaction) {
|
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
||||||
$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,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $orderId,
|
||||||
'status' => $exception->status(),
|
'status' => $exception->status(),
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'context' => $exception->context(),
|
'context' => $exception->context(),
|
||||||
@@ -492,77 +486,77 @@ class CheckoutController extends Controller
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
Log::warning('[Checkout] Paddle recovery failed', [
|
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $orderId,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $transaction) {
|
if (! $order) {
|
||||||
Log::info('[Checkout] Paddle recovery: transaction not found', [
|
Log::info('[Checkout] Lemon Squeezy recovery: order not found', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $orderId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = strtolower((string) ($transaction['status'] ?? ''));
|
$status = strtolower((string) data_get($order, 'attributes.status', ''));
|
||||||
$transactionId = $transactionId ?: ($transaction['id'] ?? null);
|
$resolvedOrderId = $orderId ?: data_get($order, 'id');
|
||||||
|
|
||||||
if ($transactionId && $session->paddle_transaction_id !== $transactionId) {
|
if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) {
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'paddle_transaction_id' => $transactionId,
|
'lemonsqueezy_order_id' => $resolvedOrderId,
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status === 'completed') {
|
if (in_array($status, ['paid', 'completed'], true)) {
|
||||||
$sessions->markProcessing($session, [
|
$sessions->markProcessing($session, [
|
||||||
'paddle_status' => $status,
|
'lemonsqueezy_status' => $status,
|
||||||
'paddle_transaction_id' => $transactionId,
|
'lemonsqueezy_order_id' => $resolvedOrderId,
|
||||||
'paddle_recovered_at' => $now->toIso8601String(),
|
'lemonsqueezy_recovered_at' => $now->toIso8601String(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$assignment->finalise($session, [
|
$assignment->finalise($session, [
|
||||||
'source' => 'paddle_poll',
|
'source' => 'lemonsqueezy_poll',
|
||||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||||
'provider_reference' => $transactionId,
|
'provider_reference' => $resolvedOrderId,
|
||||||
'payload' => $transaction,
|
'payload' => $order,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$sessions->markCompleted($session, $now);
|
$sessions->markCompleted($session, $now);
|
||||||
|
|
||||||
Log::info('[Checkout] Paddle session recovered via API', [
|
Log::info('[Checkout] Lemon Squeezy session recovered via API', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $resolvedOrderId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) {
|
if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) {
|
||||||
$sessions->markFailed($session, 'paddle_'.$status);
|
$sessions->markFailed($session, 'lemonsqueezy_'.$status);
|
||||||
|
|
||||||
Log::info('[Checkout] Paddle transaction failed', [
|
Log::info('[Checkout] Lemon Squeezy order failed', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $resolvedOrderId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info('[Checkout] Paddle transaction pending', [
|
Log::info('[Checkout] Lemon Squeezy order pending', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $resolvedOrderId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,27 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
|
use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest;
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Coupons\CouponService;
|
use App\Services\Coupons\CouponService;
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||||
use App\Support\CheckoutRequestContext;
|
use App\Support\CheckoutRequestContext;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PaddleCheckoutController extends Controller
|
class LemonSqueezyCheckoutController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PaddleCheckoutService $checkout,
|
private readonly LemonSqueezyCheckoutService $checkout,
|
||||||
private readonly CheckoutSessionService $sessions,
|
private readonly CheckoutSessionService $sessions,
|
||||||
private readonly CouponService $coupons,
|
private readonly CouponService $coupons,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function create(PaddleCheckoutRequest $request): JsonResponse
|
public function create(LemonSqueezyCheckoutRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ class PaddleCheckoutController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail((int) $data['package_id']);
|
$package = Package::findOrFail((int) $data['package_id']);
|
||||||
|
|
||||||
if (! $package->paddle_price_id) {
|
if (! $package->lemonsqueezy_variant_id) {
|
||||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||||
@@ -46,7 +46,7 @@ class PaddleCheckoutController extends Controller
|
|||||||
]
|
]
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
@@ -59,44 +59,18 @@ class PaddleCheckoutController extends Controller
|
|||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
|
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
|
||||||
$discountId = null;
|
|
||||||
|
|
||||||
if ($couponCode !== '') {
|
if ($couponCode !== '') {
|
||||||
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
||||||
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||||
$discountId = $preview['coupon']->paddle_discount_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->boolean('inline') && $discountId === null) {
|
if (app()->environment('local')) {
|
||||||
$metadata = array_merge($session->provider_metadata ?? [], [
|
$checkout = $this->simulateLocalCheckout($session, $package, $couponCode);
|
||||||
'mode' => 'inline',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$session->forceFill([
|
return response()->json(array_merge($checkout, [
|
||||||
'provider_metadata' => $metadata,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'checkout_session_id' => $session->id,
|
'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, [
|
$checkout = $this->checkout->createCheckout($tenant, $package, [
|
||||||
@@ -108,15 +82,17 @@ class PaddleCheckoutController extends Controller
|
|||||||
'legal_version' => $session->legal_version,
|
'legal_version' => $session->legal_version,
|
||||||
'accepted_terms' => true,
|
'accepted_terms' => true,
|
||||||
],
|
],
|
||||||
'discount_id' => $discountId,
|
'discount_code' => $couponCode ?: null,
|
||||||
|
'customer_email' => $user?->email,
|
||||||
|
'customer_name' => trim(($user?->first_name ?? '').' '.($user?->last_name ?? '')) ?: ($user?->name ?? null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
||||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
||||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
||||||
])),
|
])),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
@@ -125,6 +101,36 @@ class PaddleCheckoutController 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
|
protected function resolveLegalVersion(): string
|
||||||
{
|
{
|
||||||
return config('app.legal_version', now()->toDateString());
|
return config('app.legal_version', now()->toDateString());
|
||||||
@@ -2,35 +2,32 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Services\Paddle\Exceptions\PaddleException;
|
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class PaddleReturnController extends Controller
|
class LemonSqueezyReturnController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly PaddleTransactionService $transactions) {}
|
public function __construct(private readonly LemonSqueezyOrderService $orders) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the incoming request.
|
|
||||||
*/
|
|
||||||
public function __invoke(Request $request): RedirectResponse
|
public function __invoke(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$transactionId = $this->resolveTransactionId($request);
|
$orderId = $this->resolveOrderId($request);
|
||||||
$fallback = $this->resolveFallbackUrl();
|
$fallback = $this->resolveFallbackUrl();
|
||||||
|
|
||||||
if (! $transactionId) {
|
if (! $orderId) {
|
||||||
return redirect()->to($fallback);
|
return redirect()->to($fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$transaction = $this->transactions->retrieve($transactionId);
|
$order = $this->orders->retrieve($orderId);
|
||||||
} catch (PaddleException $exception) {
|
} catch (LemonSqueezyException $exception) {
|
||||||
Log::warning('Paddle return failed to load transaction', [
|
Log::warning('Lemon Squeezy return failed to load order', [
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $orderId,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
'status' => $exception->status(),
|
'status' => $exception->status(),
|
||||||
]);
|
]);
|
||||||
@@ -38,10 +35,10 @@ class PaddleReturnController extends Controller
|
|||||||
return redirect()->to($fallback);
|
return redirect()->to($fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
$customData = $this->extractCustomData($transaction);
|
$customData = $this->extractCustomData($order);
|
||||||
$status = Str::lower((string) ($transaction['status'] ?? ''));
|
$status = Str::lower((string) Arr::get($order, 'attributes.status', ''));
|
||||||
$successUrl = $customData['success_url'] ?? null;
|
$successUrl = $customData['success_url'] ?? null;
|
||||||
$cancelUrl = $customData['cancel_url'] ?? $customData['return_url'] ?? null;
|
$cancelUrl = $customData['return_url'] ?? null;
|
||||||
|
|
||||||
$target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl;
|
$target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl;
|
||||||
$target = $this->resolveSafeRedirect($target, $fallback);
|
$target = $this->resolveSafeRedirect($target, $fallback);
|
||||||
@@ -49,11 +46,10 @@ class PaddleReturnController extends Controller
|
|||||||
return redirect()->to($target);
|
return redirect()->to($target);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveTransactionId(Request $request): ?string
|
protected function resolveOrderId(Request $request): ?string
|
||||||
{
|
{
|
||||||
$candidate = $request->query('_ptxn')
|
$candidate = $request->query('order_id')
|
||||||
?? $request->query('ptxn')
|
?? $request->query('order');
|
||||||
?? $request->query('transaction_id');
|
|
||||||
|
|
||||||
if (! is_string($candidate) || $candidate === '') {
|
if (! is_string($candidate) || $candidate === '') {
|
||||||
return null;
|
return null;
|
||||||
@@ -68,33 +64,19 @@ class PaddleReturnController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $transaction
|
* @param array<string, mixed> $order
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function extractCustomData(array $transaction): array
|
protected function extractCustomData(array $order): array
|
||||||
{
|
{
|
||||||
$customData = Arr::get($transaction, 'custom_data', []);
|
$customData = Arr::get($order, 'attributes.custom_data', []);
|
||||||
|
|
||||||
if (! is_array($customData)) {
|
return is_array($customData) ? $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
|
protected function isSuccessStatus(string $status): bool
|
||||||
{
|
{
|
||||||
return in_array($status, ['completed', 'paid'], true);
|
return in_array($status, ['paid', 'completed'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||||
@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class PaddleWebhookController extends Controller
|
class LemonSqueezyWebhookController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutWebhookService $webhooks,
|
private readonly CheckoutWebhookService $webhooks,
|
||||||
@@ -22,7 +22,7 @@ class PaddleWebhookController extends Controller
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (! $this->verify($request)) {
|
if (! $this->verify($request)) {
|
||||||
Log::warning('Paddle webhook signature verification failed');
|
Log::warning('Lemon Squeezy webhook signature verification failed');
|
||||||
|
|
||||||
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
|
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
@@ -33,29 +33,27 @@ class PaddleWebhookController extends Controller
|
|||||||
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
|
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
$eventType = $payload['event_type'] ?? null;
|
$eventType = $payload['meta']['event_name'] ?? $request->headers->get('X-Event-Name');
|
||||||
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
|
$eventId = $payload['meta']['event_id'] ?? $payload['data']['id'] ?? null;
|
||||||
$webhookEvent = $this->recorder->recordReceived(
|
$webhookEvent = $this->recorder->recordReceived(
|
||||||
'paddle',
|
'lemonsqueezy',
|
||||||
$eventId ? (string) $eventId : null,
|
$eventId ? (string) $eventId : null,
|
||||||
$eventType ? (string) $eventType : null,
|
$eventType ? (string) $eventType : null,
|
||||||
);
|
);
|
||||||
$handled = false;
|
$handled = false;
|
||||||
|
|
||||||
$this->logDev('Paddle webhook received', [
|
$this->logDev('Lemon Squeezy webhook received', [
|
||||||
'event_type' => $eventType,
|
'event_type' => $eventType,
|
||||||
'checkout_id' => data_get($payload, 'data.checkout_id'),
|
'order_id' => data_get($payload, 'data.id'),
|
||||||
'transaction_id' => data_get($payload, 'data.id'),
|
'has_signature' => (string) $request->headers->get('X-Signature', '') !== '',
|
||||||
'has_billing_signature' => (string) $request->headers->get('Paddle-Signature', '') !== '',
|
|
||||||
'has_legacy_signature' => (string) $request->headers->get('Paddle-Webhook-Signature', '') !== '',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($eventType) {
|
if ($eventType) {
|
||||||
$handled = $this->webhooks->handlePaddleEvent($payload);
|
$handled = $this->webhooks->handleLemonSqueezyEvent($payload);
|
||||||
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info('Paddle webhook processed', [
|
Log::info('Lemon Squeezy webhook processed', [
|
||||||
'event_type' => $eventType,
|
'event_type' => $eventType,
|
||||||
'handled' => $handled,
|
'handled' => $handled,
|
||||||
]);
|
]);
|
||||||
@@ -71,13 +69,13 @@ class PaddleWebhookController extends Controller
|
|||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$eventId = $this->captureWebhookException($exception);
|
$eventId = $this->captureWebhookException($exception);
|
||||||
|
|
||||||
Log::error('Paddle webhook processing failed', [
|
Log::error('Lemon Squeezy webhook processing failed', [
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'event_type' => (string) $request->json('event_type'),
|
'event_type' => (string) data_get($request->json()->all(), 'meta.event_name'),
|
||||||
'sentry_event_id' => $eventId,
|
'sentry_event_id' => $eventId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
|
$this->logDev('Lemon Squeezy webhook error payload', $this->reducePayload($request->json()->all()));
|
||||||
|
|
||||||
if (isset($webhookEvent)) {
|
if (isset($webhookEvent)) {
|
||||||
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
|
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
|
||||||
@@ -89,85 +87,33 @@ class PaddleWebhookController extends Controller
|
|||||||
|
|
||||||
protected function verify(Request $request): bool
|
protected function verify(Request $request): bool
|
||||||
{
|
{
|
||||||
$secret = config('paddle.webhook_secret');
|
$secret = config('lemonsqueezy.webhook_secret');
|
||||||
|
|
||||||
if (! $secret) {
|
if (! $secret) {
|
||||||
// Allow processing in sandbox or when secret not configured
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$billingSignature = (string) $request->headers->get('Paddle-Signature', '');
|
$signature = (string) $request->headers->get('X-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 === '') {
|
if ($signature === '') {
|
||||||
$this->logDev('Paddle webhook missing signature header', [
|
$this->logDev('Lemon Squeezy webhook missing signature header', [
|
||||||
'header' => 'Paddle-Webhook-Signature',
|
'header' => 'X-Signature',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$payload = $request->getContent();
|
||||||
$expected = hash_hmac('sha256', $payload, $secret);
|
$expected = hash_hmac('sha256', $payload, $secret);
|
||||||
|
|
||||||
$valid = hash_equals($expected, $signature);
|
$valid = hash_equals($expected, $signature);
|
||||||
if (! $valid) {
|
if (! $valid) {
|
||||||
$this->logDev('Paddle webhook signature mismatch (legacy)', []);
|
$this->logDev('Lemon Squeezy webhook signature mismatch', []);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $valid;
|
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
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
@@ -177,7 +123,7 @@ class PaddleWebhookController extends Controller
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info('[PaddleWebhook] '.$message, $context);
|
Log::info('[LemonSqueezyWebhook] '.$message, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,12 +132,11 @@ class PaddleWebhookController extends Controller
|
|||||||
protected function reducePayload(array $payload): array
|
protected function reducePayload(array $payload): array
|
||||||
{
|
{
|
||||||
return array_filter([
|
return array_filter([
|
||||||
'event_type' => $payload['event_type'] ?? null,
|
'event_type' => data_get($payload, 'meta.event_name'),
|
||||||
'transaction_id' => data_get($payload, 'data.id'),
|
'order_id' => data_get($payload, 'data.id'),
|
||||||
'checkout_id' => data_get($payload, 'data.checkout_id'),
|
'status' => data_get($payload, 'data.attributes.status'),
|
||||||
'status' => data_get($payload, 'data.status'),
|
'customer_id' => data_get($payload, 'data.attributes.customer_id'),
|
||||||
'customer_id' => data_get($payload, 'data.customer_id'),
|
'has_custom_data' => is_array(data_get($payload, 'meta.custom_data')),
|
||||||
'has_custom_data' => is_array(data_get($payload, 'data.custom_data')),
|
|
||||||
], static fn ($value) => $value !== null);
|
], static fn ($value) => $value !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,8 @@ use App\Models\TenantPackage;
|
|||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Coupons\CouponService;
|
use App\Services\Coupons\CouponService;
|
||||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\PayPal\Exceptions\PayPalException;
|
||||||
|
use App\Services\PayPal\PayPalOrderService;
|
||||||
use App\Support\CheckoutRequestContext;
|
use App\Support\CheckoutRequestContext;
|
||||||
use App\Support\CheckoutRoutes;
|
use App\Support\CheckoutRoutes;
|
||||||
use App\Support\Concerns\PresentsPackages;
|
use App\Support\Concerns\PresentsPackages;
|
||||||
@@ -41,7 +42,7 @@ class MarketingController extends Controller
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutSessionService $checkoutSessions,
|
private readonly CheckoutSessionService $checkoutSessions,
|
||||||
private readonly PaddleCheckoutService $paddleCheckout,
|
private readonly PayPalOrderService $paypalOrders,
|
||||||
private readonly CouponService $coupons,
|
private readonly CouponService $coupons,
|
||||||
private readonly GiftVoucherCheckoutService $giftVouchers,
|
private readonly GiftVoucherCheckoutService $giftVouchers,
|
||||||
) {}
|
) {}
|
||||||
@@ -194,16 +195,6 @@ class MarketingController extends Controller
|
|||||||
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
|
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(
|
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
||||||
CheckoutRequestContext::fromRequest($request),
|
CheckoutRequestContext::fromRequest($request),
|
||||||
[
|
[
|
||||||
@@ -211,7 +202,7 @@ class MarketingController extends Controller
|
|||||||
]
|
]
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
@@ -223,52 +214,71 @@ class MarketingController extends Controller
|
|||||||
'legal_version' => $this->resolveLegalVersion(),
|
'legal_version' => $this->resolveLegalVersion(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$appliedDiscountId = null;
|
|
||||||
|
|
||||||
if ($couponCode) {
|
if ($couponCode) {
|
||||||
try {
|
try {
|
||||||
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
||||||
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||||
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
|
|
||||||
$request->session()->forget('marketing.checkout.coupon');
|
$request->session()->forget('marketing.checkout.coupon');
|
||||||
} catch (ValidationException $exception) {
|
} catch (ValidationException $exception) {
|
||||||
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
|
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
$successUrl = route('marketing.success', [
|
||||||
'success_url' => route('marketing.success', [
|
|
||||||
'locale' => app()->getLocale(),
|
'locale' => app()->getLocale(),
|
||||||
'packageId' => $package->id,
|
'packageId' => $package->id,
|
||||||
]),
|
]);
|
||||||
'return_url' => route('packages', [
|
$cancelUrl = route('packages', [
|
||||||
'locale' => app()->getLocale(),
|
'locale' => app()->getLocale(),
|
||||||
'highlight' => $package->slug,
|
'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,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$checkout = $this->paypalOrders->createOrder($session, $package, [
|
||||||
|
'return_url' => route('paypal.return', absolute: true),
|
||||||
|
'cancel_url' => route('paypal.return', absolute: true),
|
||||||
|
'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);
|
||||||
|
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
'paypal_order_id' => $orderId,
|
||||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
'paypal_order_id' => $orderId,
|
||||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
'paypal_status' => $checkout['status'] ?? null,
|
||||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
'paypal_approve_url' => $redirectUrl,
|
||||||
|
'paypal_success_url' => $successUrl,
|
||||||
|
'paypal_cancel_url' => $cancelUrl,
|
||||||
|
'paypal_created_at' => now()->toIso8601String(),
|
||||||
])),
|
])),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$redirectUrl = $checkout['checkout_url'] ?? null;
|
$this->checkoutSessions->markRequiresCustomerAction($session, 'paypal_approval');
|
||||||
|
|
||||||
if (! $redirectUrl) {
|
if (! $redirectUrl) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'paddle' => __('marketing.packages.paddle_checkout_failed'),
|
'paypal' => __('marketing.packages.paypal_checkout_failed'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +419,15 @@ class MarketingController extends Controller
|
|||||||
public function demo()
|
public function demo()
|
||||||
{
|
{
|
||||||
$event = Event::query()
|
$event = Event::query()
|
||||||
|
->where(function ($query) {
|
||||||
|
$query
|
||||||
->where('settings->marketing_demo', true)
|
->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');
|
||||||
|
})
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
$joinToken = null;
|
$joinToken = null;
|
||||||
|
|||||||
150
app/Http/Controllers/PayPalAddonReturnController.php
Normal file
150
app/Http/Controllers/PayPalAddonReturnController.php
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
app/Http/Controllers/PayPalCheckoutController.php
Normal file
228
app/Http/Controllers/PayPalCheckoutController.php
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Http/Controllers/PayPalGiftVoucherReturnController.php
Normal file
129
app/Http/Controllers/PayPalGiftVoucherReturnController.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/Http/Controllers/PayPalReturnController.php
Normal file
141
app/Http/Controllers/PayPalReturnController.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Http/Controllers/PayPalWebhookController.php
Normal file
120
app/Http/Controllers/PayPalWebhookController.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?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,13 +100,30 @@ class TenantAdminFacebookController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($decoded, '//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($decoded, '/')) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
|
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||||
|
{
|
||||||
|
if ($targetHost === $appHost) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::endsWith($targetHost, '.'.$appHost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,13 +100,30 @@ class TenantAdminGoogleController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($decoded, '//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($decoded, '/')) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
|
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $decoded;
|
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 simulatePaddle(
|
public function simulateLemonSqueezy(
|
||||||
Request $request,
|
Request $request,
|
||||||
CheckoutWebhookService $webhooks,
|
CheckoutWebhookService $webhooks,
|
||||||
CheckoutSession $session
|
CheckoutSession $session
|
||||||
@@ -70,13 +70,13 @@ class TestCheckoutController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'event_type' => ['nullable', 'string'],
|
'event_type' => ['nullable', 'string'],
|
||||||
'transaction_id' => ['nullable', 'string'],
|
'order_id' => ['nullable', 'string'],
|
||||||
'status' => ['nullable', 'string'],
|
'status' => ['nullable', 'string'],
|
||||||
'checkout_id' => ['nullable', 'string'],
|
'checkout_id' => ['nullable', 'string'],
|
||||||
'metadata' => ['nullable', 'array'],
|
'metadata' => ['nullable', 'array'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$eventType = $validated['event_type'] ?? 'transaction.completed';
|
$eventType = $validated['event_type'] ?? 'order_created';
|
||||||
$metadata = array_merge([
|
$metadata = array_merge([
|
||||||
'tenant_id' => $session->tenant_id,
|
'tenant_id' => $session->tenant_id,
|
||||||
'package_id' => $session->package_id,
|
'package_id' => $session->package_id,
|
||||||
@@ -84,16 +84,21 @@ class TestCheckoutController extends Controller
|
|||||||
], $validated['metadata'] ?? []);
|
], $validated['metadata'] ?? []);
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'event_type' => $eventType,
|
'meta' => [
|
||||||
'data' => array_filter([
|
'event_name' => $eventType,
|
||||||
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
|
|
||||||
'status' => $validated['status'] ?? 'completed',
|
|
||||||
'custom_data' => $metadata,
|
'custom_data' => $metadata,
|
||||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(),
|
],
|
||||||
|
'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,
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
$handled = $webhooks->handlePaddleEvent($payload);
|
$handled = $webhooks->handleLemonSqueezyEvent($payload);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => [
|
'data' => [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Models\EventPackage;
|
|||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Notifications\Customer\WithdrawalConfirmed;
|
use App\Notifications\Customer\WithdrawalConfirmed;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -36,7 +36,7 @@ class WithdrawalController extends Controller
|
|||||||
|
|
||||||
public function confirm(
|
public function confirm(
|
||||||
WithdrawalConfirmRequest $request,
|
WithdrawalConfirmRequest $request,
|
||||||
PaddleTransactionService $transactions,
|
LemonSqueezyOrderService $orders,
|
||||||
string $locale
|
string $locale
|
||||||
): RedirectResponse {
|
): RedirectResponse {
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
@@ -60,10 +60,10 @@ class WithdrawalController extends Controller
|
|||||||
->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale));
|
->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
$transactionId = $this->resolveTransactionId($purchase);
|
$orderId = $this->resolveOrderId($purchase);
|
||||||
|
|
||||||
if (! $transactionId) {
|
if (! $orderId) {
|
||||||
Log::warning('Withdrawal missing Paddle transaction reference.', [
|
Log::warning('Withdrawal missing Lemon Squeezy order reference.', [
|
||||||
'purchase_id' => $purchase->id,
|
'purchase_id' => $purchase->id,
|
||||||
'provider' => $purchase->provider,
|
'provider' => $purchase->provider,
|
||||||
]);
|
]);
|
||||||
@@ -74,11 +74,11 @@ class WithdrawalController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$transactions->refund($transactionId, ['reason' => 'withdrawal']);
|
$orders->refund($orderId, ['reason' => 'withdrawal']);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
Log::warning('Withdrawal refund failed', [
|
Log::warning('Withdrawal refund failed', [
|
||||||
'purchase_id' => $purchase->id,
|
'purchase_id' => $purchase->id,
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $orderId,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -94,13 +94,13 @@ class WithdrawalController extends Controller
|
|||||||
$withdrawalMeta = array_merge($withdrawalMeta, [
|
$withdrawalMeta = array_merge($withdrawalMeta, [
|
||||||
'confirmed_at' => $confirmedAt->toIso8601String(),
|
'confirmed_at' => $confirmedAt->toIso8601String(),
|
||||||
'confirmed_by' => $user?->id,
|
'confirmed_by' => $user?->id,
|
||||||
'transaction_id' => $transactionId,
|
'order_id' => $orderId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$metadata['withdrawal'] = $withdrawalMeta;
|
$metadata['withdrawal'] = $withdrawalMeta;
|
||||||
|
|
||||||
$purchase->forceFill([
|
$purchase->forceFill([
|
||||||
'provider_id' => $transactionId,
|
'provider_id' => $orderId,
|
||||||
'refunded' => true,
|
'refunded' => true,
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
])->save();
|
])->save();
|
||||||
@@ -127,7 +127,7 @@ class WithdrawalController extends Controller
|
|||||||
->with('package')
|
->with('package')
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('type', 'endcustomer_event')
|
->where('type', 'endcustomer_event')
|
||||||
->where('provider', 'paddle')
|
->where('provider', 'lemonsqueezy')
|
||||||
->where('refunded', false)
|
->where('refunded', false)
|
||||||
->orderByDesc('purchased_at')
|
->orderByDesc('purchased_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
@@ -151,7 +151,7 @@ class WithdrawalController extends Controller
|
|||||||
$reasons[] = 'type';
|
$reasons[] = 'type';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($purchase->provider !== 'paddle') {
|
if ($purchase->provider !== 'lemonsqueezy') {
|
||||||
$reasons[] = 'provider';
|
$reasons[] = 'provider';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ class WithdrawalController extends Controller
|
|||||||
$reasons[] = 'refunded';
|
$reasons[] = 'refunded';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->resolveTransactionId($purchase)) {
|
if (! $this->resolveOrderId($purchase)) {
|
||||||
$reasons[] = 'missing_reference';
|
$reasons[] = 'missing_reference';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,13 +224,13 @@ class WithdrawalController extends Controller
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTransactionId(PackagePurchase $purchase): ?string
|
private function resolveOrderId(PackagePurchase $purchase): ?string
|
||||||
{
|
{
|
||||||
if ($purchase->provider === 'paddle' && $purchase->provider_id) {
|
if ($purchase->provider === 'lemonsqueezy' && $purchase->provider_id) {
|
||||||
return (string) $purchase->provider_id;
|
return (string) $purchase->provider_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data_get($purchase->metadata, 'paddle_transaction_id');
|
return data_get($purchase->metadata, 'lemonsqueezy_order_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void
|
private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class ContentSecurityPolicy
|
|||||||
$scriptSources = [
|
$scriptSources = [
|
||||||
"'self'",
|
"'self'",
|
||||||
"'nonce-{$scriptNonce}'",
|
"'nonce-{$scriptNonce}'",
|
||||||
'https://cdn.paddle.com',
|
'https://app.lemonsqueezy.com',
|
||||||
'https://global.localizecdn.com',
|
'https://global.localizecdn.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,21 +49,16 @@ class ContentSecurityPolicy
|
|||||||
|
|
||||||
$connectSources = [
|
$connectSources = [
|
||||||
"'self'",
|
"'self'",
|
||||||
'https://api.paddle.com',
|
'https://api.lemonsqueezy.com',
|
||||||
'https://sandbox-api.paddle.com',
|
'https://app.lemonsqueezy.com',
|
||||||
'https://checkout.paddle.com',
|
'https://fotospiel.lemonsqueezy.com',
|
||||||
'https://sandbox-checkout.paddle.com',
|
|
||||||
'https://checkout-service.paddle.com',
|
|
||||||
'https://sandbox-checkout-service.paddle.com',
|
|
||||||
'https://global.localizecdn.com',
|
'https://global.localizecdn.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
$frameSources = [
|
$frameSources = [
|
||||||
"'self'",
|
"'self'",
|
||||||
'https://checkout.paddle.com',
|
'https://app.lemonsqueezy.com',
|
||||||
'https://sandbox-checkout.paddle.com',
|
'https://fotospiel.lemonsqueezy.com',
|
||||||
'https://checkout-service.paddle.com',
|
|
||||||
'https://sandbox-checkout-service.paddle.com',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$imgSources = [
|
$imgSources = [
|
||||||
@@ -86,6 +81,23 @@ class ContentSecurityPolicy
|
|||||||
'https:',
|
'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) {
|
if ($matomoOrigin) {
|
||||||
$scriptSources[] = $matomoOrigin;
|
$scriptSources[] = $matomoOrigin;
|
||||||
$connectSources[] = $matomoOrigin;
|
$connectSources[] = $matomoOrigin;
|
||||||
@@ -95,6 +107,18 @@ class ContentSecurityPolicy
|
|||||||
$isDev = app()->environment(['local', 'development']) || config('app.debug');
|
$isDev = app()->environment(['local', 'development']) || config('app.debug');
|
||||||
|
|
||||||
if ($isDev) {
|
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 = [
|
$devHosts = [
|
||||||
'http://fotospiel-app.test:5173',
|
'http://fotospiel-app.test:5173',
|
||||||
'http://127.0.0.1:5173',
|
'http://127.0.0.1:5173',
|
||||||
@@ -134,6 +158,7 @@ class ContentSecurityPolicy
|
|||||||
'font-src' => array_unique($fontSources),
|
'font-src' => array_unique($fontSources),
|
||||||
'connect-src' => array_unique($connectSources),
|
'connect-src' => array_unique($connectSources),
|
||||||
'media-src' => array_unique($mediaSources),
|
'media-src' => array_unique($mediaSources),
|
||||||
|
'worker-src' => array_unique($workerSources),
|
||||||
'frame-src' => array_unique($frameSources),
|
'frame-src' => array_unique($frameSources),
|
||||||
'form-action' => ["'self'"],
|
'form-action' => ["'self'"],
|
||||||
'base-uri' => ["'self'"],
|
'base-uri' => ["'self'"],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class VerifyCsrfToken extends Middleware
|
|||||||
protected $except = [
|
protected $except = [
|
||||||
'api/v1/photos/*/like',
|
'api/v1/photos/*/like',
|
||||||
'api/v1/events/*/upload',
|
'api/v1/events/*/upload',
|
||||||
'paddle/webhook*',
|
'lemonsqueezy/webhook*',
|
||||||
|
'paypal/webhook*',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
26
app/Http/Requests/Api/GuestAiEditStoreRequest.php
Normal file
26
app/Http/Requests/Api/GuestAiEditStoreRequest.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?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
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'transaction_id' => ['nullable', 'string', 'required_without:checkout_id'],
|
'order_id' => ['nullable', 'string', 'required_without:checkout_id'],
|
||||||
'checkout_id' => ['nullable', 'string', 'required_without:transaction_id'],
|
'checkout_id' => ['nullable', 'string', 'required_without:order_id'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'transaction_id.required_without' => 'Transaction ID oder Checkout ID fehlt.',
|
'order_id.required_without' => 'Order ID oder Checkout ID fehlt.',
|
||||||
'checkout_id.required_without' => 'Checkout ID oder Transaction ID fehlt.',
|
'checkout_id.required_without' => 'Checkout ID oder Order ID fehlt.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Paddle;
|
namespace App\Http\Requests\LemonSqueezy;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class PaddleCheckoutRequest extends FormRequest
|
class LemonSqueezyCheckoutRequest extends FormRequest
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Determine if the user is authorized to make this request.
|
* Determine if the user is authorized to make this request.
|
||||||
*/
|
*/
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return (bool) $this->user();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,15 +25,11 @@ class PaddleCheckoutRequest extends FormRequest
|
|||||||
'package_id' => ['required', 'exists:packages,id'],
|
'package_id' => ['required', 'exists:packages,id'],
|
||||||
'success_url' => ['nullable', 'url'],
|
'success_url' => ['nullable', 'url'],
|
||||||
'return_url' => ['nullable', 'url'],
|
'return_url' => ['nullable', 'url'],
|
||||||
'inline' => ['sometimes', 'boolean'],
|
|
||||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||||
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
'accepted_terms' => ['required', 'boolean', 'accepted'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get custom validation messages.
|
|
||||||
*/
|
|
||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
56
app/Http/Requests/PayPal/PayPalCaptureRequest.php
Normal file
56
app/Http/Requests/PayPal/PayPalCaptureRequest.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Http/Requests/PayPal/PayPalCheckoutRequest.php
Normal file
41
app/Http/Requests/PayPal/PayPalCheckoutRequest.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?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),
|
Rule::unique('tenants', 'slug')->ignore($tenantId),
|
||||||
],
|
],
|
||||||
'contact_email' => ['sometimes', 'email', 'max:255'],
|
'contact_email' => ['sometimes', 'email', 'max:255'],
|
||||||
'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
|
'lemonsqueezy_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||||
'is_active' => ['sometimes', 'boolean'],
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
'is_suspended' => ['sometimes', 'boolean'],
|
'is_suspended' => ['sometimes', 'boolean'],
|
||||||
'features' => ['sometimes', 'array'],
|
'features' => ['sometimes', 'array'],
|
||||||
@@ -31,7 +31,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
|
|||||||
return [
|
return [
|
||||||
'slug',
|
'slug',
|
||||||
'contact_email',
|
'contact_email',
|
||||||
'paddle_customer_id',
|
'lemonsqueezy_customer_id',
|
||||||
'is_active',
|
'is_active',
|
||||||
'is_suspended',
|
'is_suspended',
|
||||||
'features',
|
'features',
|
||||||
|
|||||||
22
app/Http/Requests/Tenant/AiEditIndexRequest.php
Normal file
22
app/Http/Requests/Tenant/AiEditIndexRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/Tenant/AiEditStoreRequest.php
Normal file
27
app/Http/Requests/Tenant/AiEditStoreRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Requests/Tenant/BillingAddonHistoryRequest.php
Normal file
24
app/Http/Requests/Tenant/BillingAddonHistoryRequest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Requests/Tenant/EventAddonPurchaseLookupRequest.php
Normal file
22
app/Http/Requests/Tenant/EventAddonPurchaseLookupRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?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,6 +55,7 @@ class EventStoreRequest extends FormRequest
|
|||||||
'settings.branding.*' => ['nullable'],
|
'settings.branding.*' => ['nullable'],
|
||||||
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
||||||
'settings.guest_downloads_enabled' => ['nullable', 'boolean'],
|
'settings.guest_downloads_enabled' => ['nullable', 'boolean'],
|
||||||
|
'settings.guest_download_variant' => ['nullable', Rule::in(['preview', 'original'])],
|
||||||
'settings.guest_sharing_enabled' => ['nullable', 'boolean'],
|
'settings.guest_sharing_enabled' => ['nullable', 'boolean'],
|
||||||
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
|
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
|
||||||
'settings.live_show' => ['nullable', 'array'],
|
'settings.live_show' => ['nullable', 'array'],
|
||||||
@@ -83,6 +84,16 @@ class EventStoreRequest extends FormRequest
|
|||||||
'settings.control_room.force_review_uploaders' => ['nullable', 'array'],
|
'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.*.device_id' => ['required', 'string', 'max:120'],
|
||||||
'settings.control_room.force_review_uploaders.*.label' => ['nullable', 'string', 'max:80'],
|
'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' => ['nullable', 'array'],
|
||||||
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
|
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
|
||||||
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],
|
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
namespace App\Http\Resources\Tenant;
|
namespace App\Http\Resources\Tenant;
|
||||||
|
|
||||||
use App\Models\WatermarkSetting;
|
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\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\TenantMemberPermissions;
|
use App\Support\TenantMemberPermissions;
|
||||||
use App\Support\WatermarkConfigResolver;
|
use App\Support\WatermarkConfigResolver;
|
||||||
@@ -49,6 +52,8 @@ class EventResource extends JsonResource
|
|||||||
if ($eventPackage) {
|
if ($eventPackage) {
|
||||||
$limitEvaluator = app()->make(PackageLimitEvaluator::class);
|
$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);
|
$settings['watermark_removal_allowed'] = WatermarkConfigResolver::determineRemovalAllowed($this->resource);
|
||||||
|
|
||||||
@@ -89,17 +94,29 @@ class EventResource extends JsonResource
|
|||||||
'qr_code_url' => null,
|
'qr_code_url' => null,
|
||||||
'package' => $eventPackage ? [
|
'package' => $eventPackage ? [
|
||||||
'id' => $eventPackage->package_id,
|
'id' => $eventPackage->package_id,
|
||||||
|
'tenant_package_id' => $eventPackage->tenant_package_id,
|
||||||
'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name,
|
'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name,
|
||||||
'price' => $eventPackage->purchased_price,
|
'price' => $eventPackage->purchased_price,
|
||||||
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
|
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
|
||||||
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
||||||
'branding_allowed' => (bool) optional($eventPackage->package)->branding_allowed,
|
'branding_allowed' => (bool) optional($eventPackage->package)->branding_allowed,
|
||||||
'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed,
|
'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed,
|
||||||
|
'features' => optional($eventPackage->package)->features ?? [],
|
||||||
] : null,
|
] : null,
|
||||||
'limits' => $eventPackage && $limitEvaluator
|
'limits' => $eventPackage && $limitEvaluator
|
||||||
? $limitEvaluator->summarizeEventPackage($eventPackage, $this->resolveTasksUsed())
|
? $limitEvaluator->summarizeEventPackage($eventPackage, $this->resolveTasksUsed())
|
||||||
: null,
|
: null,
|
||||||
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
'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,
|
'member_permissions' => $memberPermissions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -206,13 +223,19 @@ class EventResource extends JsonResource
|
|||||||
? $eventPackage->addons
|
? $eventPackage->addons
|
||||||
: $eventPackage->addons()->latest()->take(10)->get();
|
: $eventPackage->addons()->latest()->take(10)->get();
|
||||||
|
|
||||||
return $addons->map(function ($addon) {
|
$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 [
|
return [
|
||||||
'id' => $addon->id,
|
'id' => $addon->id,
|
||||||
'key' => $addon->addon_key,
|
'key' => $addon->addon_key,
|
||||||
'label' => $addon->metadata['label'] ?? null,
|
'label' => $addon->metadata['label']
|
||||||
|
?? ($addonLabels[$addon->addon_key] ?? null)
|
||||||
|
?? $addon->addon_key,
|
||||||
'status' => $addon->status,
|
'status' => $addon->status,
|
||||||
'price_id' => $addon->price_id,
|
'variant_id' => $addon->variant_id,
|
||||||
'transaction_id' => $addon->transaction_id,
|
'transaction_id' => $addon->transaction_id,
|
||||||
'extra_photos' => (int) $addon->extra_photos,
|
'extra_photos' => (int) $addon->extra_photos,
|
||||||
'extra_guests' => (int) $addon->extra_guests,
|
'extra_guests' => (int) $addon->extra_guests,
|
||||||
|
|||||||
264
app/Jobs/PollAiEditRequest.php
Normal file
264
app/Jobs/PollAiEditRequest.php
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
317
app/Jobs/ProcessAiEditRequest.php
Normal file
317
app/Jobs/ProcessAiEditRequest.php
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<?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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Services\Paddle\PaddleCatalogService;
|
use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@@ -13,7 +13,7 @@ use Illuminate\Support\Arr;
|
|||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class PullPackageFromPaddle implements ShouldQueue
|
class PullPackageFromLemonSqueezy implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
@@ -22,7 +22,7 @@ class PullPackageFromPaddle implements ShouldQueue
|
|||||||
|
|
||||||
public function __construct(private readonly int $packageId) {}
|
public function __construct(private readonly int $packageId) {}
|
||||||
|
|
||||||
public function handle(PaddleCatalogService $catalog): void
|
public function handle(LemonSqueezyCatalogService $catalog): void
|
||||||
{
|
{
|
||||||
$package = Package::query()->find($this->packageId);
|
$package = Package::query()->find($this->packageId);
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ class PullPackageFromPaddle implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $package->paddle_product_id && ! $package->paddle_price_id) {
|
if (! $package->lemonsqueezy_product_id && ! $package->lemonsqueezy_variant_id) {
|
||||||
Log::channel('paddle-sync')->warning('Paddle pull skipped for package without linkage', [
|
Log::channel('lemonsqueezy-sync')->warning('Lemon Squeezy pull skipped for package without linkage', [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -39,41 +39,41 @@ class PullPackageFromPaddle implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$product = $package->paddle_product_id ? $catalog->fetchProduct($package->paddle_product_id) : null;
|
$product = $package->lemonsqueezy_product_id ? $catalog->fetchProduct($package->lemonsqueezy_product_id) : null;
|
||||||
$price = $package->paddle_price_id ? $catalog->fetchPrice($package->paddle_price_id) : null;
|
$price = $package->lemonsqueezy_variant_id ? $catalog->fetchPrice($package->lemonsqueezy_variant_id) : null;
|
||||||
|
|
||||||
$snapshot = $package->paddle_snapshot ?? [];
|
$snapshot = $package->lemonsqueezy_snapshot ?? [];
|
||||||
$snapshot['remote'] = array_filter([
|
$snapshot['remote'] = array_filter([
|
||||||
'product' => $product,
|
'product' => $product,
|
||||||
'price' => $price,
|
'price' => $price,
|
||||||
], static fn ($value) => $value !== null);
|
], static fn ($value) => $value !== null);
|
||||||
|
|
||||||
$package->forceFill([
|
$package->forceFill([
|
||||||
'paddle_sync_status' => 'pulled',
|
'lemonsqueezy_sync_status' => 'pulled',
|
||||||
'paddle_synced_at' => now(),
|
'lemonsqueezy_synced_at' => now(),
|
||||||
'paddle_snapshot' => $snapshot,
|
'lemonsqueezy_snapshot' => $snapshot,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Log::channel('paddle-sync')->info('Paddle package pull completed', [
|
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package pull completed', [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::channel('paddle-sync')->error('Paddle package pull failed', [
|
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package pull failed', [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'exception' => $exception,
|
'exception' => $exception,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$snapshot = $package->paddle_snapshot ?? [];
|
$snapshot = $package->lemonsqueezy_snapshot ?? [];
|
||||||
$snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [
|
$snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'class' => $exception::class,
|
'class' => $exception::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$package->forceFill([
|
$package->forceFill([
|
||||||
'paddle_sync_status' => 'pull-failed',
|
'lemonsqueezy_sync_status' => 'pull-failed',
|
||||||
'paddle_synced_at' => now(),
|
'lemonsqueezy_synced_at' => now(),
|
||||||
'paddle_snapshot' => $snapshot,
|
'lemonsqueezy_snapshot' => $snapshot,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
throw $exception;
|
throw $exception;
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Coupon;
|
use App\Models\Coupon;
|
||||||
use App\Services\Paddle\Exceptions\PaddleException;
|
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||||
use App\Services\Paddle\PaddleDiscountService;
|
use App\Services\LemonSqueezy\LemonSqueezyDiscountService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@@ -12,7 +12,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
|||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class SyncCouponToPaddle implements ShouldQueue
|
class SyncCouponToLemonSqueezy implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
@@ -24,16 +24,16 @@ class SyncCouponToPaddle implements ShouldQueue
|
|||||||
public bool $archive = false,
|
public bool $archive = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(PaddleDiscountService $discounts): void
|
public function handle(LemonSqueezyDiscountService $discounts): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if ($this->archive) {
|
if ($this->archive) {
|
||||||
$discounts->archiveDiscount($this->coupon);
|
$discounts->archiveDiscount($this->coupon);
|
||||||
|
|
||||||
$this->coupon->forceFill([
|
$this->coupon->forceFill([
|
||||||
'paddle_discount_id' => null,
|
'lemonsqueezy_discount_id' => null,
|
||||||
'paddle_snapshot' => null,
|
'lemonsqueezy_snapshot' => null,
|
||||||
'paddle_last_synced_at' => now(),
|
'lemonsqueezy_last_synced_at' => now(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -42,12 +42,12 @@ class SyncCouponToPaddle implements ShouldQueue
|
|||||||
$data = $discounts->updateDiscount($this->coupon);
|
$data = $discounts->updateDiscount($this->coupon);
|
||||||
|
|
||||||
$this->coupon->forceFill([
|
$this->coupon->forceFill([
|
||||||
'paddle_discount_id' => $data['id'] ?? $this->coupon->paddle_discount_id,
|
'lemonsqueezy_discount_id' => $data['id'] ?? $this->coupon->lemonsqueezy_discount_id,
|
||||||
'paddle_snapshot' => $data,
|
'lemonsqueezy_snapshot' => $data,
|
||||||
'paddle_last_synced_at' => now(),
|
'lemonsqueezy_last_synced_at' => now(),
|
||||||
])->save();
|
])->save();
|
||||||
} catch (PaddleException $exception) {
|
} catch (LemonSqueezyException $exception) {
|
||||||
Log::channel('paddle-sync')->error('Failed syncing coupon to Paddle', [
|
Log::channel('lemonsqueezy-sync')->error('Failed syncing coupon to Lemon Squeezy', [
|
||||||
'coupon_id' => $this->coupon->id,
|
'coupon_id' => $this->coupon->id,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'status' => $exception->status(),
|
'status' => $exception->status(),
|
||||||
@@ -55,7 +55,7 @@ class SyncCouponToPaddle implements ShouldQueue
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->coupon->forceFill([
|
$this->coupon->forceFill([
|
||||||
'paddle_snapshot' => [
|
'lemonsqueezy_snapshot' => [
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
'status' => $exception->status(),
|
'status' => $exception->status(),
|
||||||
'context' => $exception->context(),
|
'context' => $exception->context(),
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\PackageAddon;
|
use App\Models\PackageAddon;
|
||||||
use App\Services\Paddle\Exceptions\PaddleException;
|
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||||
use App\Services\Paddle\PaddleAddonCatalogService;
|
use App\Services\LemonSqueezy\LemonSqueezyAddonCatalogService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
|
|||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class SyncPackageAddonToPaddle implements ShouldQueue
|
class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
@@ -26,7 +26,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
|||||||
*/
|
*/
|
||||||
public function __construct(private readonly int $addonId, private readonly array $options = []) {}
|
public function __construct(private readonly int $addonId, private readonly array $options = []) {}
|
||||||
|
|
||||||
public function handle(PaddleAddonCatalogService $catalog): void
|
public function handle(LemonSqueezyAddonCatalogService $catalog): void
|
||||||
{
|
{
|
||||||
$addon = PackageAddon::query()->find($this->addonId);
|
$addon = PackageAddon::query()->find($this->addonId);
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
|||||||
$priceOverrides = Arr::get($this->options, 'price', []);
|
$priceOverrides = Arr::get($this->options, 'price', []);
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides);
|
$this->storeDryRunSnapshot($addon, $productOverrides, $priceOverrides);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -47,41 +47,41 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
|||||||
// Mark syncing (metadata)
|
// Mark syncing (metadata)
|
||||||
$addon->forceFill([
|
$addon->forceFill([
|
||||||
'metadata' => array_merge($addon->metadata ?? [], [
|
'metadata' => array_merge($addon->metadata ?? [], [
|
||||||
'paddle_sync_status' => 'syncing',
|
'lemonsqueezy_sync_status' => 'syncing',
|
||||||
'paddle_synced_at' => now()->toIso8601String(),
|
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||||
]),
|
]),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
||||||
|
|
||||||
$productResponse = $addon->metadata['paddle_product_id'] ?? null
|
$productResponse = $addon->metadata['lemonsqueezy_product_id'] ?? null
|
||||||
? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product'])
|
? $catalog->updateProduct($addon->metadata['lemonsqueezy_product_id'], $addon, $payloadOverrides['product'])
|
||||||
: $catalog->createProduct($addon, $payloadOverrides['product']);
|
: $catalog->createProduct($addon, $payloadOverrides['product']);
|
||||||
|
|
||||||
$productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null);
|
$productId = (string) ($productResponse['id'] ?? $addon->metadata['lemonsqueezy_product_id'] ?? null);
|
||||||
|
|
||||||
if (! $productId) {
|
if (! $productId) {
|
||||||
throw new PaddleException('Paddle product ID missing after addon sync.');
|
throw new LemonSqueezyException('Lemon Squeezy product ID missing after addon sync.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$priceResponse = $addon->price_id
|
$priceResponse = $addon->variant_id
|
||||||
? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
|
? $catalog->updatePrice($addon->variant_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
|
||||||
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
|
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
|
||||||
|
|
||||||
$priceId = (string) ($priceResponse['id'] ?? $addon->price_id);
|
$priceId = (string) ($priceResponse['id'] ?? $addon->variant_id);
|
||||||
|
|
||||||
if (! $priceId) {
|
if (! $priceId) {
|
||||||
throw new PaddleException('Paddle price ID missing after addon sync.');
|
throw new LemonSqueezyException('Lemon Squeezy variant ID missing after addon sync.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$addon->forceFill([
|
$addon->forceFill([
|
||||||
'price_id' => $priceId,
|
'variant_id' => $priceId,
|
||||||
'metadata' => array_merge($addon->metadata ?? [], [
|
'metadata' => array_merge($addon->metadata ?? [], [
|
||||||
'paddle_sync_status' => 'synced',
|
'lemonsqueezy_sync_status' => 'synced',
|
||||||
'paddle_synced_at' => now()->toIso8601String(),
|
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||||
'paddle_product_id' => $productId,
|
'lemonsqueezy_product_id' => $productId,
|
||||||
'paddle_snapshot' => [
|
'lemonsqueezy_snapshot' => [
|
||||||
'product' => $productResponse,
|
'product' => $productResponse,
|
||||||
'price' => $priceResponse,
|
'price' => $priceResponse,
|
||||||
'payload' => $payloadOverrides,
|
'payload' => $payloadOverrides,
|
||||||
@@ -89,7 +89,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
|||||||
]),
|
]),
|
||||||
])->save();
|
])->save();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::channel('paddle-sync')->error('Paddle addon sync failed', [
|
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy addon sync failed', [
|
||||||
'addon_id' => $addon->id,
|
'addon_id' => $addon->id,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'exception' => $exception,
|
'exception' => $exception,
|
||||||
@@ -97,9 +97,9 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
|||||||
|
|
||||||
$addon->forceFill([
|
$addon->forceFill([
|
||||||
'metadata' => array_merge($addon->metadata ?? [], [
|
'metadata' => array_merge($addon->metadata ?? [], [
|
||||||
'paddle_sync_status' => 'failed',
|
'lemonsqueezy_sync_status' => 'failed',
|
||||||
'paddle_synced_at' => now()->toIso8601String(),
|
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||||
'paddle_error' => [
|
'lemonsqueezy_error' => [
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'class' => $exception::class,
|
'class' => $exception::class,
|
||||||
],
|
],
|
||||||
@@ -145,22 +145,22 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
|||||||
* @param array<string, mixed> $productOverrides
|
* @param array<string, mixed> $productOverrides
|
||||||
* @param array<string, mixed> $priceOverrides
|
* @param array<string, mixed> $priceOverrides
|
||||||
*/
|
*/
|
||||||
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void
|
protected function storeDryRunSnapshot(PackageAddon $addon, array $productOverrides, array $priceOverrides): void
|
||||||
{
|
{
|
||||||
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
||||||
|
|
||||||
$addon->forceFill([
|
$addon->forceFill([
|
||||||
'metadata' => array_merge($addon->metadata ?? [], [
|
'metadata' => array_merge($addon->metadata ?? [], [
|
||||||
'paddle_sync_status' => 'dry-run',
|
'lemonsqueezy_sync_status' => 'dry-run',
|
||||||
'paddle_synced_at' => now()->toIso8601String(),
|
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||||
'paddle_snapshot' => [
|
'lemonsqueezy_snapshot' => [
|
||||||
'dry_run' => true,
|
'dry_run' => true,
|
||||||
'payload' => $payloadOverrides,
|
'payload' => $payloadOverrides,
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Log::channel('paddle-sync')->info('Paddle addon dry-run snapshot generated', [
|
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy addon dry-run snapshot generated', [
|
||||||
'addon_id' => $addon->id,
|
'addon_id' => $addon->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Services\Paddle\Exceptions\PaddleException;
|
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||||
use App\Services\Paddle\PaddleCatalogService;
|
use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
|
|||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class SyncPackageToPaddle implements ShouldQueue
|
class SyncPackageToLemonSqueezy implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
@@ -26,7 +26,7 @@ class SyncPackageToPaddle implements ShouldQueue
|
|||||||
*/
|
*/
|
||||||
public function __construct(private readonly int $packageId, private readonly array $options = []) {}
|
public function __construct(private readonly int $packageId, private readonly array $options = []) {}
|
||||||
|
|
||||||
public function handle(PaddleCatalogService $catalog): void
|
public function handle(LemonSqueezyCatalogService $catalog): void
|
||||||
{
|
{
|
||||||
$package = Package::query()->find($this->packageId);
|
$package = Package::query()->find($this->packageId);
|
||||||
|
|
||||||
@@ -45,37 +45,37 @@ class SyncPackageToPaddle implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
$package->forceFill([
|
$package->forceFill([
|
||||||
'paddle_sync_status' => 'syncing',
|
'lemonsqueezy_sync_status' => 'syncing',
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$productResponse = $package->paddle_product_id
|
$productResponse = $package->lemonsqueezy_product_id
|
||||||
? $catalog->updateProduct($package->paddle_product_id, $package, $productOverrides)
|
? $catalog->updateProduct($package->lemonsqueezy_product_id, $package, $productOverrides)
|
||||||
: $catalog->createProduct($package, $productOverrides);
|
: $catalog->createProduct($package, $productOverrides);
|
||||||
|
|
||||||
$productId = (string) ($productResponse['id'] ?? $package->paddle_product_id);
|
$productId = (string) ($productResponse['id'] ?? $package->lemonsqueezy_product_id);
|
||||||
|
|
||||||
if (! $productId) {
|
if (! $productId) {
|
||||||
throw new PaddleException('Paddle product ID missing after sync.');
|
throw new LemonSqueezyException('Lemon Squeezy product ID missing after sync.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$package->paddle_product_id = $productId;
|
$package->lemonsqueezy_product_id = $productId;
|
||||||
|
|
||||||
$priceResponse = $package->paddle_price_id
|
$priceResponse = $package->lemonsqueezy_variant_id
|
||||||
? $catalog->updatePrice($package->paddle_price_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
|
? $catalog->updatePrice($package->lemonsqueezy_variant_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
|
||||||
: $catalog->createPrice($package, $productId, $priceOverrides);
|
: $catalog->createPrice($package, $productId, $priceOverrides);
|
||||||
|
|
||||||
$priceId = (string) ($priceResponse['id'] ?? $package->paddle_price_id);
|
$priceId = (string) ($priceResponse['id'] ?? $package->lemonsqueezy_variant_id);
|
||||||
|
|
||||||
if (! $priceId) {
|
if (! $priceId) {
|
||||||
throw new PaddleException('Paddle price ID missing after sync.');
|
throw new LemonSqueezyException('Lemon Squeezy variant ID missing after sync.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$package->forceFill([
|
$package->forceFill([
|
||||||
'paddle_price_id' => $priceId,
|
'lemonsqueezy_variant_id' => $priceId,
|
||||||
'paddle_sync_status' => 'synced',
|
'lemonsqueezy_sync_status' => 'synced',
|
||||||
'paddle_synced_at' => now(),
|
'lemonsqueezy_synced_at' => now(),
|
||||||
'paddle_snapshot' => [
|
'lemonsqueezy_snapshot' => [
|
||||||
'product' => $productResponse,
|
'product' => $productResponse,
|
||||||
'price' => $priceResponse,
|
'price' => $priceResponse,
|
||||||
'payload' => [
|
'payload' => [
|
||||||
@@ -85,16 +85,16 @@ class SyncPackageToPaddle implements ShouldQueue
|
|||||||
],
|
],
|
||||||
])->save();
|
])->save();
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::channel('paddle-sync')->error('Paddle package sync failed', [
|
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package sync failed', [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'exception' => $exception,
|
'exception' => $exception,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$package->forceFill([
|
$package->forceFill([
|
||||||
'paddle_sync_status' => 'failed',
|
'lemonsqueezy_sync_status' => 'failed',
|
||||||
'paddle_synced_at' => now(),
|
'lemonsqueezy_synced_at' => now(),
|
||||||
'paddle_snapshot' => array_merge($package->paddle_snapshot ?? [], [
|
'lemonsqueezy_snapshot' => array_merge($package->lemonsqueezy_snapshot ?? [], [
|
||||||
'error' => [
|
'error' => [
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'class' => $exception::class,
|
'class' => $exception::class,
|
||||||
@@ -110,19 +110,19 @@ class SyncPackageToPaddle implements ShouldQueue
|
|||||||
* @param array<string, mixed> $productOverrides
|
* @param array<string, mixed> $productOverrides
|
||||||
* @param array<string, mixed> $priceOverrides
|
* @param array<string, mixed> $priceOverrides
|
||||||
*/
|
*/
|
||||||
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
|
protected function storeDryRunSnapshot(LemonSqueezyCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
|
||||||
{
|
{
|
||||||
$productPayload = $catalog->buildProductPayload($package, $productOverrides);
|
$productPayload = $catalog->buildProductPayload($package, $productOverrides);
|
||||||
$pricePayload = $catalog->buildPricePayload(
|
$pricePayload = $catalog->buildPricePayload(
|
||||||
$package,
|
$package,
|
||||||
$package->paddle_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
|
$package->lemonsqueezy_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
|
||||||
$priceOverrides
|
$priceOverrides
|
||||||
);
|
);
|
||||||
|
|
||||||
$package->forceFill([
|
$package->forceFill([
|
||||||
'paddle_sync_status' => 'dry-run',
|
'lemonsqueezy_sync_status' => 'dry-run',
|
||||||
'paddle_synced_at' => now(),
|
'lemonsqueezy_synced_at' => now(),
|
||||||
'paddle_snapshot' => [
|
'lemonsqueezy_snapshot' => [
|
||||||
'dry_run' => true,
|
'dry_run' => true,
|
||||||
'payload' => [
|
'payload' => [
|
||||||
'product' => $productPayload,
|
'product' => $productPayload,
|
||||||
@@ -131,7 +131,7 @@ class SyncPackageToPaddle implements ShouldQueue
|
|||||||
],
|
],
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
Log::channel('paddle-sync')->info('Paddle package dry-run snapshot generated', [
|
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package dry-run snapshot generated', [
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ class PurchaseConfirmation extends Mailable
|
|||||||
|
|
||||||
private function formattedTotal(): string
|
private function formattedTotal(): string
|
||||||
{
|
{
|
||||||
$totals = $this->purchase->metadata['paddle_totals'] ?? [];
|
$totals = $this->purchase->metadata['lemonsqueezy_totals'] ?? [];
|
||||||
$currency = $totals['currency']
|
$currency = $totals['currency']
|
||||||
?? $this->purchase->metadata['currency']
|
?? $this->purchase->metadata['currency']
|
||||||
?? $this->purchase->package?->currency
|
?? $this->purchase->package?->currency
|
||||||
@@ -113,7 +113,7 @@ class PurchaseConfirmation extends Mailable
|
|||||||
|
|
||||||
private function providerLabel(): string
|
private function providerLabel(): string
|
||||||
{
|
{
|
||||||
$provider = $this->purchase->provider ?? 'paddle';
|
$provider = $this->purchase->provider ?? 'lemonsqueezy';
|
||||||
$labelKey = 'emails.purchase.provider.'.$provider;
|
$labelKey = 'emails.purchase.provider.'.$provider;
|
||||||
$label = __($labelKey);
|
$label = __($labelKey);
|
||||||
|
|
||||||
|
|||||||
51
app/Models/AiEditOutput.php
Normal file
51
app/Models/AiEditOutput.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Models/AiEditRequest.php
Normal file
103
app/Models/AiEditRequest.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Models/AiEditingSetting.php
Normal file
63
app/Models/AiEditingSetting.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Models/AiProviderRun.php
Normal file
56
app/Models/AiProviderRun.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Models/AiStyle.php
Normal file
73
app/Models/AiStyle.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Models/AiUsageLedger.php
Normal file
60
app/Models/AiUsageLedger.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?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,7 +30,9 @@ class CheckoutSession extends Model
|
|||||||
|
|
||||||
public const PROVIDER_NONE = 'none';
|
public const PROVIDER_NONE = 'none';
|
||||||
|
|
||||||
public const PROVIDER_PADDLE = 'paddle';
|
public const PROVIDER_LEMONSQUEEZY = 'lemonsqueezy';
|
||||||
|
|
||||||
|
public const PROVIDER_PAYPAL = 'paypal';
|
||||||
|
|
||||||
public const PROVIDER_FREE = 'free';
|
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