Compare commits

..

295 Commits

Author SHA1 Message Date
Codex Agent
19425c0f62 Document dynamic security review checklists
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 12:27:15 +01:00
Codex Agent
1443ff0d3a Add marketing hreflang tests and docs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:52:44 +01:00
Codex Agent
e48ec3c564 Add storage checksum env defaults 2026-01-30 11:52:20 +01:00
Codex Agent
eeffe4c6f1 Add checksum validation for archived media
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:29:40 +01:00
Codex Agent
9a8305d986 Add Uptime Kuma monitoring template
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:12:15 +01:00
Codex Agent
6ca0b50403 Make queue health widget full width
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:25:21 +01:00
Codex Agent
ce7da1ff66 Read Dokploy environments for composes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:17:41 +01:00
Codex Agent
87f348462b Load Dokploy project details for compose data
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:00:53 +01:00
Codex Agent
dba0cd5882 Handle Dokploy project composes in widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 10:45:21 +01:00
Codex Agent
78af7838bf Use Dokploy projects in dashboard widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 10:40:10 +01:00
Codex Agent
b8bb7926c0 Expand support API contract coverage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 07:42:53 +01:00
Codex Agent
6e4656946c Expand support API integration tests and add load script
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:49:16 +01:00
Codex Agent
c94fbe4ab8 Require current password on profile password change
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:34:27 +01:00
Codex Agent
9ccf079a3a Register support API token widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:30:49 +01:00
Codex Agent
e0e9723b11 Add support API token management to profile
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:24:37 +01:00
Codex Agent
0d2759b0d4 Fix support API audit logging
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:02:25 +01:00
Codex Agent
f0e8cee850 Expand support API validation for writable resources
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 20:46:12 +01:00
Codex Agent
981df2ee45 Add support API validation rules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 19:42:28 +01:00
Codex Agent
6bc1d86009 Tighten support API resource mutations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 18:34:12 +01:00
Codex Agent
53a6500e6a Add support API scaffold
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 13:52:47 +01:00
Codex Agent
75c4dbd1f0 Add spacing between tabs and packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 15:53:00 +01:00
Codex Agent
5d48b804a5 Move packages tabs further up
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 15:50:41 +01:00
Codex Agent
80dca9fe67 Adjust packages tabs label and spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 15:47:52 +01:00
Codex Agent
78bd3c9267 Allow superadmin to bypass onboarding billing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 00:05:34 +01:00
Codex Agent
c4ac38e41a Relax style-src-elem to allow inline
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:41:53 +01:00
Codex Agent
84e253b61c Allow inline style tags and remove Bunny font
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:34:10 +01:00
Codex Agent
8414305ea3 Fix CSP style-src-elem allowlist
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:16:23 +01:00
Codex Agent
694ce218c9 Adjust packages tabs spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:59:12 +01:00
Codex Agent
ec98086e23 Refine packages hero and translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:55:14 +01:00
Codex Agent
d87d22fa22 Redesign marketing packages layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:30:03 +01:00
Codex Agent
a21321bb3c Allow inline style elements for event-admin CSP
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 21:16:31 +01:00
Codex Agent
7a91e40bb3 Allow inline style elements for event-admin CSP
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 21:02:33 +01:00
Codex Agent
71604c6e41 Fix CSP nonce timing for admin styles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 20:54:23 +01:00
Codex Agent
2b4d9e9411 Add CSP nonce for Tamagui styles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 20:38:36 +01:00
Codex Agent
35d8c94c11 Update Dokploy compose for prod/staging
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 11:12:53 +01:00
Codex Agent
ce43cac145 Fix foldable background layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 10:41:24 +01:00
Codex Agent
b11f010938 refactor(checkout): wrap auth step buttons in shadcn tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 09:50:06 +01:00
Codex Agent
e3b356e810 Enable foldable background presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 09:02:52 +01:00
Codex Agent
6bd75b0788 Add more invite background presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 22:54:54 +01:00
Codex Agent
14bb375674 Add from-disk rebuild for font manifest
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 21:46:09 +01:00
Codex Agent
a33bf0e3a4 Scope social login callbacks per flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:38:22 +01:00
Codex Agent
1241f5092e Remove Google helper badge in checkout auth
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:24:43 +01:00
Codex Agent
73728f6baf Add Facebook social login
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:19:15 +01:00
Codex Agent
db90b9af2e Fix pagination totals for zero counts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:33:18 +01:00
Codex Agent
7dd8bc4c91 Fix tenant admin Google OAuth redirect
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:25:12 +01:00
Codex Agent
ee6fb7a5bb Fix event naming and checklist labels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:13:10 +01:00
Codex Agent
1c4c93c547 Simplify guest language selector
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 16:27:48 +01:00
Codex Agent
bdb1789a10 Add guest analytics consent nudge
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 16:20:14 +01:00
Codex Agent
4bf0d5052c Add honeypot protection to contact forms
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 15:38:34 +01:00
Codex Agent
d629b745c4 Add Google login to checkout login form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 14:17:12 +01:00
Codex Agent
72dd1409e8 Add Google login to mobile admin PWA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:59:14 +01:00
Codex Agent
2729c3c713 Add spacing around KPI separator
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:40:46 +01:00
Codex Agent
4135deb110 Update dashboard live show KPI label (DE)
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:34:00 +01:00
Codex Agent
fda97b3c05 Update dashboard KPIs for live show and auto-approval
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:31:50 +01:00
Codex Agent
55608c311d Add dashboard action colors and admin help translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:14:33 +01:00
Codex Agent
ead80025fc Update admin theme palette and heading font
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 12:36:57 +01:00
Codex Agent
d000d9b456 Record bd issue activity
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 12:25:09 +01:00
Codex Agent
ebfcc090d6 Fix admin PWA status badge contrast 2026-01-23 12:24:09 +01:00
Codex Agent
49c4f9ad7d Tweak German admin copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 11:10:32 +01:00
Codex Agent
0089a14204 Replace KPI/tenant wording in admin UI and help
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:55:24 +01:00
Codex Agent
0eb3b85f06 Add tenant PWA help articles and links
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:29:20 +01:00
Codex Agent
db0fdc58a1 Ensure help lists render as lists
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:13:39 +01:00
Codex Agent
0db0ddf3c4 Add related help titles and fix umlauts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:05:29 +01:00
Codex Agent
df5e8204fa Add admin FAQ help article
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:53:10 +01:00
Codex Agent
6f7bf818dd Add control room help article and move ops docs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:47:58 +01:00
Codex Agent
b3ea522e31 Remove ops-only help articles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:42:10 +01:00
Codex Agent
b267ae2c15 Refresh admin help articles for event PWA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:35:26 +01:00
Codex Agent
4706b21d22 Acknowledge bd 0.49 upgrade
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:26:26 +01:00
Codex Agent
96e65ffc0b Acknowledge bd version
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:22:47 +01:00
Codex Agent
348834250a Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:21:00 +01:00
Codex Agent
31a5148263 Update help system issue status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:20:07 +01:00
Codex Agent
35f28fd48d Add contextual help links to admin pages 2026-01-23 09:18:46 +01:00
Codex Agent
53a90fec33 Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 08:56:44 +01:00
Codex Agent
e1a2850768 Add admin help center entry points 2026-01-23 08:55:37 +01:00
Codex Agent
03ee16bb87 Read admin theme colors from CSS vars
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:31:18 +01:00
Codex Agent
1313135020 Fix admin theme dark fallbacks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:14:18 +01:00
Codex Agent
85f2c42fc5 Improve admin mobile dark mode contrast
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:02:45 +01:00
Codex Agent
6318aec3cb Replace checklist badge with check icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:26:24 +01:00
Codex Agent
056d864f80 Compact tasks toggle and title
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:15:55 +01:00
Codex Agent
ef88342bd0 Polish tasks hero and dialog
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:10:21 +01:00
Codex Agent
d76b26b7ad Style collection import CTA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:03:41 +01:00
Codex Agent
c1dfbaa51e Tighten tasks tab controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:59:25 +01:00
Codex Agent
32644eb41e Restructure event tasks layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:50:38 +01:00
Codex Agent
db5fea9f2a Refactor event tasks tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:33:02 +01:00
Codex Agent
fba9714ede Switch tasks quick nav to tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 19:48:22 +01:00
Codex Agent
cebc1d1ec5 Simplify hero toggles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:34:42 +01:00
Codex Agent
5aa79b587d Add hero quick settings toggles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:30:51 +01:00
Codex Agent
2e089f7f77 Unify setup status block
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:17:10 +01:00
Codex Agent
fd52f8e13d Tighten KPI card layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:05:42 +01:00
Codex Agent
8ac38cf264 Adjust KPI strip layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:01:54 +01:00
Codex Agent
66193a6461 Compact dashboard overview
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:51:05 +01:00
Codex Agent
64c9d7357a Embed quick actions header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:31:46 +01:00
Codex Agent
8aa2efdd9a Refine dashboard overview layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:24:48 +01:00
Codex Agent
4f3503e3f4 Refactor mobile dashboard layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:13:22 +01:00
Codex Agent
4235eda49a Fix guest PWA dark mode contrast
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:47:26 +01:00
Codex Agent
ad0e8b7923 Allow longer blog post excerpts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:10:50 +01:00
Codex Agent
446eb15c6b Fix blog post image upload storage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:03:05 +01:00
Codex Agent
02a24877f7 chore: shift blog post published_at dates 3 weeks into the future
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:47:08 +01:00
Codex Agent
f016004b2b Update gallery retention copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:26:20 +01:00
Codex Agent
a0248d976b Refine photobooth timeline label and video note
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:23:53 +01:00
Codex Agent
99a880854a Adjust photobooth timeline step
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:16:52 +01:00
Codex Agent
a3747138a4 Expand photobooth info on how-it-works
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:09:20 +01:00
Codex Agent
287cc8a532 Refine photobooth wording and add FAQ
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:00:21 +01:00
Codex Agent
191f39cf5b Add photobooth connect marketing copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:54:29 +01:00
Codex Agent
543b3015ca UI: Change PWA header icon backgrounds to primary color
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:37:33 +01:00
Codex Agent
9d3c866562 Fix: Add missing 'text' variable to EventControlRoomPage theme destructuring
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:35:09 +01:00
Codex Agent
911880f1a0 Refactor: Update Tenant PWA headers and tabs to use Playfair Display and Tamagui components
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:29:56 +01:00
Codex Agent
b9d91c8f40 Improve marketing language switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 09:07:46 +01:00
Codex Agent
23193a3452 Adjust package CTA split and label
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 08:48:33 +01:00
Codex Agent
da6f95aead Add order CTA links on packages overview
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 22:07:46 +01:00
Codex Agent
2f9a700e00 Fix guest demo UX and enforce guest limits
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 21:35:40 +01:00
Codex Agent
50cc4e76df Add marketing motion reveals to blog and occasions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 15:22:39 +01:00
Codex Agent
941931934f Update beads issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 12:55:55 +01:00
Codex Agent
9b245e9c51 Update marketing packages testimonials and demo 2026-01-21 12:48:34 +01:00
Codex Agent
b9708d5174 Enhance Event admin UI and fix translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Remove default_locale and primary_join_token columns from event list
- Add read-only join link field to event edit form
- Add missing translations for used/remaining photos and join link
- Fix array-to-string conversion error in join link modal
2026-01-21 11:20:22 +01:00
Codex Agent
a038594130 Widen marketing demo frame
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:58:48 +01:00
Codex Agent
9bab5f6c89 Use marketing demo flag for demo page
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:55:21 +01:00
Codex Agent
ebab856137 Fix event package display and add missing translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Correct Event->eventPackage relationship to HasOne
- Add missing translations for event packages and table columns
2026-01-21 10:50:51 +01:00
Codex Agent
fa33e7cbcf Fix Event & EventType resource issues and apply formatting
- Fix EventType deletion error handling (constraint violations)
- Fix Event update error (package_id column missing)
- Fix Event Type dropdown options (JSON display issue)
- Fix EventPackagesRelationManager query error
- Add missing translations for deletion errors
- Apply Pint formatting
2026-01-21 10:34:06 +01:00
Codex Agent
198fbf6751 Hide add FAB at task limit
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:30:12 +01:00
Codex Agent
246e54f970 Update task mode UI details
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:19:34 +01:00
Codex Agent
1c5412e82c Enforce task limits and update event form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 09:49:30 +01:00
Codex Agent
0b1430e64d Refine control room filter pill styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:43:03 +01:00
Codex Agent
52c2aa0e9b Update control room filter pill styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:20:54 +01:00
Codex Agent
dd459aa381 Replace control room filters with count bar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:12:29 +01:00
Codex Agent
02ec14a0d3 Collapse upload settings by default
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:01:33 +01:00
Codex Agent
e490f9995c Refine control room upload settings UI defaults
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 15:59:11 +01:00
Codex Agent
5e5b69f655 Add control room automations and uploader overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 15:49:04 +01:00
Codex Agent
e5e74febbd Shrink control room photo actions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 14:02:49 +01:00
Codex Agent
5674ed99f1 Add compact control room photo grid
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:53:53 +01:00
Codex Agent
6ab24e65a1 Refine event status filter styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:29:43 +01:00
Codex Agent
d7ba1880dc Integrate status filters into event list
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:25:21 +01:00
Codex Agent
9d8f01d294 Refresh event overview list UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:21:39 +01:00
Codex Agent
f88aa40315 Clarify watermark features across packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:10:49 +01:00
Codex Agent
cb5d5a2870 Gate event create FAB by package quota
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 12:54:16 +01:00
Codex Agent
e28eb9a90b Fix event search filtering
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 12:41:14 +01:00
Codex Agent
3c2ebdbc0e Fix sticky tasks toolbar layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 11:37:20 +01:00
Codex Agent
a916bf8c4d Compact tasks hero and harden sticky toolbar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 10:59:58 +01:00
Codex Agent
7a71efedd1 Fix sticky task search bar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 09:04:48 +01:00
Codex Agent
e1221e0466 Clarify photo task wording in admin UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 08:49:34 +01:00
Codex Agent
508c8201fa Update photo task labels and filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 08:30:40 +01:00
Codex Agent
750acb0bec Allow task attach search across global tasks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:42:09 +01:00
Codex Agent
42f6178b6d Fix task collection attach relation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:33:38 +01:00
Codex Agent
802e360c8e Use full pages for task collections
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:26:30 +01:00
Codex Agent
7030e8b5b9 Add superadmin task collections resource
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:19:37 +01:00
Codex Agent
b61507ea04 Hochzeitsaufgaben auf 44 reduziert und Spezialthemenpakete vorbereitet. 2026-01-19 19:45:48 +01:00
Codex Agent
dfaf21898a chore: sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 18:50:51 +01:00
Codex Agent
fbd48afbd6 feat: add task multi-select on long-press
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 18:49:40 +01:00
Codex Agent
6f6d8901ec Route /api requests to Laravel in nginx
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 14:27:31 +01:00
Codex Agent
d4ab9a3a20 Adjust watermark permissions and transparency
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 13:45:43 +01:00
Codex Agent
fbff2afa3e Update admin PWA events, branding, and packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 11:35:38 +01:00
Codex Agent
926bc7d070 feat(admin-pwa): add floating action button to event form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:33:09 +01:00
Codex Agent
f1f552ad2d fix(admin-pwa): fix location saving and dashboard refresh delay
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:14:42 +01:00
Codex Agent
4219daba25 feat(admin-pwa): modernize dashboard KPI section with unified glass strip
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:02:04 +01:00
Codex Agent
1e821a2fb4 refactor(dashboard): refine setup checklist UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Removed progress bar from hero for cleaner look
- Made setup checklist collapsible (auto-collapsed when complete)
- Improved checklist item styling with active/inactive states
2026-01-18 10:08:39 +01:00
Codex Agent
48d4716ab1 feat(dashboard): implement transparent setup roadmap and fix translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Added SetupChecklist component for clear progress visualization
- Refactored LifecycleHero to show readiness state
- Fixed remaining untranslated keys in tool grid and readiness hook
2026-01-18 10:02:59 +01:00
Codex Agent
45f0cea264 feat(mobile): implement event switcher sheet in header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Replaced direct navigation with a bottom sheet for event switching
- Created reusable EventSwitcherSheet component
- Preserves context when switching events
2026-01-17 19:17:19 +01:00
Codex Agent
9d7990fe71 fix(dashboard): correct translation keys for tasks, settings, analytics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated grid item labels to use valid i18next keys
- Ensured consistent German localization for all dashboard widgets
2026-01-17 18:29:01 +01:00
Codex Agent
0c5939e541 fix(dashboard): resolve missing translations and refine alert styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated useEventReadiness hook to use 'Bearbeiten' instead of untranslated string
- Fixed 'guestsBlocked' literal appearing in alerts by passing translator correctly
- Refined limit warning styles to respect danger tone
- Localized pulse strip labels (Fotos, Gäste) properly
2026-01-17 18:06:14 +01:00
Codex Agent
e7e095cec9 fix(theme): correct text color mapping for light/dark modes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated useAdminTheme to derive muted/subtle colors from theme.color using alpha
- Fixed issue where muted text was invisible in light mode
- Updated global gradients to match new Slate palette
2026-01-17 16:39:22 +01:00
Codex Agent
d905ba8e6c fix(admin): refine dashboard translations and label mapping
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Mapped 'Photobooth' and 'Guests' grid items to correct translation keys
- Localized pulse strip labels (Fotos, Gäste)
- Updated readiness hook to use translated CTAs
2026-01-17 16:35:30 +01:00
Codex Agent
40bed1e44e feat(admin): modernize tenant admin PWA with cockpit layout and slate theme
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Replaced rainbow grid with phase-aware cockpit layout
- Implemented smart lifecycle hero with readiness logic
- Introduced dark command bar header with context pill and search placeholder
- Updated global Tamagui theme to slate/indigo palette
- Refined bottom navigation with minimalist spotlight style
2026-01-17 14:46:19 +01:00
Codex Agent
7e77dd2931 Refresh mobile dashboard and header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 22:06:41 +01:00
Codex Agent
b316beb522 Allow partial event updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 15:12:03 +01:00
Codex Agent
6d3f4f36e8 Update tasks toggle copy 2026-01-16 15:06:48 +01:00
Codex Agent
9e4ea3dafb Add tasks toggle card 2026-01-16 14:58:24 +01:00
Codex Agent
1517eb8631 Add tasks setup nudge and prompt
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:41:09 +01:00
Codex Agent
9a4ece33bf Refresh event list after create
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:25:52 +01:00
Codex Agent
30c653913d Show endcustomer event allowance on dashboard
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:17:27 +01:00
Codex Agent
4c37f874bd Preserve null remaining_events in package normalization
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:11:44 +01:00
Codex Agent
05fdda811b Avoid billing redirect for endcustomer packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:05:20 +01:00
Codex Agent
eeeca0eed5 Show event-per-purchase for endcustomer packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:00:12 +01:00
Codex Agent
fa6a5678f0 Set starter event quota in package seeder
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:56:03 +01:00
Codex Agent
63956087a4 Fix demo starter package seeding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:52:32 +01:00
Codex Agent
a3f153de6f Allow dashboard access with active package
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:44:58 +01:00
Codex Agent
8d729c6a86 Fix dashboard empty state permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:39:27 +01:00
Codex Agent
7ad43a3661 Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:36:29 +01:00
Codex Agent
7aa0a4c847 Enforce tenant member permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:33:36 +01:00
Codex Agent
df60be826d Sync beads issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 12:15:38 +01:00
Codex Agent
918bff08aa Fix auth translations and admin PWA UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 12:14:53 +01:00
Codex Agent
292c8f0b26 Refine admin PWA layout and tamagui usage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 22:24:10 +01:00
Codex Agent
11018f273d chore: sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 19:54:53 +01:00
Codex Agent
7e32d8f706 feat: update package copy and admin control room 2026-01-15 19:54:04 +01:00
Codex Agent
ad829ae509 Update partner packages, copy, and demo switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 17:33:36 +01:00
Codex Agent
2f93271d94 Route billing upgrade CTA to package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:28:18 +01:00
Codex Agent
62255dc9e7 Add missing branding watermark translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:21:17 +01:00
Codex Agent
738659112d Add upgrade CTAs for branding and watermarks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:17:05 +01:00
Codex Agent
89d9b656de Add watermark tier labels to marketing translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:58:02 +01:00
Codex Agent
5d0ae0faa5 Customize watermark labels in package comparison
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:54:47 +01:00
Codex Agent
2ecd417b55 Enable watermarks for premium package
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:48:44 +01:00
Codex Agent
3755213010 Align demo seed branding defaults
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:38:24 +01:00
Codex Agent
9cb236f123 Update default branding palette for tenants and guests
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:32:51 +01:00
Codex Agent
10232cf40e Adjust default branding accent color
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:23:34 +01:00
Codex Agent
3ce6507268 Collapse branding controls on default mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:17:06 +01:00
Codex Agent
a39295a0f0 Fix branding translations in locale overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:06:49 +01:00
Codex Agent
5dc69fb187 Adopt Tamagui sliders in admin
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:01:43 +01:00
Codex Agent
92b341bdcd Use Tamagui slider for branding controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:58:09 +01:00
Codex Agent
725a7a29b3 Refine branding labels and access checks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:51:06 +01:00
Codex Agent
8634d16359 Expand branding controls and logo upload
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:42:20 +01:00
Codex Agent
81446b37c3 Wire guest branding theme 2026-01-15 08:06:21 +01:00
Codex Agent
33e46b448d Match gallery preview filters and tiles to gallery
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 16:07:29 +01:00
Codex Agent
289ef70e53 Remove gallery route padding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:58:40 +01:00
Codex Agent
d0559bf8c9 Align gallery layout with achievements structure
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:50:48 +01:00
Codex Agent
0ef4b32bf6 Match gallery layout to achievements spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:44:32 +01:00
Codex Agent
3612c97e86 Tighten gallery spacing and add filter dividers
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:08:28 +01:00
Codex Agent
c0510581c6 Tighten gallery filters and badge placement
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 14:04:31 +01:00
Codex Agent
1ffd3e3b9d Fix gallery section closing tag
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:56:00 +01:00
Codex Agent
e05ee3b186 Unify gallery header and grid section
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:53:35 +01:00
Codex Agent
cf7b2e563a Unify gallery layout and reduce image overlays
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 12:40:55 +01:00
Codex Agent
719afb6920 Refresh gallery layout and tile styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:48:40 +01:00
Codex Agent
83c58358a1 Show photobooth filter only when enabled
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:45:29 +01:00
Codex Agent
2b888078a0 Modernize gallery UI and fix nav motion
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:42:12 +01:00
Codex Agent
2f584162d6 Avoid hidden gallery content on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:36:02 +01:00
Codex Agent
0833ea6b36 Skip hidden initial motion on achievements tab nav
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:32:54 +01:00
Codex Agent
5bdc15d399 Tune guest route transition animations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:30:03 +01:00
Codex Agent
693540f609 Avoid task page hidden animation on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:25:43 +01:00
Codex Agent
c0193c9581 Deduplicate guest tasks list and restore header icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:17:35 +01:00
Codex Agent
03c7b20cae Improve guest help routing and loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 09:00:12 +01:00
Codex Agent
3a78c4f2c0 Ensure help sync creates cache directory
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 08:17:49 +01:00
Codex Agent
fa333deed9 Ensure storage subdirs exist on boot 2026-01-13 22:49:47 +01:00
Codex Agent
a733df6221 Add symfony/yaml for help sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:38:09 +01:00
Codex Agent
5ee1baa7e2 Fix forwarded host/port for signed URLs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:30:10 +01:00
Codex Agent
2f19752199 chore: sync beads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:33:26 +01:00
Codex Agent
7dd7ec14a4 chore: sync beads 2026-01-13 21:32:39 +01:00
Codex Agent
d9568be579 Fix proxy headers and help sync boot
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:31:46 +01:00
Codex Agent
9cf6e9d94d Add photobooth email translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:40:00 +01:00
Codex Agent
a23ce0c86f Set locale on photobooth mail
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:24:29 +01:00
Codex Agent
9efea136bd Normalize photobooth mail locale
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:37:26 +01:00
Codex Agent
7a6f489b8b Add tenant admin account edit page
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:09:25 +01:00
Codex Agent
cc11e024f0 Add photobooth folder presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 12:00:39 +01:00
Codex Agent
2089251a92 Extend uploader profiles, filters, and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:26:04 +01:00
Codex Agent
53094b8d36 Add filters, throttling, and connection test
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:15:57 +01:00
Codex Agent
0c33c1ddc1 Persist upload queue and uploaded cache
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:12:26 +01:00
Codex Agent
ce0b7c951a Update beads issues for uploader epic
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:08:39 +01:00
Codex Agent
fbbbbdac4c Add upload retries and richer errors 2026-01-13 11:08:26 +01:00
Codex Agent
94d0713ec0 Add manual uploader credentials fields
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:56:33 +01:00
Codex Agent
3e36354916 Restructure photobooth page flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:52:50 +01:00
Codex Agent
24a1319cc2 Add photobooth uploader download email
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 09:59:39 +01:00
Codex Agent
b1250c6246 Collapse photobooth credentials
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:59:24 +01:00
Codex Agent
fd7a3c846a Add uploader downloads for Windows macOS Linux
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:49:08 +01:00
Codex Agent
1ca7545f86 Add photobooth uploader build service
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:37:26 +01:00
Codex Agent
9f4a202d2b Add Windows app icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:12:11 +01:00
Codex Agent
fe0525e678 Fix uploader header layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:26:17 +01:00
Codex Agent
d62efdb55c Refresh uploader UI styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:15:55 +01:00
Codex Agent
be722f6e37 Remember uploader window size
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:03:27 +01:00
Codex Agent
898ac9ff0e Add uploader advanced settings and live status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:50:39 +01:00
Codex Agent
c8d1ac7971 Improve uploader client connection and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:40:40 +01:00
Codex Agent
3ee23f3a66 Add uploader branding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:28:49 +01:00
Codex Agent
993c351832 Remove response format from uploader UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:22:45 +01:00
Codex Agent
2444a62a4d Show connect code expiry time
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:14:42 +01:00
Codex Agent
e52720a3cb Rename photobooth upload endpoint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:05:09 +01:00
Codex Agent
93bed358ba Remove sparkbooth option from photobooth UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 19:50:30 +01:00
Codex Agent
a16bd9c498 Relabel photobooth uploader mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 18:46:41 +01:00
Codex Agent
e32b1fa45a Add photobooth connect code UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:59:35 +01:00
Codex Agent
6edc890e01 Configure beads sync branch and ignore artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:46:39 +01:00
Codex Agent
e4100f7800 Polish uploader UI and queue handling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:35:05 +01:00
Codex Agent
7786e3d134 Switch photobooth uploader to Avalonia
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:26:45 +01:00
Codex Agent
30f3d148bb bd sync: 2026-01-12 17:24:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:24:05 +01:00
Codex Agent
1970c259ed Restore photobooth uploader files after sync 2026-01-12 17:23:34 +01:00
Codex Agent
dc5c80cda4 bd sync: 2026-01-12 17:21:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:21:16 +01:00
Codex Agent
75a9bcee12 Migrate photobooth uploader to Avalonia 2026-01-12 17:20:35 +01:00
Codex Agent
6fe363640f Reapply photobooth uploader changes after sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:47 +01:00
Codex Agent
3df0542013 bd sync: 2026-01-12 17:10:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:05 +01:00
Codex Agent
4f4a527010 Reapply photobooth uploader changes 2026-01-12 17:09:37 +01:00
Codex Agent
e69c94ad20 bd sync: 2026-01-12 17:07:55
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:07:55 +01:00
Codex Agent
5afa96251b Fix WinUI build settings for linux tooling 2026-01-12 17:07:28 +01:00
Codex Agent
24f053d4c4 Add photobooth connect codes and uploader pipeline
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:50 +01:00
Codex Agent
ec360ed860 bd sync: 2026-01-12 17:02:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:15 +01:00
Codex Agent
83e78d7c66 Update backend photobooth connect API 2026-01-12 16:59:49 +01:00
Codex Agent
9b1c5bf978 bd sync: 2026-01-12 16:57:37
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 16:57:37 +01:00
Codex Agent
fb23a0a2f3 Add photobooth connect codes and uploader scaffold 2026-01-12 16:56:51 +01:00
Codex Agent
2287e7f32c Fix tenant photo moderation and guest updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 14:35:57 +01:00
Codex Agent
cceed361b7 feat: add checkout action banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:35:43 +01:00
Codex Agent
02363792c8 feat: poll checkout status and show failures
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:31:30 +01:00
Codex Agent
e93a00f0fc fix: block non-upgrade package selection
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:40:18 +01:00
Codex Agent
c1be7dd1ef fix: add package feature labels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:32:43 +01:00
Codex Agent
f01a0e823b fix: handle array package features
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:29:19 +01:00
Codex Agent
915aede66e feat: add package comparison view
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:25:35 +01:00
Codex Agent
b854e3feaa Show billing activation banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:07:37 +01:00
Codex Agent
4bcaef53f7 Redirect checkout to billing with status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:49:10 +01:00
Codex Agent
8f1d3a3eb6 Disallow downgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:45:12 +01:00
Codex Agent
ab2cf3e023 Highlight upgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:38:16 +01:00
Codex Agent
ce0ab269c9 Cap analytics timeframe label
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:30:00 +01:00
Codex Agent
dce24bb86a Compute analytics timeframe dynamically
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:12:50 +01:00
Codex Agent
03bf178d61 Enhance analytics snapshot and empty states
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:07:23 +01:00
Codex Agent
8ebaf6c31d Refine analytics page and i18n
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:03:55 +01:00
Codex Agent
1b6dc63ec6 Clamp package summary remaining counts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:50:35 +01:00
Codex Agent
accc63f4a2 Add pending test files
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:46:18 +01:00
Codex Agent
59e318e7b9 Ignore beads sync artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:42:58 +01:00
Codex Agent
3de1d3deab Misc unrelated updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:31:31 +01:00
Codex Agent
e9afbeb028 Unify admin home with event overview 2026-01-12 10:31:05 +01:00
Codex Agent
3e2b63f71f Paddle Coupon Sync prüft nun zuerst, ob der Discount schon existiert. 2026-01-08 13:36:58 +01:00
Codex Agent
cff014ede5 fix(i18n): restore missing translations and enable Suspense loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 20:50:09 +01:00
Codex Agent
8c5d3b93d5 feat: improve mobile navigation with tap-to-reset and history filtering
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 15:14:31 +01:00
Codex Agent
22cb7ed7ce fix: resolve typescript and build errors across admin and guest apps
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 13:25:30 +01:00
818 changed files with 50361 additions and 92043 deletions

1
.beads/last-touched Normal file
View File

@@ -0,0 +1 @@
fotospiel-app-vc3

View File

@@ -117,22 +117,14 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_SANDBOX=true
# Lemon Squeezy Billing
LEMONSQUEEZY_STORE_ID=284860
LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_WEBHOOK_SECRET=
LEMONSQUEEZY_WEBHOOK_EVENTS=
LEMONSQUEEZY_TEST_MODE=false
LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
LEMONSQUEEZY_GIFT_VARIANT_STARTER=
LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD=
LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP=
# Paddle Billing
PADDLE_SANDBOX=true
PADDLE_API_KEY=
PADDLE_CLIENT_ID=
PADDLE_WEBHOOK_SECRET=
PADDLE_PUBLIC_KEY=
PADDLE_BASE_URL=
PADDLE_CONSOLE_URL=
# Sanctum / SPA auth
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

139
AGENTS.md
View File

@@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Lemon Squeezy API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
- Payment Systems: Lemon Squeezy (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
## Repo Structure (high-level)
@@ -38,9 +38,6 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
- resources/js/pages/ — Inertia pages (React).
- docs/archive/README.md — historical PRP context.
- Marketing frontend language files:
- Source translations: `resources/lang/{de,en}/marketing.php` and `resources/lang/{de,en}/marketing.json`.
- Runtime i18next JSON served to the frontend: `public/lang/{de,en}/marketing.json` (must stay in sync with the source files).
## Standard Workflows
- Coding tasks (Codegen Agent):
@@ -61,7 +58,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
#### Billing & Packages
- package:check-status — check event package status.
- packages:migrate-legacy — migrate legacy package purchases.
- lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run).
- coupons:export — export coupon redemptions.
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
@@ -96,7 +93,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
- inspire — inspiring quote (routes/console.php).
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
## PWA Architecture
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
@@ -623,134 +620,6 @@ export default () => (
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== filament/filament rules ===
## Filament
- Filament is used by this application. Follow existing conventions for how and where it's implemented.
- Filament is a Server-Driven UI (SDUI) framework for Laravel that lets you define user interfaces in PHP using structured configuration objects. Built on Livewire, Alpine.js, and Tailwind CSS.
- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices.
### Artisan
- Use Filament-specific Artisan commands to create files. Find them with `list-artisan-commands` or `php artisan --help`.
- Inspect required options and always pass `--no-interaction`.
### Patterns
Use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values.
Use `Get $get` to read other form field values for conditional logic:
<code-snippet name="Conditional form field" lang="php">
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Utilities\Get;
Select::make('type')
->options(CompanyType::class)
->required()
->live(),
TextInput::make('company_name')
->required()
->visible(fn (Get $get): bool => $get('type') === 'business'),
</code-snippet>
Use `state()` with a `Closure` to compute derived column values:
<code-snippet name="Computed table column" lang="php">
use Filament\Tables\Columns\TextColumn;
TextColumn::make('full_name')
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
</code-snippet>
Actions encapsulate a button with optional modal form and logic:
<code-snippet name="Action with modal form" lang="php">
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
Action::make('updateEmail')
->form([
TextInput::make('email')->email()->required(),
])
->action(fn (array $data, User $record): void => $record->update($data)),
</code-snippet>
### Testing
Authenticate before testing panel functionality. Filament uses Livewire, so use `livewire()` or `Livewire::test()`:
<code-snippet name="Filament Table Test" lang="php">
livewire(ListUsers::class)
->assertCanSeeTableRecords($users)
->searchTable($users->first()->name)
->assertCanSeeTableRecords($users->take(1))
->assertCanNotSeeTableRecords($users->skip(1));
</code-snippet>
<code-snippet name="Filament Create Resource Test" lang="php">
livewire(CreateUser::class)
->fillForm([
'name' => 'Test',
'email' => 'test@example.com',
])
->call('create')
->assertNotified()
->assertRedirect();
assertDatabaseHas(User::class, [
'name' => 'Test',
'email' => 'test@example.com',
]);
</code-snippet>
<code-snippet name="Testing Validation" lang="php">
livewire(CreateUser::class)
->fillForm([
'name' => null,
'email' => 'invalid-email',
])
->call('create')
->assertHasFormErrors([
'name' => 'required',
'email' => 'email',
])
->assertNotNotified();
</code-snippet>
<code-snippet name="Calling Actions" lang="php">
use Filament\Actions\DeleteAction;
use Filament\Actions\Testing\TestAction;
livewire(EditUser::class, ['record' => $user->id])
->callAction(DeleteAction::class)
->assertNotified()
->assertRedirect();
livewire(ListUsers::class)
->callAction(TestAction::make('promote')->table($user), [
'role' => 'admin',
])
->assertNotified();
</code-snippet>
### Common Mistakes
**Commonly Incorrect Namespaces:**
- Form fields (TextInput, Select, etc.): `Filament\Forms\Components\`
- Infolist entries (for read-only views) (TextEntry, IconEntry, etc.): `Filament\Forms\Components\`
- Layout components (Grid, Section, Fieldset, Tabs, Wizard, etc.): `Filament\Schemas\Components\`
- Schema utilities (Get, Set, etc.): `Filament\Schemas\Components\Utilities\`
- Actions: `Filament\Actions\` (no `Filament\Tables\Actions\` etc.)
- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`)
**Recent breaking changes to Filament:**
- File visibility is `private` by default. Use `->visibility('public')` for public access.
- `Grid`, `Section`, and `Fieldset` no longer span all columns by default.
</laravel-boost-guidelines>
## Issue Tracking

View File

@@ -100,8 +100,6 @@ COPY . .
COPY --from=vendor /var/www/html/vendor ./vendor
COPY --from=node_builder /var/www/html/public/build ./public/build
RUN php artisan vendor:publish --tag=livewire:assets --force --no-interaction
RUN php artisan config:clear \
&& php artisan config:cache \
&& php artisan route:clear \

1
GEMINI.md Symbolic link
View File

@@ -0,0 +1 @@
/mnt/c/wwwroot/fotospiel-app/AGENTS.md

View File

@@ -1,144 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditOutputStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class AiEditsBackfillStorageCommand extends Command
{
protected $signature = 'ai-edits:backfill-storage
{--request-id= : Restrict backfill to one AI edit request id}
{--limit=200 : Maximum outputs to process}
{--pretend : Dry run without writing changes}';
protected $description = 'Backfill local storage paths for AI outputs that only have provider URLs.';
public function __construct(private readonly AiEditOutputStorageService $outputStorage)
{
parent::__construct();
}
public function handle(): int
{
$limit = max(1, (int) $this->option('limit'));
$requestId = $this->normalizeRequestId($this->option('request-id'));
$pretend = (bool) $this->option('pretend');
$query = AiEditOutput::query()
->with('request')
->whereNotNull('provider_url')
->where(function (Builder $builder): void {
$builder
->whereNull('storage_path')
->orWhere('storage_path', '');
})
->orderBy('id');
if ($requestId !== null) {
$query->where('request_id', $requestId);
}
$candidateCount = (clone $query)->count();
$outputs = $query->limit($limit)->get();
if ($outputs->isEmpty()) {
$this->info('No AI outputs require storage backfill.');
return self::SUCCESS;
}
$this->line(sprintf(
'AI output backfill candidates: %d (processing up to %d).',
$candidateCount,
$limit
));
if ($pretend) {
$this->table(
['Output ID', 'Request ID', 'Provider URL'],
$outputs->map(static fn (AiEditOutput $output): array => [
(string) $output->id,
(string) $output->request_id,
(string) $output->provider_url,
])->all()
);
$this->info('Pretend mode enabled. No records were changed.');
return self::SUCCESS;
}
$processed = 0;
$stored = 0;
$failed = 0;
foreach ($outputs as $output) {
$processed++;
$request = $output->request;
if (! $request instanceof AiEditRequest) {
$failed++;
$this->warn(sprintf('Output %d skipped: missing request relation.', $output->id));
continue;
}
$persisted = $this->outputStorage->persist($request, [
'provider_url' => $output->provider_url,
'provider_asset_id' => $output->provider_asset_id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'bytes' => $output->bytes,
'checksum' => $output->checksum,
'metadata' => $output->metadata,
]);
$output->forceFill([
'provider_url' => $persisted['provider_url'] ?? $output->provider_url,
'storage_disk' => $persisted['storage_disk'] ?? $output->storage_disk,
'storage_path' => $persisted['storage_path'] ?? $output->storage_path,
'mime_type' => $persisted['mime_type'] ?? $output->mime_type,
'width' => array_key_exists('width', $persisted) ? $persisted['width'] : $output->width,
'height' => array_key_exists('height', $persisted) ? $persisted['height'] : $output->height,
'bytes' => array_key_exists('bytes', $persisted) ? $persisted['bytes'] : $output->bytes,
'checksum' => $persisted['checksum'] ?? $output->checksum,
'metadata' => is_array($persisted['metadata'] ?? null) ? $persisted['metadata'] : $output->metadata,
])->save();
$storagePath = trim((string) ($output->storage_path ?? ''));
if ($storagePath !== '') {
$stored++;
} else {
$failed++;
$this->warn(sprintf('Output %d could not be persisted locally.', $output->id));
}
}
$this->info(sprintf(
'AI output backfill complete: processed=%d stored=%d failed=%d.',
$processed,
$stored,
$failed
));
return self::SUCCESS;
}
private function normalizeRequestId(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$requestId = (int) $value;
return $requestId > 0 ? $requestId : null;
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AiEditRequest;
use App\Models\AiUsageLedger;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class AiEditsPruneCommand extends Command
{
protected $signature = 'ai-edits:prune
{--request-days= : Override AI request retention days}
{--ledger-days= : Override usage ledger retention days}
{--pretend : Report counts without deleting data}';
protected $description = 'Prune stale AI edit requests and usage ledgers based on retention settings.';
public function handle(): int
{
$requestRetentionDays = max(1, (int) ($this->option('request-days') ?: config('ai-editing.retention.request_days', 90)));
$ledgerRetentionDays = max(1, (int) ($this->option('ledger-days') ?: config('ai-editing.retention.usage_ledger_days', 365)));
$pretend = (bool) $this->option('pretend');
$requestCutoff = now()->subDays($requestRetentionDays);
$ledgerCutoff = now()->subDays($ledgerRetentionDays);
$requestQuery = AiEditRequest::query()
->where(function (Builder $query) use ($requestCutoff): void {
$query->where(function (Builder $completedQuery) use ($requestCutoff): void {
$completedQuery->whereNotNull('completed_at')
->where('completed_at', '<=', $requestCutoff);
})->orWhere(function (Builder $expiredQuery): void {
$expiredQuery->whereNotNull('expires_at')
->where('expires_at', '<=', now());
});
});
$ledgerQuery = AiUsageLedger::query()
->where('recorded_at', '<=', $ledgerCutoff);
$requestCount = (clone $requestQuery)->count();
$ledgerCount = (clone $ledgerQuery)->count();
$this->line(sprintf(
'AI prune candidates -> requests: %d (<= %s), ledgers: %d (<= %s)',
$requestCount,
$requestCutoff->toDateString(),
$ledgerCount,
$ledgerCutoff->toDateString()
));
if ($pretend) {
$this->info('Pretend mode enabled. No records were deleted.');
return self::SUCCESS;
}
$deletedRequests = $requestQuery->delete();
$deletedLedgers = $ledgerQuery->delete();
$this->info(sprintf(
'Pruned AI data -> requests: %d, ledgers: %d.',
$deletedRequests,
$deletedLedgers
));
return self::SUCCESS;
}
}

View File

@@ -1,179 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\PollAiEditRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class AiEditsRecoverStuckCommand extends Command
{
protected $signature = 'ai-edits:recover-stuck
{--minutes=30 : Minimum age in minutes for queued/processing requests}
{--requeue : Re-dispatch stuck requests back to the queue}
{--fail : Mark stuck requests as failed}';
protected $description = 'Inspect stuck AI edit requests and optionally recover them by requeueing or failing.';
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig)
{
parent::__construct();
}
public function handle(): int
{
$minutes = max(1, (int) $this->option('minutes'));
$shouldRequeue = (bool) $this->option('requeue');
$shouldFail = (bool) $this->option('fail');
if ($shouldRequeue && $shouldFail) {
$this->error('Use either --requeue or --fail, not both.');
return self::FAILURE;
}
$cutoff = now()->subMinutes($minutes);
$requests = AiEditRequest::query()
->with([
'event:id,slug,name',
'providerRuns' => function (HasMany $query): void {
$query->select(['id', 'request_id', 'provider_task_id', 'attempt'])
->orderByDesc('attempt');
},
])
->whereIn('status', [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING])
->where(function (Builder $query) use ($cutoff): void {
$query
->where(function (Builder $queuedQuery) use ($cutoff): void {
$queuedQuery->whereNull('started_at')
->whereNotNull('queued_at')
->where('queued_at', '<=', $cutoff);
})
->orWhere(function (Builder $processingQuery) use ($cutoff): void {
$processingQuery->whereNotNull('started_at')
->where('started_at', '<=', $cutoff);
})
->orWhere(function (Builder $fallbackQuery) use ($cutoff): void {
$fallbackQuery->whereNull('queued_at')
->whereNull('started_at')
->where('updated_at', '<=', $cutoff);
});
})
->orderBy('updated_at')
->get();
if ($requests->isEmpty()) {
$this->info(sprintf('No stuck AI edit requests older than %d minute(s).', $minutes));
return self::SUCCESS;
}
$this->table(
['ID', 'Event', 'Status', 'Queued/Started', 'Latest task'],
$requests->map(function (AiEditRequest $request): array {
$latestTaskId = $this->latestProviderTaskId($request) ?? '-';
$eventLabel = (string) ($request->event?->name ?: $request->event?->slug ?: $request->event_id);
$ageSource = $request->started_at ?: $request->queued_at ?: $request->updated_at;
return [
(string) $request->id,
$eventLabel,
$request->status,
$ageSource?->toIso8601String() ?? '-',
$latestTaskId,
];
})->all()
);
if (! $shouldRequeue && ! $shouldFail) {
$this->info('Dry-run only. Use --requeue to dispatch recovery jobs or --fail to terminate stuck requests.');
return self::SUCCESS;
}
if ($shouldFail) {
$count = $this->markAsFailed($requests);
$this->info(sprintf('Marked %d AI edit request(s) as failed.', $count));
return self::SUCCESS;
}
[$processDispatches, $pollDispatches] = $this->requeueRequests($requests);
$this->info(sprintf(
'Recovered %d stuck AI edit request(s): %d process dispatch(es), %d poll dispatch(es).',
$processDispatches + $pollDispatches,
$processDispatches,
$pollDispatches
));
return self::SUCCESS;
}
/**
* @return array{0:int,1:int}
*/
private function requeueRequests(Collection $requests): array
{
$queueName = $this->runtimeConfig->queueName();
$processDispatches = 0;
$pollDispatches = 0;
foreach ($requests as $request) {
if ($request->status === AiEditRequest::STATUS_QUEUED) {
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
$processDispatches++;
continue;
}
$providerTaskId = $this->latestProviderTaskId($request);
if ($providerTaskId !== null) {
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)->onQueue($queueName);
$pollDispatches++;
continue;
}
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
$processDispatches++;
}
return [$processDispatches, $pollDispatches];
}
private function markAsFailed(Collection $requests): int
{
$updated = 0;
$now = now();
foreach ($requests as $request) {
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'operator_recovery_marked_failed',
'failure_message' => 'Marked as failed by ai-edits:recover-stuck.',
'completed_at' => $now,
])->save();
$updated++;
}
return $updated;
}
private function latestProviderTaskId(AiEditRequest $request): ?string
{
foreach ($request->providerRuns as $run) {
$taskId = trim((string) ($run->provider_task_id ?? ''));
if ($taskId !== '') {
return $taskId;
}
}
return null;
}
}

View File

@@ -1,130 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\LemonSqueezy\LemonSqueezyClient;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class LemonSqueezyRegisterWebhooks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lemonsqueezy:webhooks:register
{--url= : Destination URL for Lemon Squeezy webhooks}
{--events=* : Override event types to subscribe}
{--secret= : Override the webhook signing secret}
{--test-mode : Register the webhook in test mode}
{--dry-run : Output payload without creating the destination}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Register Lemon Squeezy webhook notification settings.';
/**
* Execute the console command.
*/
public function handle(LemonSqueezyClient $client): int
{
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
if ($destination === '') {
$this->error('Webhook destination URL is required. Use --url=...');
return self::FAILURE;
}
$events = collect((array) $this->option('events'))
->filter()
->map(fn ($event) => trim((string) $event))
->filter()
->values()
->all();
if ($events === []) {
$events = config('lemonsqueezy.webhook_events', []);
}
if ($events === [] || ! is_array($events)) {
$this->error('No webhook events configured. Set config(lemonsqueezy.webhook_events) or pass --events.');
return self::FAILURE;
}
$secret = (string) ($this->option('secret') ?: config('lemonsqueezy.webhook_secret'));
if ($secret === '') {
$this->error('Webhook signing secret is required. Set LEMONSQUEEZY_WEBHOOK_SECRET or pass --secret.');
return self::FAILURE;
}
$storeId = (string) config('lemonsqueezy.store_id');
if ($storeId === '') {
$this->error('Lemon Squeezy store id is required. Set LEMONSQUEEZY_STORE_ID.');
return self::FAILURE;
}
$testMode = (bool) $this->option('test-mode') || (bool) config('lemonsqueezy.test_mode', false);
$attributes = array_filter([
'url' => $destination,
'events' => $events,
'secret' => $secret,
'test_mode' => $testMode ? true : null,
], static fn ($value) => $value !== null && $value !== '');
$payload = [
'data' => [
'type' => 'webhooks',
'attributes' => $attributes,
'relationships' => [
'store' => [
'data' => [
'type' => 'stores',
'id' => $storeId,
],
],
],
],
];
if ((bool) $this->option('dry-run')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
return self::SUCCESS;
}
$response = $client->post('/webhooks', $payload);
$data = Arr::get($response, 'data', $response);
$id = Arr::get($data, 'id');
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy webhook registered', [
'webhook_id' => $id,
'destination' => $destination,
'test_mode' => $testMode,
]);
$this->info('Lemon Squeezy webhook registered.');
if ($id) {
$this->line('ID: '.$id);
}
return self::SUCCESS;
}
protected function defaultWebhookUrl(): string
{
$base = rtrim((string) config('app.url'), '/');
return $base !== '' ? $base.'/lemonsqueezy/webhook' : '';
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Console\Commands;
use App\Services\Paddle\PaddleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class PaddleRegisterWebhooks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'paddle:webhooks:register
{--url= : Destination URL for Paddle webhooks}
{--description= : Description for the webhook destination}
{--events=* : Override event types to subscribe}
{--traffic-source=all : platform|simulation|all}
{--include-sensitive : Include sensitive fields in webhook payloads}
{--show-secret : Output the endpoint secret key}
{--dry-run : Output payload without creating the destination}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Register Paddle webhook notification settings.';
/**
* Execute the console command.
*/
public function handle(PaddleClient $client): int
{
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
if ($destination === '') {
$this->error('Webhook destination URL is required. Use --url=...');
return self::FAILURE;
}
$events = collect((array) $this->option('events'))
->filter()
->map(fn ($event) => trim((string) $event))
->filter()
->values()
->all();
if ($events === []) {
$events = config('paddle.webhook_events', []);
}
if ($events === [] || ! is_array($events)) {
$this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.');
return self::FAILURE;
}
$trafficSource = (string) $this->option('traffic-source');
$allowedSources = ['platform', 'simulation', 'all'];
if (! in_array($trafficSource, $allowedSources, true)) {
$this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources)));
return self::FAILURE;
}
$payload = [
'type' => 'url',
'destination' => $destination,
'description' => $this->resolveDescription(),
'subscribed_events' => $events,
'traffic_source' => $trafficSource,
'include_sensitive_fields' => (bool) $this->option('include-sensitive'),
];
if ((bool) $this->option('dry-run')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
return self::SUCCESS;
}
$response = $client->post('/notification-settings', $payload);
$data = Arr::get($response, 'data', $response);
$id = Arr::get($data, 'id');
$secret = Arr::get($data, 'endpoint_secret_key');
Log::channel('paddle-sync')->info('Paddle webhook registered', [
'notification_setting_id' => $id,
'destination' => $destination,
'traffic_source' => $trafficSource,
]);
$this->info('Paddle webhook registered.');
if ($id) {
$this->line('ID: '.$id);
}
if ($secret && $this->option('show-secret')) {
$this->line('Secret: '.$secret);
} elseif ($secret) {
$this->line('Secret returned (hidden). Use --show-secret to display.');
}
return self::SUCCESS;
}
protected function defaultWebhookUrl(): string
{
$base = rtrim((string) config('app.url'), '/');
return $base !== '' ? $base.'/paddle/webhook' : '';
}
protected function resolveDescription(): string
{
$description = (string) $this->option('description');
if ($description !== '') {
return $description;
}
$environment = (string) config('paddle.environment', 'production');
return sprintf('Fotospiel Paddle webhooks (%s)', $environment);
}
}

View File

@@ -2,23 +2,23 @@
namespace App\Console\Commands;
use App\Jobs\PullPackageFromLemonSqueezy;
use App\Jobs\SyncPackageToLemonSqueezy;
use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle;
use App\Models\Package;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class LemonSqueezySyncPackages extends Command
class PaddleSyncPackages extends Command
{
protected $signature = 'lemonsqueezy:sync-packages
protected $signature = 'paddle:sync-packages
{--package=* : Limit sync to the given package IDs or slugs}
{--dry-run : Generate payload snapshots without calling Lemon Squeezy}
{--pull : Fetch remote Lemon Squeezy state instead of pushing local changes}
{--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs}
{--dry-run : Generate payload snapshots without calling Paddle}
{--pull : Fetch remote Paddle state instead of pushing local changes}
{--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs}
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
protected $description = 'Synchronise local packages with Lemon Squeezy products and variants.';
protected $description = 'Synchronise local packages with Paddle products and prices.';
public function handle(): int
{
@@ -52,7 +52,7 @@ class LemonSqueezySyncPackages extends Command
});
$this->info(sprintf(
'Queued %d package %s for Lemon Squeezy %s.',
'Queued %d package %s for Paddle %s.',
$packages->count(),
Str::plural('entry', $packages->count()),
$pull ? 'pull' : 'sync'
@@ -97,22 +97,22 @@ class LemonSqueezySyncPackages extends Command
protected function guardUnmappedPackages(Collection $packages): bool
{
$unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id));
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id));
if ($unmapped->isEmpty()) {
return true;
}
$this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.');
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.');
$this->table(
['ID', 'Slug', 'Missing'],
$unmapped->map(function (Package $package): array {
$missing = [];
if (blank($package->lemonsqueezy_product_id)) {
if (blank($package->paddle_product_id)) {
$missing[] = 'product_id';
}
if (blank($package->lemonsqueezy_variant_id)) {
$missing[] = 'variant_id';
if (blank($package->paddle_price_id)) {
$missing[] = 'price_id';
}
return [
@@ -133,26 +133,26 @@ class LemonSqueezySyncPackages extends Command
];
if ($queue) {
SyncPackageToLemonSqueezy::dispatch($package->id, $context);
SyncPackageToPaddle::dispatch($package->id, $context);
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
return;
}
SyncPackageToLemonSqueezy::dispatchSync($package->id, $context);
SyncPackageToPaddle::dispatchSync($package->id, $context);
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
}
protected function dispatchPullJob(Package $package, bool $queue): void
{
if ($queue) {
PullPackageFromLemonSqueezy::dispatch($package->id);
PullPackageFromPaddle::dispatch($package->id);
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
return;
}
PullPackageFromLemonSqueezy::dispatchSync($package->id);
PullPackageFromPaddle::dispatchSync($package->id);
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages;
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
use Filament\Resources\Pages\ListRecords;
class ListTenantCheckoutHealths extends ListRecords
{
protected static string $resource = TenantCheckoutHealthResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
use Filament\Resources\Pages\ListRecords;
class ListTenantPaddleHealths extends ListRecords
{
protected static string $resource = TenantPaddleHealthResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables;
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use Filament\Forms\Components\TextInput;
@@ -13,9 +13,12 @@ use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class TenantCheckoutHealthTable
class TenantPaddleHealthTable
{
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
public static function configure(Table $table): Table
{
return $table
@@ -32,6 +35,11 @@ class TenantCheckoutHealthTable
->label(__('admin.tenants.fields.contact_email'))
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_customer_id')
->label('Paddle customer')
->toggleable(isToggledHiddenByDefault: true)
->copyable()
->formatStateUsing(fn (?string $state) => $state ?: '—'),
TextColumn::make('subscription_status')
->label('Subscription')
->badge()
@@ -48,77 +56,134 @@ class TenantCheckoutHealthTable
->badge()
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_subscription_id')
->label('Paddle subscription')
->toggleable(isToggledHiddenByDefault: true)
->copyable()
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id)
->formatStateUsing(fn (?string $state) => $state ?: '—'),
IconColumn::make('missing_paddle_subscription')
->label('Missing Paddle subscription')
->boolean()
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)),
IconColumn::make('status_mismatch')
->label('Status mismatch')
->boolean()
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
TextColumn::make('last_checkout_transaction_at')
->label('Last transaction')
TextColumn::make('paddle_customer_duplicates')
->label('Paddle duplicates')
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
TextColumn::make('paddle_sync_status')
->label('Paddle sync')
->badge()
->color(fn (?string $state) => match ($state) {
'synced' => 'success',
'syncing' => 'warning',
'pulled' => 'info',
'dry-run' => 'gray',
'failed', 'pull-failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_synced_at')
->label('Paddle synced')
->badge()
->color(fn ($state) => self::syncAgeColor($state))
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at),
TextColumn::make('last_paddle_transaction_at')
->label('Last Paddle tx')
->badge()
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
->getStateUsing(fn (Tenant $record) => $record->last_checkout_transaction_at
? Carbon::parse($record->last_checkout_transaction_at)
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
? Carbon::parse($record->last_paddle_transaction_at)
: null)
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_transaction_count_window')
->label('Transactions (30d)')
TextColumn::make('paddle_transaction_count_window')
->label('Paddle tx (30d)')
->default('0')
->sortable()
->toggleable(),
TextColumn::make('checkout_transaction_total_window')
->label('Total (30d)')
TextColumn::make('paddle_transaction_total_window')
->label('Paddle total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(),
TextColumn::make('checkout_refund_count_window')
TextColumn::make('paddle_refund_count_window')
->label('Refunds (30d)')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_refund_total_window')
TextColumn::make('paddle_refund_total_window')
->label('Refund total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_requires_action_count')
TextColumn::make('paddle_checkout_requires_action_count')
->label('Checkout action required')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_processing_count')
TextColumn::make('paddle_checkout_processing_count')
->label('Checkout processing')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_expired_count')
TextColumn::make('paddle_checkout_expired_count')
->label('Checkout expired')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_transaction_count')
->label('Transactions (all)')
TextColumn::make('paddle_transaction_count')
->label('Paddle tx (all)')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_transaction_total')
->label('Total (all)')
TextColumn::make('paddle_transaction_total')
->label('Paddle total (all)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Filter::make('missing_paddle_customer')
->label('Missing Paddle customer')
->indicator('Missing Paddle customer')
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')),
Filter::make('missing_paddle_subscription')
->label('Missing Paddle subscription')
->indicator('Missing Paddle subscription')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
->where('active', true)
->whereNull('paddle_subscription_id'))),
Filter::make('duplicate_paddle_customer')
->label('Duplicate Paddle customer')
->indicator('Duplicate Paddle customer')
->query(fn (Builder $query) => $query
->whereNotNull('paddle_customer_id')
->whereIn('paddle_customer_id', function ($subquery) {
$subquery->select('paddle_customer_id')
->from('tenants')
->whereNotNull('paddle_customer_id')
->groupBy('paddle_customer_id')
->havingRaw('count(*) > 1');
})),
Filter::make('status_mismatch')
->label('Status mismatch')
->indicator('Status mismatch')
@@ -140,24 +205,39 @@ class TenantCheckoutHealthTable
->where('is_suspended', false)
->whereNull('pending_deletion_at')
->whereNull('anonymized_at')),
Filter::make('checkout_transaction_stale')
->label('Stale transactions')
->indicator('Stale transactions')
Filter::make('paddle_sync_failed')
->label('Paddle sync failed')
->indicator('Paddle sync failed')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))),
Filter::make('paddle_sync_stale')
->label('Paddle sync stale')
->indicator('Paddle sync stale')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNotNull('paddle_synced_at')
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))),
Filter::make('paddle_sync_missing')
->label('Missing Paddle sync timestamp')
->indicator('Missing Paddle sync timestamp')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNull('paddle_synced_at'))),
Filter::make('paddle_transaction_stale')
->label('Stale Paddle transactions')
->indicator('Stale Paddle transactions')
->query(function (Builder $query): Builder {
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
$provider = TenantCheckoutHealthResource::provider();
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
return $query
->whereHas('purchases', fn (Builder $query) => $query->where('provider', $provider))
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
->whereDoesntHave('purchases', fn (Builder $query) => $query
->where('provider', $provider)
->where('provider', 'paddle')
->where('purchased_at', '>=', $cutoff));
}),
Filter::make('checkout_attention')
->label('Checkout attention')
->indicator('Checkout attention')
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
$query->where('provider', TenantCheckoutHealthResource::provider())
$query->where('provider', 'paddle')
->where(function (Builder $query) {
$query->whereIn('status', [
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
@@ -194,11 +274,10 @@ class TenantCheckoutHealthTable
return $query;
}
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
$provider = TenantCheckoutHealthResource::provider();
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
return $query->whereHas('purchases', fn (Builder $query) => $query
->where('provider', $provider)
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $cutoff), '>=', $min);
}),
@@ -235,6 +314,13 @@ class TenantCheckoutHealthTable
return false;
}
private static function missingPaddleSubscription(Tenant $record): bool
{
$package = $record->activeResellerPackage;
return $package && $package->active && ! $package->paddle_subscription_id;
}
private static function applyStatusMismatchFilter(Builder $query): Builder
{
return $query->where(function (Builder $query) {
@@ -252,13 +338,26 @@ class TenantCheckoutHealthTable
});
}
private static function syncAgeColor($state): string
{
if (! $state) {
return 'gray';
}
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
return 'danger';
}
return 'success';
}
private static function transactionAgeColor(?Carbon $state): string
{
if (! $state) {
return 'gray';
}
if ($state->lt(now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS))) {
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
return 'danger';
}

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths;
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages\ListTenantCheckoutHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables\TenantCheckoutHealthTable;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use BackedEnum;
@@ -13,11 +13,11 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class TenantCheckoutHealthResource extends Resource
class TenantPaddleHealthResource extends Resource
{
public const TRANSACTION_WINDOW_DAYS = 30;
public const STALE_SYNC_DAYS = 30;
public const DEFAULT_PROVIDER = CheckoutSession::PROVIDER_PAYPAL;
public const TRANSACTION_WINDOW_DAYS = 30;
protected static ?string $model = Tenant::class;
@@ -25,13 +25,13 @@ class TenantCheckoutHealthResource extends Resource
protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'checkout-health';
protected static ?string $slug = 'paddle-health';
protected static ?int $navigationSort = 20;
public static function table(Table $table): Table
{
return TenantCheckoutHealthTable::configure($table);
return TenantPaddleHealthTable::configure($table);
}
public static function canCreate(): bool
@@ -41,7 +41,7 @@ class TenantCheckoutHealthResource extends Resource
public static function getNavigationLabel(): string
{
return __('admin.checkout_health.navigation.label');
return __('admin.paddle_health.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
@@ -51,32 +51,37 @@ class TenantCheckoutHealthResource extends Resource
public static function getEloquentQuery(): Builder
{
$provider = static::provider();
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
return parent::getEloquentQuery()
->with(['activeResellerPackage.package'])
->withExists('activeResellerPackage as has_active_reseller_package')
->addSelect([
'paddle_customer_duplicates' => Tenant::query()
->selectRaw('count(*)')
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id')
->whereNotNull('paddle_customer_id'),
])
->withCount([
'purchases as checkout_transaction_count' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false),
'purchases as checkout_transaction_count_window' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
'purchases as checkout_refund_count_window' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
'checkoutSessions as checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', $provider)
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
'checkoutSessions as checkout_processing_count' => fn (Builder $query) => $query
->where('provider', $provider)
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->where('status', CheckoutSession::STATUS_PROCESSING),
'checkoutSessions as checkout_expired_count' => fn (Builder $query) => $query
->where('provider', $provider)
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED,
@@ -85,37 +90,32 @@ class TenantCheckoutHealthResource extends Resource
->where('expires_at', '<', now()),
])
->withSum([
'purchases as checkout_transaction_total' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false),
], 'price')
->withSum([
'purchases as checkout_transaction_total_window' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withSum([
'purchases as checkout_refund_total_window' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withMax([
'purchases as last_checkout_transaction_at' => fn (Builder $query) => $query
->where('provider', $provider),
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
->where('provider', 'paddle'),
], 'purchased_at');
}
public static function getPages(): array
{
return [
'index' => ListTenantCheckoutHealths::route('/'),
'index' => ListTenantPaddleHealths::route('/'),
];
}
public static function provider(): string
{
return (string) config('checkout.default_provider', self::DEFAULT_PROVIDER);
}
}

View File

@@ -1,305 +0,0 @@
<?php
namespace App\Filament\Resources\AiStyles;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
use App\Models\AiStyle;
use App\Services\AiEditing\RunwareModelSearchService;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
class AiStyleResource extends Resource
{
protected static ?string $model = AiStyle::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-paint-brush';
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 31;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getNavigationLabel(): string
{
return 'AI Styles';
}
public static function form(Schema $schema): Schema
{
return $schema->schema([
Section::make('Style Basics')
->schema([
TextInput::make('key')
->required()
->maxLength(120)
->unique(ignoreRecord: true),
TextInput::make('name')
->required()
->maxLength(120),
TextInput::make('version')
->numeric()
->default(1)
->disabled()
->dehydrated(false)
->helperText('Auto-increments when core style configuration changes.'),
TextInput::make('category')
->maxLength(50),
TextInput::make('sort')
->numeric()
->default(0)
->required(),
Toggle::make('is_active')
->default(true),
Toggle::make('is_premium')
->default(false),
Toggle::make('requires_source_image')
->default(true),
])
->columns(3),
Section::make('Provider Binding')
->schema([
Select::make('provider')
->options([
'runware' => 'runware.ai',
])
->required()
->default('runware'),
Select::make('provider_model')
->label('Runware model (AIR)')
->searchable()
->getSearchResultsUsing(static fn (string $search): array => app(RunwareModelSearchService::class)->searchOptions($search))
->getOptionLabelUsing(static fn (mixed $value): ?string => app(RunwareModelSearchService::class)->labelForModel($value))
->helperText('Start typing to search models from runware.ai.')
->native(false)
->live()
->afterStateUpdated(static function (Set $set, ?string $state): void {
self::applySelectedRunwareModel($set, $state);
}),
])
->columns(2),
Section::make('Runware Generation')
->schema([
TextInput::make('metadata.runware.generation.width')
->label('Width')
->numeric()
->minValue(64)
->maxValue(4096)
->step(64)
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'width')),
TextInput::make('metadata.runware.generation.height')
->label('Height')
->numeric()
->minValue(64)
->maxValue(4096)
->step(64)
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'height')),
TextInput::make('metadata.runware.generation.steps')
->label('Steps')
->numeric()
->minValue(1)
->maxValue(150)
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'steps')),
TextInput::make('metadata.runware.generation.cfg_scale')
->label('CFG Scale')
->numeric()
->minValue(0)
->maxValue(30)
->step(0.1)
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'cfg_scale')),
TextInput::make('metadata.runware.generation.strength')
->label('Strength')
->numeric()
->minValue(0)
->maxValue(1)
->step(0.01)
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'strength')),
Select::make('metadata.runware.generation.output_format')
->label('Output format')
->options([
'JPG' => 'JPG',
'PNG' => 'PNG',
'WEBP' => 'WEBP',
])
->default('JPG')
->native(false),
Select::make('metadata.runware.generation.delivery_method')
->label('Delivery method')
->options([
'async' => 'async (queue + poll)',
'sync' => 'sync',
])
->default('async')
->native(false),
])
->columns(3),
Section::make('Prompts')
->schema([
Textarea::make('description')
->rows(2),
Textarea::make('prompt_template')
->rows(5),
Textarea::make('negative_prompt_template')
->rows(4),
]),
Section::make('Metadata')
->schema([
KeyValue::make('metadata')
->nullable(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('sort')
->columns([
Tables\Columns\TextColumn::make('key')
->searchable()
->copyable(),
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('version')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('provider')
->badge(),
Tables\Columns\TextColumn::make('provider_model')
->toggleable(),
Tables\Columns\IconColumn::make('is_active')
->boolean(),
Tables\Columns\IconColumn::make('is_premium')
->boolean(),
Tables\Columns\TextColumn::make('sort')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->since()
->toggleable(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active'),
Tables\Filters\TernaryFilter::make('is_premium'),
])
->actions([
Actions\EditAction::make()
->after(fn (array $data, AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
static::class
)),
Actions\DeleteAction::make()
->after(fn (AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
])
->bulkActions([
Actions\DeleteBulkAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => ManageAiStyles::route('/'),
];
}
private static function applySelectedRunwareModel(Set $set, ?string $air): void
{
if (! is_string($air) || trim($air) === '') {
return;
}
$model = app(RunwareModelSearchService::class)->findByAir($air);
if (! is_array($model)) {
return;
}
$set('metadata.runware.model.air', $model['air']);
$set('metadata.runware.model.name', $model['name']);
$set('metadata.runware.model.architecture', $model['architecture']);
$set('metadata.runware.model.category', $model['category']);
foreach ((array) ($model['constraints'] ?? []) as $key => $value) {
$set("metadata.runware.constraints.{$key}", $value);
}
self::setIfNumeric($set, 'metadata.runware.generation.width', $model['defaults']['width'] ?? null);
self::setIfNumeric($set, 'metadata.runware.generation.height', $model['defaults']['height'] ?? null);
self::setIfNumeric($set, 'metadata.runware.generation.steps', $model['defaults']['steps'] ?? null);
self::setIfNumeric($set, 'metadata.runware.generation.cfg_scale', $model['defaults']['cfg_scale'] ?? null);
}
private static function setIfNumeric(Set $set, string $path, mixed $value): void
{
if (is_numeric($value)) {
$set($path, $value);
}
}
private static function dimensionConstraintHint(Get $get, string $dimension): ?string
{
$min = $get("metadata.runware.constraints.min_{$dimension}");
$max = $get("metadata.runware.constraints.max_{$dimension}");
$step = $get("metadata.runware.constraints.{$dimension}_step");
if (! is_numeric($min) && ! is_numeric($max) && ! is_numeric($step)) {
return null;
}
$parts = [];
if (is_numeric($min) || is_numeric($max)) {
$parts[] = sprintf(
'Model range: %s - %s',
is_numeric($min) ? (string) (int) $min : '?',
is_numeric($max) ? (string) (int) $max : '?'
);
}
if (is_numeric($step) && (int) $step > 0) {
$parts[] = sprintf('Step: %d', (int) $step);
}
return $parts !== [] ? implode(' | ', $parts) : null;
}
private static function rangeConstraintHint(Get $get, string $field): ?string
{
$min = $get("metadata.runware.constraints.min_{$field}");
$max = $get("metadata.runware.constraints.max_{$field}");
if (! is_numeric($min) && ! is_numeric($max)) {
return null;
}
return sprintf(
'Model range: %s - %s',
is_numeric($min) ? trim((string) $min) : '?',
is_numeric($max) ? trim((string) $max) : '?'
);
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Filament\Resources\AiStyles\Pages;
use App\Filament\Resources\AiStyles\AiStyleResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageAiStyles extends ManageRecords
{
protected static string $resource = AiStyleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$record,
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
static::class
)),
];
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Jobs\SyncCouponToLemonSqueezy;
use App\Jobs\SyncCouponToPaddle;
class CreateCoupon extends AuditedCreateRecord
{
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
{
parent::afterCreate();
SyncCouponToLemonSqueezy::dispatch($this->record);
SyncCouponToPaddle::dispatch($this->record);
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Jobs\SyncCouponToLemonSqueezy;
use App\Jobs\SyncCouponToPaddle;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
source: static::class
);
SyncCouponToLemonSqueezy::dispatch($record, true);
SyncCouponToPaddle::dispatch($record, true);
}),
ForceDeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
{
parent::afterSave();
SyncCouponToLemonSqueezy::dispatch($this->record);
SyncCouponToPaddle::dispatch($this->record);
}
}

View File

@@ -21,7 +21,7 @@ class RedemptionsRelationManager extends RelationManager
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('lemonsqueezy_order_id')
->recordTitleAttribute('paddle_transaction_id')
->columns([
TextColumn::make('tenant.name')
->label(__('Tenant'))
@@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
'failed' => 'danger',
default => 'warning',
}),
TextColumn::make('lemonsqueezy_order_id')
TextColumn::make('paddle_transaction_id')
->label(__('Transaction'))
->copyable()
->toggleable(isToggledHiddenByDefault: true),

View File

@@ -123,22 +123,22 @@ class CouponForm
->nullable()
->columnSpanFull(),
]),
Section::make(__('Lemon Squeezy sync'))
Section::make(__('Paddle sync'))
->columns(2)
->schema([
Select::make('lemonsqueezy_mode')
->label(__('Lemon Squeezy mode'))
Select::make('paddle_mode')
->label(__('Paddle mode'))
->options([
'standard' => __('Standard'),
'custom' => __('Custom (one-off)'),
])
->default('standard'),
Placeholder::make('lemonsqueezy_discount_id')
->label(__('Lemon Squeezy Discount ID'))
->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'),
Placeholder::make('lemonsqueezy_last_synced_at')
Placeholder::make('paddle_discount_id')
->label(__('Paddle Discount ID'))
->content(fn ($record) => $record?->paddle_discount_id ?? '—'),
Placeholder::make('paddle_last_synced_at')
->label(__('Last synced'))
->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
Placeholder::make('redemptions_count')
->label(__('Total redemptions'))
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),

View File

@@ -63,17 +63,17 @@ class CouponInfolist
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
]),
Section::make(__('Lemon Squeezy'))
Section::make(__('Paddle'))
->columns(3)
->schema([
TextEntry::make('lemonsqueezy_discount_id')
TextEntry::make('paddle_discount_id')
->label(__('Discount ID'))
->copyable()
->placeholder('—'),
TextEntry::make('lemonsqueezy_last_synced_at')
TextEntry::make('paddle_last_synced_at')
->label(__('Last synced'))
->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
TextEntry::make('lemonsqueezy_mode')
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
TextEntry::make('paddle_mode')
->label(__('Mode'))
->badge()
->placeholder('standard'),

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use App\Jobs\SyncCouponToLemonSqueezy;
use App\Jobs\SyncCouponToPaddle;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
@@ -105,9 +105,9 @@ class CouponsTable
static::class
)),
Action::make('sync')
->label(__('Sync to Lemon Squeezy'))
->label(__('Sync to Paddle'))
->icon('heroicon-m-arrow-path')
->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record))
->action(fn ($record) => SyncCouponToPaddle::dispatch($record))
->requiresConfirmation(),
])
->toolbarActions([

View File

@@ -6,29 +6,24 @@ use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventJoinTokenEvent;
use App\Models\EventType;
use App\Models\Tenant;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\EventJoinTokenService;
use App\Support\JoinTokenLayoutRegistry;
use BackedEnum;
use Carbon\Carbon;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use UnitEnum;
class EventResource extends Resource
@@ -98,10 +93,6 @@ class EventResource extends Resource
Toggle::make('is_active')
->label(__('admin.events.fields.is_active'))
->default(true),
Toggle::make('settings.marketing_demo')
->label(__('admin.events.fields.marketing_demo'))
->helperText(__('admin.events.fields.marketing_demo_help'))
->default(false),
KeyValue::make('settings')
->label(__('admin.events.fields.settings'))
->keyLabel(__('admin.common.key'))
@@ -173,161 +164,7 @@ class EventResource extends Resource
->modalHeading(__('admin.events.modal.join_link_heading'))
->modalSubmitActionLabel(__('admin.common.close'))
->modalWidth('xl')
->registerModalActions([
Actions\Action::make('extend_join_token_expiry')
->label(__('admin.events.join_link.extend_expiry'))
->icon('heroicon-o-clock')
->color('warning')
->size('xs')
->modalHeading(function (Actions\Action $action, Event $record): string {
$token = static::resolveJoinTokenFromAction($record, $action);
return $token
? __('admin.events.join_link.extend_expiry_heading', [
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
])
: __('admin.events.join_link.extend_expiry_heading_fallback');
})
->schema(function (Event $record): array {
$minimumExpiry = app(EventJoinTokenService::class)->minimumExpiryForEvent($record);
$rules = [
'date',
'after:now',
];
if ($minimumExpiry) {
$rules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
}
return [
DateTimePicker::make('expires_at')
->label(__('admin.events.join_link.extend_expiry_label'))
->required()
->seconds(false)
->rules($rules)
->helperText($minimumExpiry
? __('admin.events.join_link.extend_expiry_min', [
'date' => $minimumExpiry->isoFormat('LLL'),
])
: null),
];
})
->fillForm(function (Actions\Action $action, Event $record): array {
$token = static::resolveJoinTokenFromAction($record, $action);
if (! $token) {
return [];
}
return [
'expires_at' => $token->expires_at,
];
})
->action(function (array $data, Actions\Action $action, Event $record): void {
$token = static::resolveJoinTokenFromAction($record, $action);
if (! $token) {
Notification::make()
->title(__('admin.events.join_link.extend_expiry_missing'))
->danger()
->send();
return;
}
$expiresAt = $data['expires_at'] ?? null;
if (! $expiresAt) {
Notification::make()
->title(__('admin.events.join_link.extend_expiry_missing_date'))
->danger()
->send();
return;
}
$resolvedExpiry = $expiresAt instanceof Carbon
? $expiresAt
: Carbon::parse($expiresAt);
$token->forceFill([
'expires_at' => $resolvedExpiry,
])->save();
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$token,
source: static::class
);
Notification::make()
->title(__('admin.events.join_link.extend_expiry_success'))
->success()
->send();
}),
Actions\Action::make('set_demo_read_only')
->label(__('admin.events.join_link.demo_read_only_action'))
->icon('heroicon-o-lock-closed')
->color('gray')
->size('xs')
->modalHeading(function (Actions\Action $action, Event $record): string {
$token = static::resolveJoinTokenFromAction($record, $action);
return $token
? __('admin.events.join_link.demo_read_only_heading', [
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
])
: __('admin.events.join_link.demo_read_only_heading_fallback');
})
->schema([
Toggle::make('demo_read_only')
->label(__('admin.events.join_link.demo_read_only_label'))
->helperText(__('admin.events.join_link.demo_read_only_help')),
])
->fillForm(function (Actions\Action $action, Event $record): array {
$token = static::resolveJoinTokenFromAction($record, $action);
return [
'demo_read_only' => (bool) Arr::get($token?->metadata ?? [], 'demo_read_only', false),
];
})
->action(function (array $data, Actions\Action $action, Event $record): void {
$token = static::resolveJoinTokenFromAction($record, $action);
if (! $token) {
Notification::make()
->title(__('admin.events.join_link.demo_read_only_missing'))
->danger()
->send();
return;
}
$metadata = is_array($token->metadata) ? $token->metadata : [];
$enabled = (bool) ($data['demo_read_only'] ?? false);
if ($enabled) {
$metadata['demo_read_only'] = true;
} else {
unset($metadata['demo_read_only']);
}
$token->metadata = empty($metadata) ? null : $metadata;
$token->save();
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$token,
source: static::class
);
Notification::make()
->title(__('admin.events.join_link.demo_read_only_success'))
->success()
->send();
}),
])
->modalContent(function (Actions\Action $action, $record) {
->modalContent(function ($record) {
$tokens = $record->joinTokens()
->orderByDesc('created_at')
->get();
@@ -397,7 +234,6 @@ class EventResource extends Resource
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'demo_read_only' => (bool) Arr::get($token->metadata ?? [], 'demo_read_only', false),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
@@ -417,7 +253,6 @@ class EventResource extends Resource
return view('filament.events.join-link', [
'event' => $record,
'tokens' => $tokens,
'action' => $action,
]);
}),
])
@@ -468,19 +303,6 @@ class EventResource extends Resource
return is_string($name) ? $name : '';
}
private static function resolveJoinTokenFromAction(Event $record, Actions\Action $action): ?EventJoinToken
{
$tokenId = $action->getArguments()['token_id'] ?? null;
if (! $tokenId) {
return null;
}
return $record->joinTokens()
->whereKey($tokenId)
->first();
}
public static function getPages(): array
{
return [

View File

@@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource
->label('Empfänger')
->toggleable()
->searchable(),
TextColumn::make('lemonsqueezy_order_id')
->label('Lemon Squeezy Order')
TextColumn::make('paddle_transaction_id')
->label('Paddle Tx')
->toggleable()
->copyable()
->wrap(),

View File

@@ -46,27 +46,24 @@ class ListGiftVouchers extends ListRecords
])
->action(function (array $data, GiftVoucherService $service): void {
$payload = [
'meta' => [
'custom_data' => [
'type' => 'gift_voucher',
'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null,
'gift_code' => $data['code'] ?? null,
],
'id' => null,
'metadata' => [
'type' => 'gift_voucher',
'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null,
'gift_code' => $data['code'] ?? null,
],
'data' => [
'id' => 'manual_'.Str::uuid(),
'attributes' => [
'currency' => $data['currency'] ?? 'EUR',
'total' => (float) $data['amount'] * 100,
'user_email' => $data['purchaser_email'],
'currency_code' => $data['currency'] ?? 'EUR',
'totals' => [
'grand_total' => [
'amount' => (float) $data['amount'],
],
],
];
$voucher = $service->issueFromLemonSqueezy($payload);
$voucher = $service->issueFromPaddle($payload);
app(SuperAdminAuditLogger::class)->recordModelMutation(
'issued',

View File

@@ -4,21 +4,15 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageAddonResource\Pages;
use App\Jobs\SyncPackageAddonToLemonSqueezy;
use App\Models\CheckoutSession;
use App\Jobs\SyncPackageAddonToPaddle;
use App\Models\PackageAddon;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn;
@@ -56,18 +50,10 @@ class PackageAddonResource extends Resource
->required()
->unique(ignoreRecord: true)
->maxLength(191),
TextInput::make('variant_id')
->label('Lemon Squeezy Variant-ID')
->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
->required(fn (Get $get): bool => (bool) $get('active') && ! is_numeric($get('metadata.price_eur')))
TextInput::make('price_id')
->label('Paddle Preis-ID')
->helperText('Paddle Billing Preis-ID für dieses Add-on')
->maxLength(191),
TextInput::make('metadata.price_eur')
->label('PayPal Preis (EUR)')
->helperText('Für PayPal-Checkout erforderlich (z. B. 9.90).')
->numeric()
->step(0.01)
->minValue(0.01)
->required(fn (Get $get): bool => (bool) $get('active') && blank($get('variant_id'))),
TextInput::make('sort')
->label('Sortierung')
->numeric()
@@ -75,23 +61,6 @@ class PackageAddonResource extends Resource
Toggle::make('active')
->label('Aktiv')
->default(true),
Placeholder::make('sellable_state')
->label('Verfügbarkeits-Check')
->content(function (Get $get): string {
$isActive = (bool) $get('active');
$hasVariant = filled($get('variant_id'));
$hasPayPalPrice = is_numeric($get('metadata.price_eur'));
if (! $isActive) {
return 'Inaktiv';
}
if (! $hasVariant && ! $hasPayPalPrice) {
return 'Nicht verkäuflich: Variant-ID oder PayPal Preis fehlt.';
}
return 'Verkäuflich';
}),
]),
Section::make('Limits-Inkremente')
->columns(3)
@@ -112,30 +81,6 @@ class PackageAddonResource extends Resource
->minValue(0)
->default(0),
]),
Section::make('Feature-Entitlements')
->columns(2)
->schema([
Select::make('metadata.scope')
->label('Scope')
->options([
'photos' => 'Fotos',
'guests' => 'Gäste',
'gallery' => 'Galerie',
'feature' => 'Feature',
'bundle' => 'Bundle',
])
->native(false)
->searchable(),
TagsInput::make('metadata.entitlements.features')
->label('Freigeschaltete Features')
->helperText('Feature-Keys für Freischaltungen, z. B. ai_styling')
->placeholder('z. B. ai_styling')
->columnSpanFull(),
DateTimePicker::make('metadata.entitlements.expires_at')
->label('Entitlement gültig bis')
->seconds(false)
->nullable(),
]),
]);
}
@@ -151,33 +96,10 @@ class PackageAddonResource extends Resource
->label('Schlüssel')
->copyable()
->sortable(),
TextColumn::make('variant_id')
->label('Lemon Squeezy Variant-ID')
TextColumn::make('price_id')
->label('Paddle Preis-ID')
->toggleable()
->copyable(),
TextColumn::make('metadata.price_eur')
->label('PayPal Preis (EUR)')
->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? number_format((float) $state, 2, ',', '.').' €' : '—')
->toggleable(),
TextColumn::make('metadata.scope')
->label('Scope')
->badge()
->toggleable(),
TextColumn::make('metadata.entitlements.features')
->label('Features')
->formatStateUsing(function (mixed $state): string {
if (! is_array($state)) {
return '—';
}
$features = array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$state,
)));
return $features === [] ? '—' : implode(', ', $features);
})
->toggleable(),
TextColumn::make('extra_photos')->label('Fotos +'),
TextColumn::make('extra_guests')->label('Gäste +'),
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
@@ -188,14 +110,6 @@ class PackageAddonResource extends Resource
'danger' => false,
])
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
BadgeColumn::make('sellability')
->label('Checkout')
->state(fn (PackageAddon $record): string => static::sellabilityLabel($record))
->colors([
'success' => fn (string $state): bool => $state === 'Verkäuflich',
'warning' => fn (string $state): bool => $state === 'Unvollständig',
'gray' => fn (string $state): bool => $state === 'Inaktiv',
]),
TextColumn::make('sort')
->label('Sort')
->sortable()
@@ -206,16 +120,16 @@ class PackageAddonResource extends Resource
->label('Aktiv'),
])
->actions([
Actions\Action::make('syncLemonSqueezy')
->label('Mit Lemon Squeezy synchronisieren')
Actions\Action::make('syncPaddle')
->label('Mit Paddle synchronisieren')
->icon('heroicon-o-cloud-arrow-up')
->action(function (PackageAddon $record) {
SyncPackageAddonToLemonSqueezy::dispatch($record->id);
SyncPackageAddonToPaddle::dispatch($record->id);
Notification::make()
->success()
->title('Lemon Squeezy-Sync gestartet')
->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
->title('Paddle-Sync gestartet')
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
->send();
}),
Actions\EditAction::make()
@@ -252,21 +166,4 @@ class PackageAddonResource extends Resource
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
];
}
protected static function sellabilityLabel(PackageAddon $record): string
{
if (! $record->active) {
return 'Inaktiv';
}
return $record->isSellableForProvider(static::addonProvider()) ? 'Verkäuflich' : 'Unvollständig';
}
protected static function addonProvider(): string
{
return (string) (
config('package-addons.provider')
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL)
);
}
}

View File

@@ -4,9 +4,12 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle;
use App\Models\Package;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
@@ -23,6 +26,7 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs as SchemaTabs;
@@ -168,31 +172,31 @@ class PackageResource extends Resource
->columnSpanFull()
->default([]),
]),
Section::make('Lemon Squeezy Billing')
Section::make('Paddle Billing')
->columns(2)
->schema([
TextInput::make('lemonsqueezy_product_id')
->label('Lemon Squeezy Produkt-ID')
TextInput::make('paddle_product_id')
->label('Paddle Produkt-ID')
->maxLength(191)
->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
->placeholder('nicht verknüpft'),
TextInput::make('lemonsqueezy_variant_id')
->label('Lemon Squeezy Variant-ID')
TextInput::make('paddle_price_id')
->label('Paddle Preis-ID')
->maxLength(191)
->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
->placeholder('nicht verknüpft'),
Placeholder::make('lemonsqueezy_sync_status')
Placeholder::make('paddle_sync_status')
->label('Sync-Status')
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '')
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '')
->columnSpanFull(),
Placeholder::make('lemonsqueezy_synced_at')
Placeholder::make('paddle_synced_at')
->label('Zuletzt synchronisiert')
->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '')
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '')
->columnSpanFull(),
Placeholder::make('lemonsqueezy_sync_error')
Placeholder::make('paddle_sync_error')
->label('Letzter Fehler')
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '')
->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '')
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
->columnSpanFull(),
]),
]);
@@ -259,15 +263,15 @@ class PackageResource extends Resource
->label('Features')
->wrap()
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
TextColumn::make('lemonsqueezy_product_id')
->label('Lemon Squeezy Produkt')
TextColumn::make('paddle_product_id')
->label('Paddle Produkt')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
TextColumn::make('lemonsqueezy_variant_id')
->label('Lemon Squeezy Variant')
TextColumn::make('paddle_price_id')
->label('Paddle Preis')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
BadgeColumn::make('lemonsqueezy_sync_status')
BadgeColumn::make('paddle_sync_status')
->label('Sync-Status')
->colors([
'success' => 'synced',
@@ -277,13 +281,13 @@ class PackageResource extends Resource
])
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('lemonsqueezy_synced_at')
TextColumn::make('paddle_synced_at')
->label('Sync am')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('lemonsqueezy_sync_error_message')
TextColumn::make('paddle_sync_error_message')
->label('Sync-Fehler')
->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
->wrap()
->toggleable(isToggledHiddenByDefault: true),
])
@@ -297,6 +301,71 @@ class PackageResource extends Resource
TrashedFilter::make(),
])
->actions([
Actions\Action::make('syncPaddle')
->label('Mit Paddle abgleichen')
->icon('heroicon-o-cloud-arrow-up')
->color('success')
->requiresConfirmation()
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing')
->action(function (Package $record) {
SyncPackageToPaddle::dispatch($record->id);
Notification::make()
->success()
->title('Paddle-Sync gestartet')
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
->send();
}),
Actions\Action::make('linkPaddle')
->label('Paddle verknüpfen')
->icon('heroicon-o-link')
->color('info')
->form([
TextInput::make('paddle_product_id')
->label('Paddle Produkt-ID')
->required()
->maxLength(191),
TextInput::make('paddle_price_id')
->label('Paddle Preis-ID')
->required()
->maxLength(191),
])
->fillForm(fn (Package $record) => [
'paddle_product_id' => $record->paddle_product_id,
'paddle_price_id' => $record->paddle_price_id,
])
->action(function (Package $record, array $data): void {
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']);
PullPackageFromPaddle::dispatch($record->id);
app(SuperAdminAuditLogger::class)->recordModelMutation(
'linked',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
);
Notification::make()
->success()
->title('Paddle-Verknüpfung gespeichert')
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
->send();
}),
Actions\Action::make('pullPaddle')
->label('Status von Paddle holen')
->icon('heroicon-o-cloud-arrow-down')
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id)
->requiresConfirmation()
->action(function (Package $record) {
PullPackageFromPaddle::dispatch($record->id);
Notification::make()
->info()
->title('Paddle-Abgleich angefordert')
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.')
->send();
}),
ViewAction::make(),
EditAction::make()
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
@@ -396,7 +465,6 @@ class PackageResource extends Resource
'unlimited_sharing' => 'Unbegrenztes Teilen',
'no_watermark' => 'Kein Wasserzeichen',
'custom_branding' => 'Eigenes Branding',
'ai_styling' => 'AI-Styling',
'custom_tasks' => 'Eigene Aufgaben',
'reseller_dashboard' => 'Reseller-Dashboard',
'advanced_analytics' => 'Erweiterte Analytics',

View File

@@ -8,7 +8,7 @@ use App\Models\PackagePurchase;
use App\Notifications\Customer\RefundReceipt;
use App\Notifications\Ops\RefundProcessed;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Services\Paddle\PaddleTransactionService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
@@ -203,15 +203,15 @@ class PurchaseResource extends Resource
$refundSuccess = true;
$errorMessage = null;
if ($record->provider === 'lemonsqueezy' && $record->provider_id) {
if ($record->provider === 'paddle' && $record->provider_id) {
try {
/** @var LemonSqueezyOrderService $lemonsqueezy */
$lemonsqueezy = App::make(LemonSqueezyOrderService::class);
$lemonsqueezy->refund($record->provider_id, ['reason' => $reason]);
/** @var PaddleTransactionService $paddle */
$paddle = App::make(PaddleTransactionService::class);
$paddle->refund($record->provider_id, ['reason' => $reason]);
} catch (\Throwable $exception) {
$refundSuccess = false;
$errorMessage = $exception->getMessage();
Log::warning('Lemon Squeezy refund failed', [
Log::warning('Paddle refund failed', [
'purchase_id' => $record->id,
'provider_id' => $record->provider_id,
'error' => $exception->getMessage(),

View File

@@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord
->visible(fn ($record): bool => ! $record->refunded)
->action(function ($record) {
$record->update(['refunded' => true]);
// TODO: Call Lemon Squeezy API for actual refund
// TODO: Call Paddle API for actual refund
app(SuperAdminAuditLogger::class)->record(
'purchase.refunded',

View File

@@ -56,7 +56,7 @@ class TenantFeedbackResource extends Resource
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.infrastructure');
return __('admin.nav.feedback_support');
}
public static function getEloquentQuery(): Builder

View File

@@ -73,10 +73,10 @@ class TenantResource extends Resource
->email()
->required()
->maxLength(255),
TextInput::make('lemonsqueezy_customer_id')
->label('Lemon Squeezy Customer ID')
TextInput::make('paddle_customer_id')
->label('Paddle Customer ID')
->maxLength(191)
->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
->nullable(),
TextInput::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue'))
@@ -135,8 +135,8 @@ class TenantResource extends Resource
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
->label('Lemon Squeezy Customer')
Tables\Columns\TextColumn::make('paddle_customer_id')
->label('Paddle Customer')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
Tables\Columns\TextColumn::make('active_reseller_package_id')

View File

@@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager
Select::make('provider')
->label('Anbieter')
->options([
'lemonsqueezy' => 'Lemon Squeezy',
'paddle' => 'Paddle',
'manual' => 'Manuell',
'free' => 'Kostenlos',
])
@@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager
TextColumn::make('provider')
->badge()
->color(fn (string $state): string => match ($state) {
'lemonsqueezy' => 'success',
'paddle' => 'success',
'manual' => 'gray',
'free' => 'success',
default => 'gray',
@@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager
]),
SelectFilter::make('provider')
->options([
'lemonsqueezy' => 'Lemon Squeezy',
'paddle' => 'Paddle',
'manual' => 'Manuell',
'free' => 'Kostenlos',
]),

View File

@@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager
DateTimePicker::make('expires_at')
->label('Ablaufdatum')
->required(),
TextInput::make('lemonsqueezy_subscription_id')
->label('Lemon Squeezy Subscription ID')
TextInput::make('paddle_subscription_id')
->label('Paddle Subscription ID')
->maxLength(191)
->helperText('Abonnement-ID aus Lemon Squeezy.')
->helperText('Abonnement-ID aus Paddle Billing.')
->nullable(),
Toggle::make('active')
->label('Aktiv'),
@@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager
TextColumn::make('expires_at')
->dateTime()
->sortable(),
TextColumn::make('lemonsqueezy_subscription_id')
->label('Lemon Squeezy Subscription')
TextColumn::make('paddle_subscription_id')
->label('Paddle Subscription')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
IconColumn::make('active')

View File

@@ -22,8 +22,8 @@ class TenantInfolist
TextEntry::make('user.full_name')
->label(__('admin.tenants.fields.owner'))
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
TextEntry::make('lemonsqueezy_customer_id')
->label('Lemon Squeezy Customer ID')
TextEntry::make('paddle_customer_id')
->label('Paddle Customer ID')
->placeholder('—'),
TextEntry::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue'))

View File

@@ -1,177 +0,0 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Models\AiEditingSetting;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class AiEditingSettingsPage extends Page
{
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
protected static ?string $cluster = RareAdminCluster::class;
protected string $view = 'filament.super-admin.pages.ai-editing-settings-page';
protected static null|string|\UnitEnum $navigationGroup = null;
protected static ?int $navigationSort = 30;
public static function getNavigationGroup(): \UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getNavigationLabel(): string
{
return 'AI Editing Settings';
}
public bool $is_enabled = true;
public string $default_provider = 'runware';
public ?string $fallback_provider = null;
public string $runware_mode = 'live';
public bool $queue_auto_dispatch = false;
public string $queue_name = 'default';
public int $queue_max_polls = 6;
/**
* @var array<int, string>
*/
public array $blocked_terms = [];
public ?string $status_message = null;
public function mount(): void
{
$settings = AiEditingSetting::current();
$this->is_enabled = (bool) $settings->is_enabled;
$this->default_provider = (string) ($settings->default_provider ?: 'runware');
$this->fallback_provider = $settings->fallback_provider ? (string) $settings->fallback_provider : null;
$this->runware_mode = (string) ($settings->runware_mode ?: 'live');
$this->queue_auto_dispatch = (bool) $settings->queue_auto_dispatch;
$this->queue_name = (string) ($settings->queue_name ?: 'default');
$this->queue_max_polls = max(1, (int) ($settings->queue_max_polls ?: 6));
$this->blocked_terms = array_values(array_filter(array_map(
static fn (mixed $term): string => trim((string) $term),
(array) $settings->blocked_terms
)));
$this->status_message = $settings->status_message ? (string) $settings->status_message : null;
}
public function form(Schema $schema): Schema
{
return $schema->schema([
Section::make('Global Availability')
->schema([
Forms\Components\Toggle::make('is_enabled')
->label('Enable AI editing globally'),
Forms\Components\Textarea::make('status_message')
->label('Disabled message')
->maxLength(255)
->rows(2)
->helperText('Shown to guest and tenant clients when the feature is disabled.')
->nullable(),
]),
Section::make('Provider')
->schema([
Forms\Components\Select::make('default_provider')
->label('Default provider')
->options([
'runware' => 'runware.ai',
])
->required(),
Forms\Components\TextInput::make('fallback_provider')
->label('Fallback provider')
->maxLength(40)
->helperText('Reserved for provider failover.'),
Forms\Components\Select::make('runware_mode')
->label('Runware mode')
->options([
'live' => 'Live API',
'fake' => 'Fake mode (internal testing)',
])
->required(),
])
->columns(2),
Section::make('Queue Orchestration')
->schema([
Forms\Components\Toggle::make('queue_auto_dispatch')
->label('Auto-dispatch jobs after request creation'),
Forms\Components\TextInput::make('queue_name')
->label('Queue name')
->required()
->maxLength(60),
Forms\Components\TextInput::make('queue_max_polls')
->label('Max provider polls')
->numeric()
->minValue(1)
->maxValue(50)
->required(),
])
->columns(2),
Section::make('Prompt Safety')
->schema([
Forms\Components\TagsInput::make('blocked_terms')
->label('Blocked prompt terms')
->helperText('Case-insensitive term match before queue dispatch.')
->placeholder('Add blocked term'),
]),
]);
}
public function save(): void
{
$this->validate();
$settings = AiEditingSetting::query()->firstOrNew(['id' => 1]);
$settings->is_enabled = $this->is_enabled;
$settings->default_provider = $this->default_provider;
$settings->fallback_provider = $this->nullableString($this->fallback_provider);
$settings->runware_mode = $this->runware_mode;
$settings->queue_auto_dispatch = $this->queue_auto_dispatch;
$settings->queue_name = $this->queue_name;
$settings->queue_max_polls = max(1, $this->queue_max_polls);
$settings->blocked_terms = array_values(array_filter(array_map(
static fn (mixed $term): string => trim((string) $term),
$this->blocked_terms
)));
$settings->status_message = $this->nullableString($this->status_message);
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'ai_editing.settings_updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
Notification::make()
->title('AI editing settings saved.')
->success()
->send();
}
private function nullableString(?string $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class InternalDocsPage extends Page
{
protected static ?string $cluster = RareAdminCluster::class;
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-book-open';
protected static null|string|UnitEnum $navigationGroup = null;
protected static ?int $navigationSort = 18;
public static function getNavigationLabel(): string
{
return __('admin.nav.internal_docs');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.infrastructure');
}
public static function getNavigationUrl(): string
{
return url('/super-admin/docs');
}
public static function getNavigationItemActiveRoutePattern(): string|array
{
return [
static::getRouteName(),
'filament.superadmin-kb.*',
];
}
}

View File

@@ -1,556 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\GuestAiEditStoreRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiBudgetGuardService;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStyleAccessService;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class EventPublicAiEditController extends BaseController
{
public function __construct(
private readonly EventJoinTokenService $joinTokenService,
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiBudgetGuardService $budgetGuard,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
private readonly AiAbuseEscalationService $abuseEscalation,
) {}
public function store(GuestAiEditStoreRequest $request, string $token, int $photo): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
$photoModel = Photo::query()
->whereKey($photo)
->where('event_id', $event->id)
->first();
if (! $photoModel) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
if ($photoModel->status !== 'approved') {
return ApiError::response(
'photo_not_eligible',
'Photo not eligible',
'Only approved photos can be used for AI edits.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
if (! $budgetDecision['allowed']) {
return ApiError::response(
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
'Budget limit reached',
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
Response::HTTP_FORBIDDEN,
[
'budget' => $budgetDecision['budget'],
]
);
}
$style = $this->resolveStyleByKey($request->input('style_key'));
if ($request->filled('style_key') && ! $style) {
return ApiError::response(
'style_not_found',
'Style not found',
'The selected style is not available.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (
$style
&& (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style))
) {
return ApiError::response(
'style_not_allowed',
'Style not allowed',
$policy['policy_message'] ?? 'This style is not allowed for this event.',
Response::HTTP_UNPROCESSABLE_ENTITY,
[
'allowed_style_keys' => $policy['allowed_style_keys'],
]
);
}
$prompt = (string) ($request->input('prompt') ?: $style?->prompt_template ?: '');
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style?->negative_prompt_template ?: '');
$providerModel = $request->input('provider_model') ?: $style?->provider_model;
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
$sessionId = $this->normalizeOptionalString((string) $request->input('session_id', ''));
$scopeKey = $this->normalizeOptionalString($deviceId ?: $sessionId) ?: 'guest';
$abuseSignal = null;
$safetyReasons = $safetyDecision->reasonCodes;
if ($safetyDecision->blocked) {
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
(int) $event->tenant_id,
(int) $event->id,
$scopeKey
);
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
}
$metadata = (array) $request->input('metadata', []);
$styleMetadata = is_array($style?->metadata) ? $style->metadata : [];
$styleRunwareMetadata = Arr::get($styleMetadata, 'runware');
if (is_array($styleRunwareMetadata)) {
$metadata['runware'] = $styleRunwareMetadata;
}
if (is_array($abuseSignal)) {
$metadata['abuse'] = $abuseSignal;
}
$metadata['budget'] = $budgetDecision['budget'];
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
$request->header('X-Idempotency-Key'),
$photoModel,
$style,
$prompt,
$deviceId,
$sessionId
);
$attributes = [
'event_id' => $event->id,
'photo_id' => $photoModel->id,
'style_id' => $style?->id,
'provider' => $this->runtimeConfig->defaultProvider(),
'provider_model' => $providerModel,
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
'safety_state' => $safetyDecision->state,
'prompt' => $prompt,
'negative_prompt' => $negativePrompt,
'input_image_path' => $photoModel->file_path,
'requested_by_device_id' => $deviceId,
'requested_by_session_id' => $sessionId,
'safety_reasons' => $safetyReasons,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $metadata,
];
$editRequest = AiEditRequest::query()->firstOrCreate(
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
$attributes
);
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
$editRequest,
$event,
$photoModel,
$style?->id,
$prompt,
$negativePrompt,
$providerModel,
$deviceId,
$sessionId
)) {
return ApiError::response(
'idempotency_conflict',
'Idempotency conflict',
'The provided idempotency key is already in use for another request.',
Response::HTTP_CONFLICT
);
}
if (
$editRequest->wasRecentlyCreated
&& ! $safetyDecision->blocked
&& $this->runtimeConfig->queueAutoDispatch()
) {
ProcessAiEditRequest::dispatch($editRequest->id)
->onQueue($this->runtimeConfig->queueName());
}
return response()->json([
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
'duplicate' => ! $editRequest->wasRecentlyCreated,
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
}
public function show(Request $request, string $token, int $requestId): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
$editRequest = AiEditRequest::query()
->with(['style', 'outputs'])
->whereKey($requestId)
->where('event_id', $event->id)
->first();
if (! $editRequest) {
return ApiError::response(
'edit_request_not_found',
'Edit request not found',
'The specified AI edit request could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
if ($editRequest->requested_by_device_id && $deviceId && $editRequest->requested_by_device_id !== $deviceId) {
return ApiError::response(
'forbidden_request_scope',
'Forbidden',
'This AI edit request belongs to another device.',
Response::HTTP_FORBIDDEN
);
}
return response()->json([
'data' => $this->serializeRequest($editRequest),
]);
}
public function styles(Request $request, string $token): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
$styles = $this->eventPolicy->filterStyles(
$event,
AiStyle::query()
->where('is_active', true)
->orderBy('sort')
->orderBy('id')
->get()
);
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
return response()->json([
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
'meta' => [
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
'allow_custom_prompt' => $policy['allow_custom_prompt'],
'allowed_style_keys' => $policy['allowed_style_keys'],
'policy_message' => $policy['policy_message'],
],
]);
}
private function resolvePublishedEvent(string $token): Event|JsonResponse
{
$joinToken = $this->joinTokenService->findActiveToken($token);
if (! $joinToken) {
return ApiError::response(
'invalid_token',
'Invalid token',
'The provided event token is invalid or expired.',
Response::HTTP_NOT_FOUND
);
}
$event = Event::query()
->whereKey($joinToken->event_id)
->where('status', 'published')
->first();
if (! $event) {
return ApiError::response(
'event_not_public',
'Event not public',
'This event is not publicly accessible.',
Response::HTTP_FORBIDDEN
);
}
return $event;
}
private function resolveStyleByKey(?string $styleKey): ?AiStyle
{
$key = $this->normalizeOptionalString((string) ($styleKey ?? ''));
if (! $key) {
return null;
}
return AiStyle::query()
->where('key', $key)
->where('is_active', true)
->first();
}
private function normalizeOptionalString(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private function resolveIdempotencyKey(
mixed $bodyKey,
mixed $headerKey,
Photo $photo,
?AiStyle $style,
string $prompt,
?string $deviceId,
?string $sessionId
): string {
$candidate = $this->normalizeOptionalString((string) ($bodyKey ?: $headerKey ?: ''));
if ($candidate) {
return Str::limit($candidate, 120, '');
}
return substr(hash('sha256', implode('|', [
(string) $photo->event_id,
(string) $photo->id,
(string) ($style?->id ?? ''),
trim($prompt),
(string) ($deviceId ?? ''),
(string) ($sessionId ?? ''),
])), 0, 120);
}
private function isIdempotencyConflict(
AiEditRequest $request,
Event $event,
Photo $photo,
?int $styleId,
string $prompt,
string $negativePrompt,
?string $providerModel,
?string $deviceId,
?string $sessionId
): bool {
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
return true;
}
if ((int) ($request->style_id ?? 0) !== (int) ($styleId ?? 0)) {
return true;
}
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
return true;
}
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
return true;
}
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
return true;
}
if ($this->normalizeOptionalString($request->requested_by_device_id) !== $this->normalizeOptionalString($deviceId)) {
return true;
}
return $this->normalizeOptionalString($request->requested_by_session_id) !== $this->normalizeOptionalString($sessionId);
}
private function serializeStyle(AiStyle $style): array
{
return [
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'version' => $style->version,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,
'provider_model' => $style->provider_model,
'requires_source_image' => $style->requires_source_image,
'is_premium' => $style->is_premium,
'metadata' => $style->metadata ?? [],
];
}
private function serializeRequest(AiEditRequest $request): array
{
return [
'id' => $request->id,
'event_id' => $request->event_id,
'photo_id' => $request->photo_id,
'style' => $request->style ? [
'id' => $request->style->id,
'key' => $request->style->key,
'name' => $request->style->name,
] : null,
'provider' => $request->provider,
'provider_model' => $request->provider_model,
'status' => $request->status,
'safety_state' => $request->safety_state,
'safety_reasons' => $request->safety_reasons ?? [],
'failure_code' => $request->failure_code,
'failure_message' => $request->failure_message,
'queued_at' => $request->queued_at?->toIso8601String(),
'started_at' => $request->started_at?->toIso8601String(),
'completed_at' => $request->completed_at?->toIso8601String(),
'outputs' => $request->outputs->map(fn ($output) => [
'id' => $output->id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'provider_url' => $output->provider_url,
'url' => $this->resolveOutputUrl(
$output->storage_disk,
$output->storage_path,
$output->provider_url
),
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'is_primary' => $output->is_primary,
'safety_state' => $output->safety_state,
'safety_reasons' => $output->safety_reasons ?? [],
'generated_at' => $output->generated_at?->toIso8601String(),
])->values(),
];
}
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
{
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
if ($resolvedStoragePath !== null) {
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
return $resolvedStoragePath;
}
$disk = $this->resolveStorageDisk($storageDisk);
try {
return Storage::disk($disk)->url($resolvedStoragePath);
} catch (\Throwable $exception) {
Log::debug('Falling back to raw AI output storage path', [
'disk' => $disk,
'path' => $resolvedStoragePath,
'error' => $exception->getMessage(),
]);
return '/'.ltrim($resolvedStoragePath, '/');
}
}
return $this->normalizeOptionalString($providerUrl);
}
private function resolveStorageDisk(?string $disk): string
{
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
return (string) config('filesystems.default', 'public');
}
return $candidate;
}
}

View File

@@ -16,9 +16,6 @@ use App\Models\GuestNotification;
use App\Models\GuestPolicySetting;
use App\Models\Photo;
use App\Models\PhotoShareLink;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\EventTasksCacheService;
@@ -44,7 +41,6 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use Symfony\Component\HttpFoundation\Response;
class EventPublicController extends BaseController
@@ -53,10 +49,6 @@ class EventPublicController extends BaseController
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
private const PREVIEW_MAX_EDGE = 1920;
private const PREVIEW_QUALITY = 86;
private ?GuestPolicySetting $guestPolicy = null;
public function __construct(
@@ -68,9 +60,6 @@ class EventPublicController extends BaseController
private readonly EventTasksCacheService $eventTasksCache,
private readonly GuestNotificationService $guestNotificationService,
private readonly PushSubscriptionService $pushSubscriptions,
private readonly AiEditingRuntimeConfig $aiEditingRuntimeConfig,
private readonly AiStylingEntitlementService $aiStylingEntitlements,
private readonly EventAiEditingPolicyService $eventAiEditingPolicy,
) {}
/**
@@ -1065,7 +1054,6 @@ class EventPublicController extends BaseController
* heading_font: ?string,
* body_font: ?string,
* font_size: string,
* welcome_message: ?string,
* logo_url: ?string,
* logo_mode: string,
* logo_value: ?string,
@@ -1105,8 +1093,12 @@ class EventPublicController extends BaseController
$brandingAllowed = $this->determineBrandingAllowed($event);
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
$sources = $brandingAllowed ? [$eventBranding] : [[]];
$sources = $brandingAllowed
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
: [[]];
$primary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
@@ -1129,7 +1121,6 @@ class EventPublicController extends BaseController
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size'];
$welcomeMessage = $this->firstStringFromSources($sources, ['welcome_message', 'welcomeMessage']);
$logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
@@ -1191,7 +1182,6 @@ class EventPublicController extends BaseController
'heading_font' => $headingFont,
'body_font' => $bodyFont,
'font_size' => $fontSize,
'welcome_message' => $welcomeMessage,
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
'logo_mode' => $logoMode,
'logo_value' => $logoValue,
@@ -1455,34 +1445,17 @@ class EventPublicController extends BaseController
}
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
{
return $this->makeSignedGalleryDownloadUrlForId($token, (int) $photo->id);
}
private function makeSignedGalleryDownloadUrlForId(string $token, int $photoId): string
{
return URL::temporarySignedRoute(
'api.v1.gallery.photos.download',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'token' => $token,
'photo' => $photoId,
'photo' => $photo->id,
]
);
}
private function galleryDownloadVariantPreference(Event $event): array
{
$settings = is_array($event->settings) ? $event->settings : [];
$configuredVariant = Arr::get($settings, 'guest_download_variant', 'preview');
if ($configuredVariant === 'original') {
return ['original'];
}
return ['preview', 'original'];
}
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
{
return URL::temporarySignedRoute(
@@ -1491,7 +1464,8 @@ class EventPublicController extends BaseController
[
'slug' => $shareLink->slug,
'variant' => $variant,
]
],
absolute: false
);
}
@@ -1764,7 +1738,6 @@ class EventPublicController extends BaseController
'name' => $event->name,
'city' => $event->city,
] : null,
'branding' => $event ? $this->resolveBrandingPayload($event) : null,
])->header('Cache-Control', 'no-store');
}
@@ -1928,12 +1901,7 @@ class EventPublicController extends BaseController
);
}
return $this->streamGalleryPhoto(
$event,
$record,
$this->galleryDownloadVariantPreference($event),
'attachment'
);
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
}
public function event(Request $request, string $token)
@@ -1985,11 +1953,6 @@ class EventPublicController extends BaseController
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
$liveShowSettings = Arr::get($settings, 'live_show', []);
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
$aiStylingEntitlement = $this->aiStylingEntitlements->resolveForEvent($event);
$aiEditingPolicy = $this->eventAiEditingPolicy->resolve($event);
$aiStylingAvailable = $this->aiEditingRuntimeConfig->isEnabled()
&& (bool) $aiStylingEntitlement['allowed']
&& (bool) $aiEditingPolicy['enabled'];
$event->loadMissing('photoboothSetting');
$policy = $this->guestPolicy();
@@ -2017,58 +1980,10 @@ class EventPublicController extends BaseController
'live_show' => [
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
],
'capabilities' => [
'ai_styling' => $aiStylingAvailable,
'ai_styling_granted_by' => $aiStylingEntitlement['granted_by'],
'ai_styling_required_feature' => $aiStylingEntitlement['required_feature'],
'ai_styling_addon_keys' => $aiStylingEntitlement['addon_keys'],
'ai_styling_event_enabled' => (bool) $aiEditingPolicy['enabled'],
],
'engagement_mode' => $engagementMode,
])->header('Cache-Control', 'no-store');
}
public function qr(Request $request, string $token): JsonResponse
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[, $joinToken] = $result;
$joinTokenValue = $joinToken->token ?? $token;
$qrCodeUrl = $joinTokenValue ? url('/e/'.$joinTokenValue) : null;
$qrCodeDataUrl = null;
if ($qrCodeUrl) {
$requestedSize = (int) $request->query('size', 360);
$size = max(120, min($requestedSize, 640));
try {
$png = QrCode::format('png')
->size($size)
->margin(1)
->errorCorrection('M')
->generate($qrCodeUrl);
$pngBinary = (string) $png;
if ($pngBinary !== '') {
$qrCodeDataUrl = 'data:image/png;base64,'.base64_encode($pngBinary);
}
} catch (\Throwable $exception) {
report($exception);
}
}
return response()->json([
'url' => $qrCodeUrl,
'qr_code_data_url' => $qrCodeDataUrl,
])->header('Cache-Control', 'no-store');
}
public function package(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
@@ -2245,15 +2160,6 @@ class EventPublicController extends BaseController
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
} elseif ($variant === 'preview') {
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'preview')->first();
$watermarked = $preferOriginals
? null
: EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_preview')->first();
$fallbackAsset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
$disk = $watermarked?->disk ?? $asset?->disk ?? $fallbackAsset?->disk;
$path = $watermarked?->path ?? $asset?->path ?? $fallbackAsset?->path ?? ($record->file_path ?? null);
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? $fallbackAsset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
} else {
$watermarked = $preferOriginals
? null
@@ -2694,15 +2600,6 @@ class EventPublicController extends BaseController
->distinct('guest_name')
->count('guest_name');
$guestCount = DB::table('photos')
->where('event_id', $eventId)
->distinct('guest_name')
->count('guest_name');
$likesCount = (int) DB::table('photos')
->where('event_id', $eventId)
->sum('likes_count');
// Tasks solved as number of photos linked to a task (proxy metric).
$tasksSolved = $engagementMode === 'photo_only'
? 0
@@ -2713,8 +2610,6 @@ class EventPublicController extends BaseController
$payload = [
'online_guests' => $onlineGuests,
'tasks_solved' => $tasksSolved,
'guest_count' => $guestCount,
'likes_count' => $likesCount,
'latest_photo_at' => $latestPhotoAt,
'engagement_mode' => $engagementMode,
];
@@ -2900,14 +2795,12 @@ class EventPublicController extends BaseController
[$locale] = $this->resolveGuestLocale($request, $event);
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
$deviceId = $this->normalizeGuestIdentifier((string) $request->header('X-Device-Id', ''));
$deviceId = $deviceId !== '' ? $deviceId : 'anon';
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$filter = $request->query('filter');
$since = $request->query('since');
$query = DB::table('photos')
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
->leftJoin('emotions', 'photos.emotion_id', '=', 'emotions.id')
->select([
'photos.id',
'photos.file_path',
@@ -2916,14 +2809,9 @@ class EventPublicController extends BaseController
'photos.emotion_id',
'photos.task_id',
'photos.guest_name',
'photos.created_by_device_id',
'photos.created_at',
'photos.ingest_source',
'tasks.title as task_title',
'emotions.name as emotion_name',
'emotions.icon as emotion_icon',
'emotions.color as emotion_color',
'emotions.id as emotion_lookup_id',
])
->where('photos.event_id', $eventId)
->where('photos.status', 'approved')
@@ -2934,50 +2822,24 @@ class EventPublicController extends BaseController
if ($filter === 'photobooth') {
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
$query->where(function ($inner) use ($deviceId) {
$inner->where('created_by_device_id', $deviceId)
->orWhere('guest_name', $deviceId);
});
$query->where('guest_name', $deviceId);
}
if ($since) {
$query->where('photos.created_at', '>', $since);
}
$rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) {
$fullUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
$rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
$thumbnailUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));
$r->file_path = $fullUrl;
$r->thumbnail_path = $thumbnailUrl;
$r->full_url = $fullUrl;
$r->thumbnail_url = $thumbnailUrl;
$r->download_url = $this->makeSignedGalleryDownloadUrlForId($token, (int) $r->id);
// Localize task title if present
if ($r->task_title) {
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
}
$emotion = null;
if ($r->emotion_id) {
$emotionName = $this->firstLocalizedValue($r->emotion_name, $fallbacks, '');
if ($emotionName !== '') {
$emotion = [
'id' => (int) ($r->emotion_lookup_id ?? $r->emotion_id),
'name' => $emotionName,
'icon' => $r->emotion_icon ?: null,
'color' => $r->emotion_color ?: null,
];
}
}
$r->emotion = $emotion;
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
$createdBy = $r->created_by_device_id ? $this->normalizeGuestIdentifier((string) $r->created_by_device_id) : '';
$r->is_mine = $deviceId !== 'anon'
&& $deviceId !== ''
&& (($createdBy !== '' && $createdBy === $deviceId) || ($createdBy === '' && (string) $r->guest_name === $deviceId));
return $r;
});
@@ -3070,159 +2932,6 @@ class EventPublicController extends BaseController
return response()->json(['liked' => true, 'likes_count' => $count]);
}
public function unlike(Request $request, int $id)
{
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
if ($deviceId === '') {
$deviceId = 'anon';
}
$photo = DB::table('photos')
->join('events', 'photos.event_id', '=', 'events.id')
->where('photos.id', $id)
->where('events.status', 'published')
->first(['photos.id', 'photos.event_id']);
if (! $photo) {
return ApiError::response(
'photo_not_found',
'Photo Not Found',
'Photo not found or event not public.',
Response::HTTP_NOT_FOUND,
['photo_id' => $id]
);
}
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
if (! $exists) {
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => false, 'likes_count' => $count]);
}
DB::beginTransaction();
try {
DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->delete();
DB::table('photos')->where('id', $id)->update([
'likes_count' => DB::raw('case when likes_count > 0 then likes_count - 1 else 0 end'),
'updated_at' => now(),
]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::warning('unlike failed', ['error' => $e->getMessage()]);
}
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => false, 'likes_count' => $count]);
}
public function destroyPhoto(Request $request, string $token, Photo $photo): JsonResponse
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$deviceId = $this->resolveDeviceIdentifier($request);
if ($deviceId === 'anonymous') {
return ApiError::response(
'photo_delete_forbidden',
'Delete Not Allowed',
'This photo cannot be deleted from this device.',
Response::HTTP_FORBIDDEN,
['photo_id' => $photo->id]
);
}
if ($photo->event_id !== (int) $event->id) {
return ApiError::response(
'photo_not_found',
'Photo Not Found',
'Photo not found or event not public.',
Response::HTTP_NOT_FOUND,
['photo_id' => $photo->id]
);
}
$ownerId = $photo->created_by_device_id
? $this->normalizeGuestIdentifier((string) $photo->created_by_device_id)
: '';
$guestName = is_string($photo->guest_name) ? $photo->guest_name : '';
$isOwner = $ownerId !== ''
? $ownerId === $deviceId
: ($guestName !== '' && $guestName === $deviceId);
if (! $isOwner) {
return ApiError::response(
'photo_delete_forbidden',
'Delete Not Allowed',
'This photo cannot be deleted from this device.',
Response::HTTP_FORBIDDEN,
['photo_id' => $photo->id]
);
}
$eventModel = Event::with(['eventPackage.package'])->find((int) $event->id);
$assets = EventMediaAsset::where('photo_id', $photo->id)->get();
foreach ($assets as $asset) {
if (! is_string($asset->path) || $asset->path === '') {
continue;
}
try {
Storage::disk($asset->disk)->delete($asset->path);
} catch (\Throwable $e) {
Log::warning('Failed to delete guest photo asset from storage', [
'asset_id' => $asset->id,
'disk' => $asset->disk,
'path' => $asset->path,
'error' => $e->getMessage(),
]);
}
}
if ($assets->isEmpty() && $eventModel) {
$fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
$paths = array_values(array_filter([
is_string($photo->path ?? null) ? $photo->path : null,
is_string($photo->thumbnail_path ?? null) ? $photo->thumbnail_path : null,
is_string($photo->file_path ?? null) ? $photo->file_path : null,
]));
if (! empty($paths)) {
Storage::disk($fallbackDisk)->delete($paths);
}
}
DB::transaction(function () use ($photo, $assets) {
$photo->likes()->delete();
PhotoShareLink::where('photo_id', $photo->id)->delete();
if ($assets->isNotEmpty()) {
EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete();
}
$photo->delete();
});
$eventPackage = $eventModel?->eventPackage;
if ($eventPackage && $eventPackage->package) {
$previousUsed = (int) $eventPackage->used_photos;
if ($previousUsed > 0) {
$eventPackage->decrement('used_photos');
$eventPackage->refresh();
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1);
}
}
return response()->json([
'message' => 'Photo deleted successfully',
'photo_id' => $photo->id,
]);
}
public function upload(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
@@ -3386,19 +3095,10 @@ class EventPublicController extends BaseController
$thumbUrl = $thumbPath
? $this->resolveDiskUrl($disk, $thumbPath)
: $this->resolveDiskUrl($disk, $path);
$previewRel = "events/{$eventId}/photos/previews/{$baseName}_preview.jpg";
$previewPath = ImageHelper::makeThumbnailOnDisk(
$disk,
$path,
$previewRel,
self::PREVIEW_MAX_EDGE,
self::PREVIEW_QUALITY
);
// Create watermarked copies (non-destructive).
$watermarkedPath = $path;
$watermarkedThumb = $thumbPath ?: $path;
$watermarkedPreview = $previewPath ?: $path;
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
if ($thumbPath) {
@@ -3411,17 +3111,6 @@ class EventPublicController extends BaseController
} else {
$watermarkedThumb = $watermarkedPath;
}
if ($previewPath) {
$watermarkedPreview = ImageHelper::copyWithWatermark(
$disk,
$previewPath,
"events/{$eventId}/photos/watermarked/{$baseName}_preview.jpg",
$watermarkConfig
) ?? $previewPath;
} else {
$watermarkedPreview = $watermarkedPath;
}
}
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
@@ -3535,23 +3224,6 @@ class EventPublicController extends BaseController
],
]);
}
if ($previewPath) {
$this->eventStorageManager->recordAsset($eventModel, $disk, $previewPath, [
'variant' => 'preview',
'mime_type' => 'image/jpeg',
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photoId,
'size_bytes' => Storage::disk($disk)->exists($previewPath)
? Storage::disk($disk)->size($previewPath)
: null,
'meta' => [
'source_variant_id' => $asset->id,
],
]);
}
if ($watermarkedThumb !== $thumbPath) {
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
'variant' => 'watermarked_thumbnail',
@@ -3568,22 +3240,6 @@ class EventPublicController extends BaseController
]);
}
if ($watermarkedPreview !== $previewPath) {
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPreview, [
'variant' => 'watermarked_preview',
'mime_type' => 'image/jpeg',
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photoId,
'size_bytes' => Storage::disk($disk)->exists($watermarkedPreview)
? Storage::disk($disk)->size($watermarkedPreview)
: null,
'meta' => [
'source_variant_id' => $watermarkedAsset?->id ?? $asset->id,
],
]);
}
DB::table('photos')
->where('id', $photoId)
->update(['media_asset_id' => $asset->id]);

View File

@@ -212,10 +212,6 @@ class LiveShowController extends BaseController
return Event::query()
->where('live_show_token', $token)
->where(function (Builder $query) {
$query->whereNull('live_show_token_expires_at')
->orWhere('live_show_token_expires_at', '>=', now());
})
->first();
}

View File

@@ -24,6 +24,12 @@ class CouponPreviewController extends Controller
$package = Package::findOrFail($data['package_id']);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.package_not_configured'),
]);
}
$tenant = Auth::user()?->tenant;
try {

View File

@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
if (! $checkout['checkout_url']) {
throw ValidationException::withMessages([
'tier_key' => __('Unable to create checkout.'),
'tier_key' => __('Unable to create Paddle checkout.'),
]);
}
@@ -46,43 +46,24 @@ class GiftVoucherCheckoutController extends Controller
public function show(Request $request): JsonResponse
{
$data = $request->validate([
'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'],
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'],
]);
$voucherQuery = GiftVoucher::query()
->where('status', '!=', GiftVoucher::STATUS_PENDING)
->where(function ($query) use ($data) {
$hasCondition = false;
$voucherQuery = GiftVoucher::query();
if (! empty($data['checkout_id'])) {
$query->where(function ($inner) use ($data) {
$inner->where('lemonsqueezy_checkout_id', $data['checkout_id'])
->orWhere('paypal_order_id', $data['checkout_id']);
});
if (! empty($data['checkout_id'])) {
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']);
}
$hasCondition = true;
}
if (! empty($data['transaction_id'])) {
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']);
}
if (! empty($data['order_id'])) {
$method = $hasCondition ? 'orWhere' : 'where';
$query->{$method}(function ($inner) use ($data) {
$inner->where('lemonsqueezy_order_id', $data['order_id'])
->orWhere('paypal_capture_id', $data['order_id'])
->orWhere('paypal_order_id', $data['order_id']);
});
$hasCondition = true;
}
if (! empty($data['code'])) {
$method = $hasCondition ? 'orWhere' : 'where';
$query->{$method}('code', strtoupper($data['code']));
}
});
if (! empty($data['code'])) {
$voucherQuery->orWhere('code', strtoupper($data['code']));
}
$voucher = $voucherQuery->latest()->firstOrFail();

View File

@@ -9,40 +9,37 @@ use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class PackageController extends Controller
{
public function __construct(
private readonly PayPalOrderService $paypalOrders,
private readonly PaddleCheckoutService $paddleCheckout,
private readonly CheckoutSessionService $sessions,
) {}
public function index(Request $request): JsonResponse
{
$type = $request->query('type', 'endcustomer');
$provider = strtolower((string) config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL));
$packages = Package::where('type', $type)
->orderBy('price')
->get();
$packages->each(function ($package) use ($provider) {
$packages->each(function ($package) {
if (is_string($package->features)) {
$decoded = json_decode($package->features, true);
$package->features = is_array($decoded) ? $decoded : [];
} elseif (! is_array($package->features)) {
$package->features = [];
return;
}
$package->setAttribute('checkout_provider', $provider);
$package->setAttribute('can_checkout', $this->canCheckoutPackage($package, $provider));
if (! is_array($package->features)) {
$package->features = [];
}
});
return response()->json([
@@ -56,7 +53,7 @@ class PackageController extends Controller
$request->validate([
'package_id' => 'required|exists:packages,id',
'type' => 'required|in:endcustomer,reseller',
'payment_method' => 'required|in:paypal',
'payment_method' => 'required|in:paddle',
'event_id' => 'nullable|exists:events,id', // For endcustomer
'success_url' => 'nullable|url',
'return_url' => 'nullable|url',
@@ -82,7 +79,7 @@ class PackageController extends Controller
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'paypal_order_id' => 'required|string',
'paddle_transaction_id' => 'required|string',
]);
$package = Package::findOrFail($request->package_id);
@@ -92,14 +89,14 @@ class PackageController extends Controller
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
$provider = 'paypal';
$provider = 'paddle';
DB::transaction(function () use ($request, $package, $tenant, $provider) {
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider' => $provider,
'provider_id' => $request->input('paypal_order_id'),
'provider_id' => $request->input('paddle_transaction_id'),
'price' => $package->price,
'type' => 'endcustomer_event',
'purchased_at' => now(),
@@ -164,14 +161,12 @@ class PackageController extends Controller
], 201);
}
public function createPayPalCheckout(Request $request): JsonResponse
public function createPaddleCheckout(Request $request): JsonResponse
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'success_url' => 'nullable|url',
'return_url' => 'nullable|url',
'cancel_url' => 'nullable|url',
'locale' => 'nullable|string|max:10',
]);
$package = Package::findOrFail($request->integer('package_id'));
@@ -186,11 +181,15 @@ class PackageController extends Controller
throw ValidationException::withMessages(['user' => 'User context missing.']);
}
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant,
]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$now = now();
@@ -202,56 +201,30 @@ class PackageController extends Controller
'legal_version' => config('app.legal_version', $now->toDateString()),
])->save();
$successUrl = $request->input('success_url') ?? $request->input('return_url');
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
$paypalReturnUrl = route('paypal.return', absolute: true);
$payload = [
'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'),
'metadata' => [
'checkout_session_id' => $session->id,
'legal_version' => $session->legal_version,
'accepted_terms' => true,
],
];
try {
$order = $this->paypalOrders->createOrder($session, $package, [
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => $request->input('locale'),
'request_id' => $session->id,
]);
} catch (PayPalException $exception) {
Log::warning('PayPal order creation failed (tenant)', [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'session_id' => $session->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']);
}
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']);
}
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
$session->forceFill([
'paypal_order_id' => $orderId,
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_status' => $order['status'] ?? null,
'paypal_approve_url' => $approveUrl,
'paypal_success_url' => $successUrl,
'paypal_cancel_url' => $cancelUrl,
'paypal_created_at' => now()->toIso8601String(),
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
$this->sessions->markRequiresCustomerAction($session, 'paypal_approval');
return response()->json([
'order_id' => $orderId,
'approve_url' => $approveUrl,
'status' => $order['status'] ?? null,
return response()->json(array_merge($checkout, [
'checkout_session_id' => $session->id,
]);
]));
}
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
@@ -266,9 +239,7 @@ class PackageController extends Controller
}
}
$checkoutUrl = $session->provider === CheckoutSession::PROVIDER_PAYPAL
? data_get($session->provider_metadata ?? [], 'paypal_approve_url')
: data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
return response()->json([
'status' => $session->status,
@@ -326,57 +297,19 @@ class PackageController extends Controller
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
{
$successUrl = $request->input('success_url') ?? $request->input('return_url');
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
$paypalReturnUrl = route('paypal.return', absolute: true);
try {
$session = $this->sessions->createOrResume($request->user(), $package, [
'tenant' => $tenant,
]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
$order = $this->paypalOrders->createOrder($session, $package, [
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => $request->input('locale'),
'request_id' => $session->id,
]);
} catch (PayPalException $exception) {
Log::warning('PayPal order creation failed (purchase)', [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']);
}
return response()->json([
'order_id' => $orderId,
'approve_url' => $this->paypalOrders->resolveApproveUrl($order),
'status' => $order['status'] ?? null,
'return_url' => $successUrl,
'cancel_url' => $cancelUrl,
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'),
'metadata' => array_filter([
'type' => $request->input('type'),
'event_id' => $request->input('event_id'),
]),
]);
}
private function canCheckoutPackage(Package $package, string $provider): bool
{
if ((float) $package->price <= 0) {
return true;
}
if ($provider === CheckoutSession::PROVIDER_LEMONSQUEEZY) {
return filled($package->lemonsqueezy_variant_id);
}
return true;
return response()->json($checkout);
}
}

View File

@@ -1,625 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\AiEditIndexRequest;
use App\Http\Requests\Tenant\AiEditStoreRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiBudgetGuardService;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStyleAccessService;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class AiEditController extends Controller
{
public function __construct(
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiBudgetGuardService $budgetGuard,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
private readonly AiAbuseEscalationService $abuseEscalation,
) {}
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$perPage = (int) $request->input('per_page', 20);
$status = (string) $request->input('status', '');
$safetyState = (string) $request->input('safety_state', '');
$query = AiEditRequest::query()
->with(['style', 'outputs'])
->where('event_id', $event->id)
->orderByDesc('created_at');
if ($status !== '') {
$query->where('status', $status);
}
if ($safetyState !== '') {
$query->where('safety_state', $safetyState);
}
$requests = $query->paginate($perPage);
return response()->json([
'data' => collect($requests->items())->map(fn (AiEditRequest $item) => $this->serializeRequest($item))->values(),
'meta' => [
'current_page' => $requests->currentPage(),
'per_page' => $requests->perPage(),
'total' => $requests->total(),
'last_page' => $requests->lastPage(),
],
]);
}
public function styles(Request $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$styles = AiStyle::query()
->where('is_active', true)
->orderBy('sort')
->orderBy('id')
->get();
$policy = $this->eventPolicy->resolve($event);
$styles = $this->eventPolicy->filterStyles($event, $styles);
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
return response()->json([
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
'meta' => [
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
'event_enabled' => $policy['enabled'],
'allow_custom_prompt' => $policy['allow_custom_prompt'],
'allowed_style_keys' => $policy['allowed_style_keys'],
'policy_message' => $policy['policy_message'],
],
]);
}
public function summary(Request $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$periodStart = now()->startOfMonth();
$periodEnd = now()->endOfMonth();
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
$statusCounts = (clone $baseQuery)
->select('status', DB::raw('count(*) as aggregate'))
->groupBy('status')
->pluck('aggregate', 'status')
->map(fn (mixed $value): int => (int) $value)
->all();
$safetyCounts = (clone $baseQuery)
->select('safety_state', DB::raw('count(*) as aggregate'))
->groupBy('safety_state')
->pluck('aggregate', 'safety_state')
->map(fn (mixed $value): int => (int) $value)
->all();
$lastRequestedAt = (clone $baseQuery)->max('created_at');
$total = array_sum($statusCounts);
$failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0));
$moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0);
$usageQuery = AiUsageLedger::query()
->where('tenant_id', $event->tenant_id)
->where('event_id', $event->id)
->where('recorded_at', '>=', $periodStart)
->where('recorded_at', '<=', $periodEnd);
$spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0);
$debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count());
$providerRunQuery = AiProviderRun::query()
->whereHas('request', fn ($query) => $query->where('event_id', $event->id))
->where('created_at', '>=', $periodStart)
->where('created_at', '<=', $periodEnd);
$providerRunTotal = (int) (clone $providerRunQuery)->count();
$providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count();
$averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0));
$failureRate = $total > 0 ? ($failedTotal / $total) : 0.0;
$moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0;
$providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0;
$failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
$latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
return response()->json([
'data' => [
'event_id' => $event->id,
'total' => $total,
'status_counts' => $statusCounts,
'safety_counts' => $safetyCounts,
'failed_total' => $failedTotal,
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
'usage' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'debit_count' => $debitCount,
'spend_usd' => round($spendUsd, 5),
],
'observability' => [
'failure_rate' => round($failureRate, 5),
'moderation_hit_rate' => round($moderationHitRate, 5),
'provider_runs_total' => $providerRunTotal,
'provider_runs_failed' => $providerRunFailed,
'provider_failure_rate' => round($providerFailureRate, 5),
'avg_provider_latency_ms' => $averageProviderLatencyMs,
'alerts' => [
'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)),
'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs,
],
],
'budget' => $budgetDecision['budget'],
],
]);
}
public function store(AiEditStoreRequest $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$photo = Photo::query()
->whereKey((int) $request->input('photo_id'))
->where('event_id', $event->id)
->first();
if (! $photo) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
$style = $this->resolveStyle($request->input('style_id'), $request->input('style_key'));
if (! $style) {
return ApiError::response(
'style_not_found',
'Style not found',
'The selected style is not available.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
if (! $budgetDecision['allowed']) {
return ApiError::response(
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
'Budget limit reached',
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
Response::HTTP_FORBIDDEN,
[
'budget' => $budgetDecision['budget'],
]
);
}
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
return ApiError::response(
'style_not_allowed',
'Style not allowed',
$policy['policy_message'] ?? 'This style is not allowed for this event.',
Response::HTTP_UNPROCESSABLE_ENTITY,
[
'allowed_style_keys' => $policy['allowed_style_keys'],
]
);
}
$prompt = (string) ($request->input('prompt') ?: $style->prompt_template ?: '');
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style->negative_prompt_template ?: '');
$providerModel = $request->input('provider_model') ?: $style->provider_model;
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$requestedByUserId = $request->user()?->id;
$scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user';
$abuseSignal = null;
$safetyReasons = $safetyDecision->reasonCodes;
if ($safetyDecision->blocked) {
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
(int) $event->tenant_id,
(int) $event->id,
$scopeKey
);
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
}
$metadata = (array) $request->input('metadata', []);
$styleMetadata = is_array($style->metadata) ? $style->metadata : [];
$styleRunwareMetadata = Arr::get($styleMetadata, 'runware');
if (is_array($styleRunwareMetadata)) {
$metadata['runware'] = $styleRunwareMetadata;
}
if (is_array($abuseSignal)) {
$metadata['abuse'] = $abuseSignal;
}
$metadata['budget'] = $budgetDecision['budget'];
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
$request->header('X-Idempotency-Key'),
$event,
$photo,
$style,
$prompt,
$requestedByUserId
);
$editRequest = AiEditRequest::query()->firstOrCreate(
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
[
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'requested_by_user_id' => $requestedByUserId,
'provider' => $this->runtimeConfig->defaultProvider(),
'provider_model' => $providerModel,
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
'safety_state' => $safetyDecision->state,
'prompt' => $prompt,
'negative_prompt' => $negativePrompt,
'input_image_path' => $photo->file_path,
'idempotency_key' => $idempotencyKey,
'safety_reasons' => $safetyReasons,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $metadata,
]
);
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
$editRequest,
$event,
$photo,
$style,
$prompt,
$negativePrompt,
$providerModel,
$requestedByUserId
)) {
return ApiError::response(
'idempotency_conflict',
'Idempotency conflict',
'The provided idempotency key is already in use for another request.',
Response::HTTP_CONFLICT
);
}
if (
$editRequest->wasRecentlyCreated
&& ! $safetyDecision->blocked
&& $this->runtimeConfig->queueAutoDispatch()
) {
ProcessAiEditRequest::dispatch($editRequest->id)
->onQueue($this->runtimeConfig->queueName());
}
return response()->json([
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
'duplicate' => ! $editRequest->wasRecentlyCreated,
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
}
public function show(Request $request, string $eventSlug, int $aiEditRequest): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$editRequest = AiEditRequest::query()
->with(['style', 'outputs'])
->whereKey($aiEditRequest)
->where('event_id', $event->id)
->first();
if (! $editRequest) {
return ApiError::response(
'edit_request_not_found',
'Edit request not found',
'The specified AI edit request could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
return response()->json([
'data' => $this->serializeRequest($editRequest),
]);
}
private function resolveTenantEventOrFail(Request $request, string $eventSlug): Event
{
$tenantId = $request->attributes->get('tenant_id');
return Event::query()
->where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
}
private function resolveStyle(mixed $styleId, mixed $styleKey): ?AiStyle
{
if ($styleId !== null) {
return AiStyle::query()
->whereKey((int) $styleId)
->where('is_active', true)
->first();
}
$key = trim((string) ($styleKey ?? ''));
if ($key === '') {
return null;
}
return AiStyle::query()
->where('key', $key)
->where('is_active', true)
->first();
}
private function resolveIdempotencyKey(
mixed $bodyKey,
mixed $headerKey,
Event $event,
Photo $photo,
AiStyle $style,
string $prompt,
mixed $requestedByUserId
): string {
$candidate = trim((string) ($bodyKey ?: $headerKey ?: ''));
if ($candidate !== '') {
return Str::limit($candidate, 120, '');
}
return substr(hash('sha256', implode('|', [
(string) $event->id,
(string) $photo->id,
(string) $style->id,
trim($prompt),
(string) ($this->normalizeUserId($requestedByUserId)),
])), 0, 120);
}
private function normalizeUserId(mixed $userId): ?string
{
if (! is_int($userId) && ! is_string($userId)) {
return null;
}
$value = trim((string) $userId);
return $value !== '' ? $value : null;
}
private function normalizeOptionalString(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private function isIdempotencyConflict(
AiEditRequest $request,
Event $event,
Photo $photo,
AiStyle $style,
string $prompt,
string $negativePrompt,
?string $providerModel,
mixed $requestedByUserId
): bool {
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
return true;
}
if ((int) ($request->style_id ?? 0) !== (int) $style->id) {
return true;
}
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
return true;
}
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
return true;
}
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
return true;
}
return $this->normalizeUserId($request->requested_by_user_id) !== $this->normalizeUserId($requestedByUserId);
}
private function serializeStyle(AiStyle $style): array
{
return [
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'version' => $style->version,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,
'provider_model' => $style->provider_model,
'requires_source_image' => $style->requires_source_image,
'is_premium' => $style->is_premium,
'metadata' => $style->metadata ?? [],
];
}
private function serializeRequest(AiEditRequest $request): array
{
return [
'id' => $request->id,
'event_id' => $request->event_id,
'photo_id' => $request->photo_id,
'style' => $request->style ? [
'id' => $request->style->id,
'key' => $request->style->key,
'name' => $request->style->name,
] : null,
'provider' => $request->provider,
'provider_model' => $request->provider_model,
'status' => $request->status,
'safety_state' => $request->safety_state,
'safety_reasons' => $request->safety_reasons ?? [],
'failure_code' => $request->failure_code,
'failure_message' => $request->failure_message,
'queued_at' => $request->queued_at?->toIso8601String(),
'started_at' => $request->started_at?->toIso8601String(),
'completed_at' => $request->completed_at?->toIso8601String(),
'outputs' => $request->outputs->map(fn ($output) => [
'id' => $output->id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'provider_url' => $output->provider_url,
'url' => $this->resolveOutputUrl(
$output->storage_disk,
$output->storage_path,
$output->provider_url
),
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'is_primary' => $output->is_primary,
'safety_state' => $output->safety_state,
'safety_reasons' => $output->safety_reasons ?? [],
'generated_at' => $output->generated_at?->toIso8601String(),
])->values(),
];
}
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
{
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
if ($resolvedStoragePath !== null) {
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
return $resolvedStoragePath;
}
$disk = $this->resolveStorageDisk($storageDisk);
try {
return Storage::disk($disk)->url($resolvedStoragePath);
} catch (\Throwable $exception) {
Log::debug('Falling back to raw AI output storage path', [
'disk' => $disk,
'path' => $resolvedStoragePath,
'error' => $exception->getMessage(),
]);
return '/'.ltrim($resolvedStoragePath, '/');
}
}
return $this->normalizeOptionalString($providerUrl);
}
private function resolveStorageDisk(?string $disk): string
{
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
return (string) config('filesystems.default', 'public');
}
return $candidate;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\CheckoutSession;
use App\Services\Addons\EventAddonCatalog;
use Illuminate\Http\JsonResponse;
@@ -13,25 +12,9 @@ class EventAddonCatalogController extends Controller
public function index(): JsonResponse
{
$provider = config('package-addons.provider')
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL);
$addons = collect($this->catalog->all())
->map(function (array $addon, string $key) use ($provider): array {
$priceId = $provider === CheckoutSession::PROVIDER_PAYPAL
? ($addon['price'] ?? null ? 'paypal' : null)
: ($addon['variant_id'] ?? null);
return [
'key' => $key,
'label' => $addon['label'] ?? null,
'price_id' => $priceId,
'increments' => $addon['increments'] ?? [],
'price' => $addon['price'] ?? null,
'currency' => $addon['currency'] ?? 'EUR',
];
})
->filter(fn (array $addon) => ! empty($addon['price_id']))
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
->values()
->all();

View File

@@ -4,13 +4,13 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
use App\Http\Requests\Tenant\EventAddonPurchaseLookupRequest;
use App\Http\Requests\Tenant\EventAddonRequest;
use App\Http\Resources\Tenant\EventResource;
use App\Models\Event;
use App\Models\EventPackageAddon;
use App\Services\Addons\EventAddonCheckoutService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
class EventAddonController extends Controller
{
@@ -52,7 +52,7 @@ class EventAddonController extends Controller
]);
}
public function purchase(EventAddonPurchaseLookupRequest $request, Event $event): JsonResponse
public function apply(EventAddonRequest $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
@@ -66,85 +66,49 @@ class EventAddonController extends Controller
);
}
$validated = $request->validated();
$addonIntent = trim((string) ($validated['addon_intent'] ?? ''));
$checkoutId = trim((string) ($validated['checkout_id'] ?? ''));
$addonKey = trim((string) ($validated['addon_key'] ?? ''));
$eventPackage = $event->eventPackage;
$baseQuery = EventPackageAddon::query()
->where('tenant_id', $tenantId)
->where('event_id', $event->id)
->with(['event:id,name,slug']);
$addon = null;
if ($addonIntent !== '') {
$addon = (clone $baseQuery)
->where('metadata->addon_intent', $addonIntent)
->orderByDesc('created_at')
->first();
}
if (! $addon && $checkoutId !== '') {
$addon = (clone $baseQuery)
->where('checkout_id', $checkoutId)
->orderByDesc('created_at')
->first();
}
if (! $addon && $addonKey !== '') {
$addon = (clone $baseQuery)
->where('addon_key', $addonKey)
->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end")
if (! $eventPackage && method_exists($event, 'eventPackages')) {
$eventPackage = $event->eventPackages()
->with('package')
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->first();
}
if (! $addon) {
$addon = (clone $baseQuery)
->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end")
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->first();
}
if (! $addon) {
if (! $eventPackage) {
return ApiError::response(
'addon_not_found',
'Add-on purchase not found',
__('Der Add-on Kauf wurde nicht gefunden.'),
404,
'event_package_missing',
'Event package missing',
__('Kein Paket ist diesem Event zugeordnet.'),
409,
['event_slug' => $event->slug ?? null]
);
}
$label = Arr::get($addon->metadata ?? [], 'label') ?? $addon->addon_key;
$data = $request->validated();
$eventPackage->fill([
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($data['extra_photos'] ?? 0),
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($data['extra_guests'] ?? 0),
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($data['extend_gallery_days'] ?? 0),
]);
if (isset($data['extend_gallery_days'])) {
$base = $eventPackage->gallery_expires_at ?? Carbon::now();
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) $data['extend_gallery_days']);
}
$eventPackage->save();
$event->load([
'eventPackage.package',
'eventPackages.package',
]);
return response()->json([
'data' => [
'id' => $addon->id,
'addon_key' => $addon->addon_key,
'label' => $label,
'quantity' => (int) ($addon->quantity ?? 1),
'status' => $addon->status,
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
'currency' => $addon->currency,
'extra_photos' => (int) $addon->extra_photos,
'extra_guests' => (int) $addon->extra_guests,
'extra_gallery_days' => (int) $addon->extra_gallery_days,
'purchased_at' => $addon->purchased_at?->toIso8601String(),
'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'),
'checkout_id' => $addon->checkout_id,
'transaction_id' => $addon->transaction_id,
'created_at' => $addon->created_at?->toIso8601String(),
'addon_intent' => Arr::get($addon->metadata ?? [], 'addon_intent'),
'event' => $addon->event ? [
'id' => $addon->event->id,
'slug' => $addon->event->slug,
'name' => $addon->event->name,
] : null,
],
'message' => __('Add-ons applied successfully.'),
'data' => new EventResource($event),
]);
}
}

View File

@@ -110,14 +110,7 @@ class EventController extends Controller
$tenantPackage = $tenant->tenantPackages()
->with('package')
->where('active', true)
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer'))
->withCount('eventPackages')
->orderBy('event_packages_count')
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
$package = null;
@@ -156,7 +149,6 @@ class EventController extends Controller
$eventServicePackage = $billingIsReseller
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
: $package;
$sourceTenantPackage = $billingIsReseller ? $billingTenantPackage : $tenantPackage;
$requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
@@ -224,13 +216,12 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin, $sourceTenantPackage) {
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
$event = Event::create($eventData);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $eventServicePackage->id,
'tenant_package_id' => $sourceTenantPackage?->id,
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
'purchased_at' => now(),
'gallery_expires_at' => $eventServicePackage->gallery_days
@@ -256,13 +247,11 @@ class EventController extends Controller
$tenant->refresh();
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
$activeResellerPackage = $tenant->getActiveResellerPackage();
return response()->json([
'message' => 'Event created successfully',
'data' => new EventResource($event),
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
'remaining_events' => $activeResellerPackage?->remaining_events ?? 0,
'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0,
], 201);
}
@@ -893,16 +882,9 @@ class EventController extends Controller
);
}
$minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event);
$expiresAtRules = ['nullable', 'date', 'after:now'];
if ($minimumExpiry) {
$expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
}
$validated = $request->validate([
'label' => ['nullable', 'string', 'max:255'],
'expires_at' => $expiresAtRules,
'expires_at' => ['nullable', 'date', 'after:now'],
'usage_limit' => ['nullable', 'integer', 'min:1'],
]);

View File

@@ -33,7 +33,7 @@ class EventJoinTokenController extends Controller
{
$this->authorizeEvent($request, $event, 'join-tokens:manage');
$validated = $this->validatePayload($request, $event);
$validated = $this->validatePayload($request);
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
'created_by' => Auth::id(),
@@ -52,7 +52,7 @@ class EventJoinTokenController extends Controller
abort(404);
}
$validated = $this->validatePayload($request, $event, true);
$validated = $this->validatePayload($request, true);
$payload = [];
@@ -115,18 +115,11 @@ class EventJoinTokenController extends Controller
}
}
private function validatePayload(Request $request, Event $event, bool $partial = false): array
private function validatePayload(Request $request, bool $partial = false): array
{
$minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event);
$expiresAtRules = [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'];
if ($minimumExpiry) {
$expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
}
$rules = [
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
'expires_at' => $expiresAtRules,
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
'metadata.layout_customization' => ['nullable', 'array'],

View File

@@ -53,7 +53,6 @@ class LiveShowLinkController extends Controller
'url' => $url,
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
'expires_at' => $event->live_show_token_expires_at?->toIso8601String(),
];
}

View File

@@ -20,7 +20,6 @@ use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -116,7 +115,6 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -323,7 +321,7 @@ class PhotoController extends Controller
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
// Generate unique filename
$extension = $this->resolvePhotoExtension($file);
$extension = $file->getClientOriginalExtension();
$filename = Str::uuid().'.'.$extension;
$path = "events/{$eventSlug}/photos/{$filename}";
@@ -565,7 +563,6 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -782,7 +779,6 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$photos = Photo::where('event_id', $event->id)
->where('status', 'pending')
@@ -1047,23 +1043,4 @@ class PhotoController extends Controller
return array_values(array_unique(array_filter($candidates)));
}
private function resolvePhotoExtension(UploadedFile $file): string
{
$extension = strtolower((string) $file->extension());
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
$extension = strtolower((string) $file->getClientOriginalExtension());
}
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
$extension = match ($file->getMimeType()) {
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg',
};
}
return $extension === 'jpeg' ? 'jpg' : $extension;
}
}

View File

@@ -119,6 +119,8 @@ class TenantAdminTokenController extends Controller
], 404);
}
$tenant->loadMissing('activeResellerPackage');
$user = $request->user();
$abilities = $user?->currentAccessToken()?->abilities ?? [];
@@ -129,7 +131,7 @@ class TenantAdminTokenController extends Controller
$fullName = trim($first.' '.$last) ?: null;
}
$activePackage = $tenant->getActiveResellerPackage();
$activePackage = $tenant->activeResellerPackage;
return response()->json([
'id' => $tenant->id,

View File

@@ -3,27 +3,22 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\BillingAddonHistoryRequest;
use App\Models\Event;
use App\Models\EventPackageAddon;
use App\Models\PackagePurchase;
use App\Services\Addons\EventAddonCatalog;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
use Dompdf\Dompdf;
use Dompdf\Options;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleCustomerPortalService;
use App\Services\Paddle\PaddleCustomerService;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class TenantBillingController extends Controller
{
public function __construct(
private readonly LemonSqueezySubscriptionService $subscriptions,
private readonly EventAddonCatalog $addonCatalog,
private readonly PaddleTransactionService $paddleTransactions,
private readonly PaddleCustomerService $paddleCustomers,
private readonly PaddleCustomerPortalService $portalSessions,
) {}
public function transactions(Request $request): JsonResponse
@@ -37,49 +32,54 @@ class TenantBillingController extends Controller
], 404);
}
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
$page = max(1, (int) $request->query('page', 1));
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
if (! $tenant->paddle_customer_id) {
try {
$this->paddleCustomers->ensureCustomerId($tenant);
} catch (\Throwable $exception) {
Log::warning('Failed to resolve Paddle customer for tenant', [
'tenant_id' => $tenant->id,
'error' => $exception->getMessage(),
]);
$paginator = PackagePurchase::query()
->where('tenant_id', $tenant->id)
->with(['package'])
->orderByDesc('purchased_at')
->orderByDesc('id')
->paginate($perPage, ['*'], 'page', $page);
return response()->json([
'data' => [],
'message' => 'Failed to resolve Paddle customer.',
], 502);
}
}
$data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) {
$totals = $this->resolvePurchaseTotals($purchase);
$transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey();
$cursor = $request->query('cursor');
$perPage = (int) $request->query('per_page', 25);
return [
'id' => $purchase->getKey(),
'status' => $purchase->refunded ? 'refunded' : 'completed',
'amount' => $totals['total'],
'currency' => $totals['currency'],
'tax' => $totals['tax'],
'provider' => $purchase->provider ?? 'paypal',
'provider_id' => $transactionId,
'package_name' => $this->resolvePackageName($purchase, $locale),
'purchased_at' => $purchase->purchased_at?->toIso8601String(),
'receipt_url' => route('api.v1.tenant.billing.transactions.receipt', [
'purchase' => $purchase->getKey(),
], absolute: false),
];
})->values();
$query = [
'per_page' => max(1, min($perPage, 100)),
];
if ($cursor) {
$query['after'] = $cursor;
}
try {
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query);
} catch (\Throwable $exception) {
Log::warning('Failed to load Paddle transactions', [
'tenant_id' => $tenant->id,
'error' => $exception->getMessage(),
]);
return response()->json([
'data' => [],
'message' => 'Failed to load Paddle transactions.',
], 502);
}
return response()->json([
'data' => $data,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'data' => $result['data'],
'meta' => $result['meta'],
]);
}
public function addons(BillingAddonHistoryRequest $request): JsonResponse
public function addons(Request $request): JsonResponse
{
$tenant = $request->attributes->get('tenant');
@@ -90,63 +90,21 @@ class TenantBillingController extends Controller
], 404);
}
$perPage = max(1, min((int) $request->validated('per_page', 25), 100));
$page = max(1, (int) $request->validated('page', 1));
$eventId = $request->validated('event_id');
$eventSlug = $request->validated('event_slug');
$status = $request->validated('status');
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
$page = max(1, (int) $request->query('page', 1));
$scopeEvent = null;
if ($eventId !== null || $eventSlug !== null) {
$scopeEventQuery = Event::query()
->where('tenant_id', $tenant->id);
if ($eventId !== null) {
$scopeEventQuery->whereKey((int) $eventId);
} elseif (is_string($eventSlug) && trim($eventSlug) !== '') {
$scopeEventQuery->where('slug', $eventSlug);
}
$scopeEvent = $scopeEventQuery->first();
if (! $scopeEvent) {
return response()->json([
'data' => [],
'message' => 'Event scope not found.',
], 404);
}
}
$query = EventPackageAddon::query()
$paginator = EventPackageAddon::query()
->where('tenant_id', $tenant->id)
->with(['event:id,name,slug']);
if ($scopeEvent) {
$query->where('event_id', $scopeEvent->id);
}
if (is_string($status) && $status !== '') {
$query->where('status', $status);
}
$paginator = $query
->with(['event:id,name,slug'])
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$addonLabels = collect($this->addonCatalog->all())
->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null])
->all();
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) use ($addonLabels) {
$label = $addon->metadata['label']
?? ($addonLabels[$addon->addon_key] ?? null)
?? $addon->addon_key;
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
return [
'id' => $addon->id,
'addon_key' => $addon->addon_key,
'label' => $label,
'label' => $addon->metadata['label'] ?? null,
'quantity' => (int) ($addon->quantity ?? 1),
'status' => $addon->status,
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
@@ -171,17 +129,6 @@ class TenantBillingController extends Controller
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'scope' => $scopeEvent ? [
'type' => 'event',
'event' => [
'id' => $scopeEvent->id,
'slug' => $scopeEvent->slug,
'name' => $scopeEvent->name,
],
] : [
'type' => 'tenant',
'event' => null,
],
],
]);
}
@@ -196,64 +143,68 @@ class TenantBillingController extends Controller
], 404);
}
$subscriptionId = null;
$customerId = null;
try {
$subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
if (! $subscriptionId) {
return response()->json([
'message' => 'No active subscription found.',
], 404);
}
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
Log::debug('Fetching Lemon Squeezy subscription portal URL', [
Log::debug('Creating Paddle customer portal session', [
'tenant_id' => $tenant->id,
'lemonsqueezy_subscription_id' => $subscriptionId,
'paddle_customer_id' => $customerId,
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
]);
$subscription = $this->subscriptions->retrieve($subscriptionId);
$session = $this->portalSessions->createSession($customerId);
} catch (\Throwable $exception) {
$context = [
'tenant_id' => $tenant->id,
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
'error' => $exception->getMessage(),
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
];
if ($exception instanceof LemonSqueezyException) {
$context['lemonsqueezy_status'] = $exception->status();
$context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0');
$context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors');
$context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id');
if ($exception instanceof PaddleException) {
$context['paddle_status'] = $exception->status();
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code');
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message');
$context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail');
$context['paddle_error_doc_url'] = Arr::get($exception->context(), 'error.documentation_url');
$context['paddle_request_id'] = Arr::get($exception->context(), 'meta.request_id');
$context['paddle_errors'] = Arr::get($exception->context(), 'error.errors');
}
Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
Log::warning('Failed to create Paddle customer portal session', [
...$context,
]);
return response()->json([
'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
'message' => 'Failed to create Paddle customer portal session.',
], 502);
}
$url = $this->subscriptions->portalUrl($subscription)
?? $this->subscriptions->updatePaymentMethodUrl($subscription);
$url = Arr::get($session, 'data.urls.general.overview')
?? Arr::get($session, 'data.urls.general')
?? Arr::get($session, 'urls.general.overview')
?? Arr::get($session, 'urls.general');
if (! $url) {
$sessionData = Arr::get($subscription, 'data');
$sessionUrls = Arr::get($subscription, 'attributes.urls');
$sessionData = Arr::get($session, 'data');
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls');
Log::warning('Lemon Squeezy subscription missing portal URL', [
Log::warning('Paddle customer portal session missing URL', [
'tenant_id' => $tenant->id,
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
'subscription_keys' => array_keys($subscription),
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
'session_keys' => array_keys($session),
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
]);
return response()->json([
'message' => 'Lemon Squeezy subscription missing portal URL.',
'message' => 'Paddle customer portal session missing URL.',
], 502);
}
@@ -261,184 +212,4 @@ class TenantBillingController extends Controller
'url' => $url,
]);
}
public function receipt(Request $request, PackagePurchase $purchase): Response
{
$tenant = $request->attributes->get('tenant');
if (! $tenant || (int) $purchase->tenant_id !== (int) $tenant->id) {
abort(404);
}
$purchase->loadMissing(['tenant.user', 'package']);
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
app()->setLocale($locale);
$totals = $this->resolvePurchaseTotals($purchase);
$currency = $totals['currency'];
$total = $totals['total'];
$tax = $totals['tax'];
$buyer = $purchase->tenant?->user;
$buyerName = $buyer?->full_name ?? $buyer?->name ?? $buyer?->email ?? '';
$buyerEmail = $buyer?->email ?? '';
$buyerAddress = $buyer?->address ?? '';
$packageName = $this->resolvePackageName($purchase, $locale);
$packageTypeLabel = $this->resolvePackageTypeLabel($purchase->package?->type);
$providerLabel = $this->resolveProviderLabel($purchase->provider);
$purchaseDate = $this->formatDate($purchase->purchased_at, $locale);
$amountFormatted = $this->formatCurrency($total, $currency, $locale);
$taxFormatted = $tax !== null ? $this->formatCurrency($tax, $currency, $locale) : null;
$totalFormatted = $amountFormatted;
$html = view('billing.receipt', [
'receiptNumber' => (string) $purchase->getKey(),
'purchaseDate' => $purchaseDate,
'packageName' => $packageName,
'packageTypeLabel' => $packageTypeLabel,
'providerLabel' => $providerLabel,
'orderId' => $purchase->provider_id ?? $purchase->getKey(),
'buyerName' => $buyerName,
'buyerEmail' => $buyerEmail,
'buyerAddress' => $buyerAddress,
'amountFormatted' => $amountFormatted,
'taxFormatted' => $taxFormatted,
'totalFormatted' => $totalFormatted,
'currency' => $currency,
'companyName' => config('app.name', 'Fotospiel'),
'companyEmail' => config('mail.from.address', 'info@fotospiel.app'),
])->render();
$options = new Options;
$options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Helvetica');
$dompdf = new Dompdf($options);
$dompdf->setPaper('A4', 'portrait');
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->render();
$pdfBinary = $dompdf->output();
$filenameStem = Str::slug($packageName ?: 'receipt');
return response($pdfBinary)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'inline; filename="receipt-'.$filenameStem.'.pdf"');
}
/**
* @return array{currency: string, total: float, tax: float|null}
*/
private function resolvePurchaseTotals(PackagePurchase $purchase): array
{
$metadata = $purchase->metadata ?? [];
$totals = $metadata['paypal_totals'] ?? $metadata['lemonsqueezy_totals'] ?? [];
$currency = $totals['currency']
?? $metadata['currency']
?? $purchase->package?->currency
?? 'EUR';
$total = array_key_exists('total', $totals)
? (float) $totals['total']
: (float) $purchase->price;
$tax = array_key_exists('tax', $totals) ? (float) $totals['tax'] : null;
return [
'currency' => strtoupper((string) $currency),
'total' => round($total, 2),
'tax' => $tax !== null ? round($tax, 2) : null,
];
}
private function resolvePackageName(PackagePurchase $purchase, string $locale): string
{
$package = $purchase->package;
if (! $package) {
return '';
}
$localized = $package->getNameForLocale($locale);
return $localized ?: (string) $package->name;
}
private function resolveProviderLabel(?string $provider): string
{
$provider = $provider ?: 'paypal';
$labelKey = 'emails.purchase.provider.'.$provider;
$label = __($labelKey);
if ($label === $labelKey) {
return ucfirst($provider);
}
return $label;
}
private function resolvePackageTypeLabel(?string $type): string
{
$type = $type ?: 'endcustomer';
$labelKey = 'emails.purchase.package_type.'.$type;
$label = __($labelKey);
if ($label === $labelKey) {
return ucfirst($type);
}
return $label;
}
private function formatCurrency(float $amount, string $currency, string $locale): string
{
$formatter = class_exists(\NumberFormatter::class)
? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY)
: null;
if ($formatter) {
$formatted = $formatter->formatCurrency($amount, $currency);
if ($formatted !== false) {
return $formatted;
}
}
$symbol = match (strtoupper($currency)) {
'EUR' => '€',
'USD' => '$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amount, 2, ',', '.');
}
private function formatDate(?\Carbon\CarbonInterface $date, string $locale): string
{
if (! $date) {
return '';
}
$localized = $date->locale($locale);
if (str_starts_with($locale, 'en')) {
return $localized->translatedFormat('F j, Y');
}
return $localized->translatedFormat('d. F Y');
}
private function mapLocale(string $locale): string
{
$normalized = strtolower(str_replace('_', '-', $locale));
return match (true) {
str_starts_with($normalized, 'de') => 'de_DE',
str_starts_with($normalized, 'en') => 'en_US',
default => 'de_DE',
};
}
}

View File

@@ -31,21 +31,18 @@ class TenantPackageController extends Controller
->get();
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
$linkedEventPackages = $this->resolveLinkedEventPackages($tenant->id, $packages->pluck('id')->all());
$packages->each(function (TenantPackage $package) use ($usageEventPackage, $linkedEventPackages): void {
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
$eventPackage = $package->active ? $usageEventPackage : null;
$this->hydratePackageSnapshot($package, $eventPackage);
$this->attachUsageEvents($package, $linkedEventPackages);
});
$activePackage = $tenant->getActiveResellerPackage();
$activePackage = $tenant->activeResellerPackage?->load('package');
if (! ($activePackage instanceof TenantPackage)) {
$activePackage = $packages->firstWhere('active', true);
} else {
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
$this->attachUsageEvents($activePackage, $linkedEventPackages);
}
return response()->json([
@@ -55,79 +52,6 @@ class TenantPackageController extends Controller
]);
}
/**
* @param array<int, int> $tenantPackageIds
* @return array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}>
*/
private function resolveLinkedEventPackages(int $tenantId, array $tenantPackageIds): array
{
if ($tenantPackageIds === []) {
return [];
}
$eventPackages = EventPackage::query()
->whereIn('tenant_package_id', $tenantPackageIds)
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId))
->with(['event:id,slug,name,date,status'])
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->get()
->groupBy('tenant_package_id');
$result = [];
foreach ($eventPackages as $tenantPackageId => $groupedPackages) {
$current = $groupedPackages
->first(function (EventPackage $eventPackage) {
return $eventPackage->gallery_expires_at && $eventPackage->gallery_expires_at->isFuture();
});
$result[(int) $tenantPackageId] = [
'current' => $current,
'last' => $groupedPackages->first(),
'count' => $groupedPackages->count(),
];
}
return $result;
}
/**
* @param array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}> $linkedEventPackages
*/
private function attachUsageEvents(TenantPackage $package, array $linkedEventPackages): void
{
$usage = $linkedEventPackages[$package->id] ?? null;
if (! $usage) {
$package->linked_events_count = 0;
$package->current_event = null;
$package->last_event = null;
return;
}
$package->linked_events_count = $usage['count'];
$package->current_event = $this->formatLinkedEvent($usage['current']);
$package->last_event = $this->formatLinkedEvent($usage['last']);
}
private function formatLinkedEvent(?EventPackage $eventPackage): ?array
{
if (! $eventPackage || ! $eventPackage->event) {
return null;
}
return [
'id' => $eventPackage->event->id,
'slug' => $eventPackage->event->slug,
'name' => $eventPackage->event->name,
'status' => $eventPackage->event->status,
'event_date' => $eventPackage->event->date?->toIso8601String(),
'linked_at' => $eventPackage->purchased_at?->toIso8601String(),
];
}
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
{
$pkg = $package->package;

View File

@@ -157,10 +157,6 @@ class AuthenticatedSessionController extends Controller
return null;
}
if (str_starts_with($candidate, '//')) {
return null;
}
if (str_starts_with($candidate, '/')) {
return $candidate;
}
@@ -174,7 +170,7 @@ class AuthenticatedSessionController extends Controller
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
return null;
}
@@ -226,7 +222,7 @@ class AuthenticatedSessionController extends Controller
$scheme = $parsed['scheme'] ?? null;
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
return '/event-admin/dashboard';
}
@@ -269,15 +265,6 @@ class AuthenticatedSessionController extends Controller
return $decoded;
}
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
private function rememberTenantAdminTarget(Request $request, ?string $target): void
{
$user = Auth::user();

View File

@@ -15,8 +15,8 @@ use App\Models\Tenant;
use App\Models\User;
use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleTransactionService;
use App\Support\CheckoutRequestContext;
use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages;
@@ -74,11 +74,9 @@ class CheckoutController extends Controller
'error' => $facebookError,
'profile' => $facebookProfile,
],
'paypal' => [
'client_id' => config('services.paypal.client_id'),
'currency' => config('checkout.currency', 'EUR'),
'intent' => 'capture',
'locale' => app()->getLocale(),
'paddle' => [
'environment' => config('paddle.environment'),
'client_token' => config('paddle.client_token'),
],
]);
}
@@ -273,9 +271,9 @@ class CheckoutController extends Controller
CheckoutSession $session,
CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment,
LemonSqueezyOrderService $orders,
PaddleTransactionService $transactions,
): JsonResponse {
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
$session->refresh();
@@ -290,56 +288,56 @@ class CheckoutController extends Controller
CheckoutSession $session,
CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment,
LemonSqueezyOrderService $orders,
PaddleTransactionService $transactions,
): JsonResponse {
$validated = $request->validated();
$orderId = $validated['order_id'] ?? null;
$transactionId = $validated['transaction_id'] ?? null;
$checkoutId = $validated['checkout_id'] ?? null;
$metadata = $session->provider_metadata ?? [];
$metadataUpdated = false;
if ($orderId) {
$session->lemonsqueezy_order_id = $orderId;
$metadata['lemonsqueezy_order_id'] = $orderId;
if ($transactionId) {
$session->paddle_transaction_id = $transactionId;
$metadata['paddle_transaction_id'] = $transactionId;
$metadataUpdated = true;
}
if ($checkoutId) {
$metadata['lemonsqueezy_checkout_id'] = $checkoutId;
$metadata['paddle_checkout_id'] = $checkoutId;
$metadataUpdated = true;
}
if ($metadataUpdated) {
$metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String();
$metadata['paddle_client_event_at'] = now()->toIso8601String();
$session->provider_metadata = $metadata;
$session->save();
}
if (app()->environment('local')
&& $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY
&& $session->provider === CheckoutSession::PROVIDER_PADDLE
&& ! in_array($session->status, [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_FAILED,
CheckoutSession::STATUS_CANCELLED,
], true)
&& ($orderId || $checkoutId)
&& ($transactionId || $checkoutId)
) {
$sessions->markProcessing($session, array_filter([
'lemonsqueezy_status' => 'paid',
'lemonsqueezy_order_id' => $orderId,
'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(),
'paddle_status' => 'completed',
'paddle_transaction_id' => $transactionId,
'paddle_local_confirmed_at' => now()->toIso8601String(),
]));
$assignment->finalise($session, [
'source' => 'lemonsqueezy_local',
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
'provider_reference' => $orderId ?? $checkoutId,
'source' => 'paddle_local',
'provider' => CheckoutSession::PROVIDER_PADDLE,
'provider_reference' => $transactionId ?? $checkoutId,
]);
$sessions->markCompleted($session);
} else {
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
}
$session->refresh();
@@ -421,13 +419,13 @@ class CheckoutController extends Controller
return $price <= 0;
}
private function attemptLemonSqueezyRecovery(
private function attemptPaddleRecovery(
CheckoutSession $session,
CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment,
LemonSqueezyOrderService $orders
PaddleTransactionService $transactions
): void {
if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
return;
}
@@ -440,7 +438,7 @@ class CheckoutController extends Controller
}
$metadata = $session->provider_metadata ?? [];
$lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null;
$lastPollAt = $metadata['paddle_poll_at'] ?? null;
$now = now();
if ($lastPollAt) {
@@ -454,31 +452,39 @@ class CheckoutController extends Controller
}
}
$checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null;
$orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null;
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null;
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null;
if (! $checkoutId && ! $orderId) {
Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [
if (! $checkoutId && ! $transactionId) {
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [
'session_id' => $session->id,
]);
}
$metadata['lemonsqueezy_poll_at'] = $now->toIso8601String();
$metadata['paddle_poll_at'] = $now->toIso8601String();
$session->forceFill([
'provider_metadata' => $metadata,
])->save();
try {
$order = $orderId ? $orders->retrieve($orderId) : null;
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null;
if (! $order && $checkoutId) {
$order = $orders->findByCheckoutId($checkoutId);
if (! $transaction && $checkoutId) {
$transaction = $transactions->findByCheckoutId($checkoutId);
}
} catch (LemonSqueezyException $exception) {
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
if (! $transaction) {
$transaction = $transactions->findByCustomData([
'checkout_session_id' => $session->id,
'package_id' => (string) $session->package_id,
'tenant_id' => (string) $session->tenant_id,
]);
}
} catch (PaddleException $exception) {
Log::warning('[Checkout] Paddle recovery failed', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'order_id' => $orderId,
'transaction_id' => $transactionId,
'status' => $exception->status(),
'message' => $exception->getMessage(),
'context' => $exception->context(),
@@ -486,77 +492,77 @@ class CheckoutController extends Controller
return;
} catch (\Throwable $exception) {
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
Log::warning('[Checkout] Paddle recovery failed', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'order_id' => $orderId,
'transaction_id' => $transactionId,
'message' => $exception->getMessage(),
]);
return;
}
if (! $order) {
Log::info('[Checkout] Lemon Squeezy recovery: order not found', [
if (! $transaction) {
Log::info('[Checkout] Paddle recovery: transaction not found', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'order_id' => $orderId,
'transaction_id' => $transactionId,
]);
return;
}
$status = strtolower((string) data_get($order, 'attributes.status', ''));
$resolvedOrderId = $orderId ?: data_get($order, 'id');
$status = strtolower((string) ($transaction['status'] ?? ''));
$transactionId = $transactionId ?: ($transaction['id'] ?? null);
if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) {
if ($transactionId && $session->paddle_transaction_id !== $transactionId) {
$session->forceFill([
'lemonsqueezy_order_id' => $resolvedOrderId,
'paddle_transaction_id' => $transactionId,
])->save();
}
if (in_array($status, ['paid', 'completed'], true)) {
if ($status === 'completed') {
$sessions->markProcessing($session, [
'lemonsqueezy_status' => $status,
'lemonsqueezy_order_id' => $resolvedOrderId,
'lemonsqueezy_recovered_at' => $now->toIso8601String(),
'paddle_status' => $status,
'paddle_transaction_id' => $transactionId,
'paddle_recovered_at' => $now->toIso8601String(),
]);
$assignment->finalise($session, [
'source' => 'lemonsqueezy_poll',
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
'provider_reference' => $resolvedOrderId,
'payload' => $order,
'source' => 'paddle_poll',
'provider' => CheckoutSession::PROVIDER_PADDLE,
'provider_reference' => $transactionId,
'payload' => $transaction,
]);
$sessions->markCompleted($session, $now);
Log::info('[Checkout] Lemon Squeezy session recovered via API', [
Log::info('[Checkout] Paddle session recovered via API', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'order_id' => $resolvedOrderId,
'transaction_id' => $transactionId,
]);
return;
}
if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) {
$sessions->markFailed($session, 'lemonsqueezy_'.$status);
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) {
$sessions->markFailed($session, 'paddle_'.$status);
Log::info('[Checkout] Lemon Squeezy order failed', [
Log::info('[Checkout] Paddle transaction failed', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'order_id' => $resolvedOrderId,
'transaction_id' => $transactionId,
'status' => $status,
]);
return;
}
Log::info('[Checkout] Lemon Squeezy order pending', [
Log::info('[Checkout] Paddle transaction pending', [
'session_id' => $session->id,
'checkout_id' => $checkoutId,
'order_id' => $resolvedOrderId,
'transaction_id' => $transactionId,
'status' => $status,
]);
}

View File

@@ -13,8 +13,7 @@ use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use App\Services\Paddle\PaddleCheckoutService;
use App\Support\CheckoutRequestContext;
use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages;
@@ -42,7 +41,7 @@ class MarketingController extends Controller
public function __construct(
private readonly CheckoutSessionService $checkoutSessions,
private readonly PayPalOrderService $paypalOrders,
private readonly PaddleCheckoutService $paddleCheckout,
private readonly CouponService $coupons,
private readonly GiftVoucherCheckoutService $giftVouchers,
) {}
@@ -195,6 +194,16 @@ class MarketingController extends Controller
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
}
if (! $package->paddle_price_id) {
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
return redirect()->route('packages', [
'locale' => app()->getLocale(),
'highlight' => $package->slug,
])
->with('error', __('marketing.packages.paddle_not_configured'));
}
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
@@ -202,7 +211,7 @@ class MarketingController extends Controller
]
));
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$now = now();
@@ -214,71 +223,52 @@ class MarketingController extends Controller
'legal_version' => $this->resolveLegalVersion(),
])->save();
$appliedDiscountId = null;
if ($couponCode) {
try {
$preview = $this->coupons->preview($couponCode, $package, $tenant);
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
$request->session()->forget('marketing.checkout.coupon');
} catch (ValidationException $exception) {
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
}
}
$successUrl = route('marketing.success', [
'locale' => app()->getLocale(),
'packageId' => $package->id,
]);
$cancelUrl = route('packages', [
'locale' => app()->getLocale(),
'highlight' => $package->slug,
]);
try {
$checkout = $this->paypalOrders->createOrder($session, $package, [
'return_url' => route('paypal.return', absolute: true),
'cancel_url' => route('paypal.return', absolute: true),
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
'success_url' => route('marketing.success', [
'locale' => app()->getLocale(),
'request_id' => $session->id,
]);
} catch (PayPalException $exception) {
Log::warning('PayPal checkout failed', [
'package_id' => $package->id,
'tenant_id' => $tenant->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
throw ValidationException::withMessages([
'paypal' => __('marketing.packages.paypal_checkout_failed'),
]);
}
$orderId = $checkout['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
throw ValidationException::withMessages([
'paypal' => __('marketing.packages.paypal_checkout_failed'),
]);
}
$redirectUrl = $this->paypalOrders->resolveApproveUrl($checkout);
'packageId' => $package->id,
]),
'return_url' => route('packages', [
'locale' => app()->getLocale(),
'highlight' => $package->slug,
]),
'metadata' => [
'checkout_session_id' => $session->id,
'coupon_code' => $couponCode,
'legal_version' => $session->legal_version,
'accepted_terms' => (bool) $session->accepted_terms_at,
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
],
'discount_id' => $appliedDiscountId,
]);
$session->forceFill([
'paypal_order_id' => $orderId,
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_status' => $checkout['status'] ?? null,
'paypal_approve_url' => $redirectUrl,
'paypal_success_url' => $successUrl,
'paypal_cancel_url' => $cancelUrl,
'paypal_created_at' => now()->toIso8601String(),
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
$this->checkoutSessions->markRequiresCustomerAction($session, 'paypal_approval');
$redirectUrl = $checkout['checkout_url'] ?? null;
if (! $redirectUrl) {
throw ValidationException::withMessages([
'paypal' => __('marketing.packages.paypal_checkout_failed'),
'paddle' => __('marketing.packages.paddle_checkout_failed'),
]);
}
@@ -419,15 +409,7 @@ class MarketingController extends Controller
public function demo()
{
$event = Event::query()
->where(function ($query) {
$query
->where('settings->marketing_demo', true)
->orWhere('settings->marketing_demo', 'true')
->orWhere('settings->marketing_demo', '1')
->orWhere('settings->demo', true)
->orWhere('settings->demo', 'true')
->orWhere('settings->demo', '1');
})
->where('settings->marketing_demo', true)
->latest('id')
->first();
$joinToken = null;

View File

@@ -2,27 +2,27 @@
namespace App\Http\Controllers;
use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest;
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Services\Paddle\PaddleCheckoutService;
use App\Support\CheckoutRequestContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LemonSqueezyCheckoutController extends Controller
class PaddleCheckoutController extends Controller
{
public function __construct(
private readonly LemonSqueezyCheckoutService $checkout,
private readonly PaddleCheckoutService $checkout,
private readonly CheckoutSessionService $sessions,
private readonly CouponService $coupons,
) {}
public function create(LemonSqueezyCheckoutRequest $request): JsonResponse
public function create(PaddleCheckoutRequest $request): JsonResponse
{
$data = $request->validated();
@@ -35,8 +35,8 @@ class LemonSqueezyCheckoutController extends Controller
$package = Package::findOrFail((int) $data['package_id']);
if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$session = $this->sessions->createOrResume($user, $package, array_merge(
@@ -46,7 +46,7 @@ class LemonSqueezyCheckoutController extends Controller
]
));
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$now = now();
@@ -59,18 +59,44 @@ class LemonSqueezyCheckoutController extends Controller
])->save();
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
$discountId = null;
if ($couponCode !== '') {
$preview = $this->coupons->preview($couponCode, $package, $tenant);
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
$discountId = $preview['coupon']->paddle_discount_id;
}
if (app()->environment('local')) {
$checkout = $this->simulateLocalCheckout($session, $package, $couponCode);
if ($request->boolean('inline') && $discountId === null) {
$metadata = array_merge($session->provider_metadata ?? [], [
'mode' => 'inline',
]);
return response()->json(array_merge($checkout, [
$session->forceFill([
'provider_metadata' => $metadata,
])->save();
return response()->json([
'checkout_session_id' => $session->id,
]));
'mode' => 'inline',
'items' => [
[
'priceId' => $package->paddle_price_id,
'quantity' => 1,
],
],
'custom_data' => [
'tenant_id' => (string) $tenant->id,
'package_id' => (string) $package->id,
'checkout_session_id' => (string) $session->id,
'legal_version' => $session->legal_version,
'accepted_terms' => '1',
],
'customer' => array_filter([
'email' => $user->email,
'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null),
]),
]);
}
$checkout = $this->checkout->createCheckout($tenant, $package, [
@@ -82,17 +108,15 @@ class LemonSqueezyCheckoutController extends Controller
'legal_version' => $session->legal_version,
'accepted_terms' => true,
],
'discount_code' => $couponCode ?: null,
'customer_email' => $user?->email,
'customer_name' => trim(($user?->first_name ?? '').' '.($user?->last_name ?? '')) ?: ($user?->name ?? null),
'discount_id' => $discountId,
]);
$session->forceFill([
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
@@ -101,36 +125,6 @@ class LemonSqueezyCheckoutController extends Controller
]));
}
/**
* @return array{checkout_url: string|null, id: string, order_id: string, simulated: bool}
*/
protected function simulateLocalCheckout(CheckoutSession $session, Package $package, string $couponCode): array
{
$checkoutId = 'chk_'.Str::uuid();
$orderId = 'order_'.Str::uuid();
$session->forceFill([
'lemonsqueezy_checkout_id' => $checkoutId,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'lemonsqueezy_checkout_id' => $checkoutId,
'lemonsqueezy_order_id' => $orderId,
'lemonsqueezy_status' => 'paid',
'lemonsqueezy_local_simulated_at' => now()->toIso8601String(),
'coupon_code' => $couponCode ?: null,
])),
])->save();
return [
'checkout_url' => route('marketing.success', [
'locale' => app()->getLocale(),
'packageId' => $package->id,
], absolute: true),
'id' => $checkoutId,
'order_id' => $orderId,
'simulated' => true,
];
}
protected function resolveLegalVersion(): string
{
return config('app.legal_version', now()->toDateString());

View File

@@ -2,32 +2,35 @@
namespace App\Http\Controllers;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class LemonSqueezyReturnController extends Controller
class PaddleReturnController extends Controller
{
public function __construct(private readonly LemonSqueezyOrderService $orders) {}
public function __construct(private readonly PaddleTransactionService $transactions) {}
/**
* Handle the incoming request.
*/
public function __invoke(Request $request): RedirectResponse
{
$orderId = $this->resolveOrderId($request);
$transactionId = $this->resolveTransactionId($request);
$fallback = $this->resolveFallbackUrl();
if (! $orderId) {
if (! $transactionId) {
return redirect()->to($fallback);
}
try {
$order = $this->orders->retrieve($orderId);
} catch (LemonSqueezyException $exception) {
Log::warning('Lemon Squeezy return failed to load order', [
'order_id' => $orderId,
$transaction = $this->transactions->retrieve($transactionId);
} catch (PaddleException $exception) {
Log::warning('Paddle return failed to load transaction', [
'transaction_id' => $transactionId,
'error' => $exception->getMessage(),
'status' => $exception->status(),
]);
@@ -35,10 +38,10 @@ class LemonSqueezyReturnController extends Controller
return redirect()->to($fallback);
}
$customData = $this->extractCustomData($order);
$status = Str::lower((string) Arr::get($order, 'attributes.status', ''));
$customData = $this->extractCustomData($transaction);
$status = Str::lower((string) ($transaction['status'] ?? ''));
$successUrl = $customData['success_url'] ?? null;
$cancelUrl = $customData['return_url'] ?? null;
$cancelUrl = $customData['cancel_url'] ?? $customData['return_url'] ?? null;
$target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl;
$target = $this->resolveSafeRedirect($target, $fallback);
@@ -46,10 +49,11 @@ class LemonSqueezyReturnController extends Controller
return redirect()->to($target);
}
protected function resolveOrderId(Request $request): ?string
protected function resolveTransactionId(Request $request): ?string
{
$candidate = $request->query('order_id')
?? $request->query('order');
$candidate = $request->query('_ptxn')
?? $request->query('ptxn')
?? $request->query('transaction_id');
if (! is_string($candidate) || $candidate === '') {
return null;
@@ -64,19 +68,33 @@ class LemonSqueezyReturnController extends Controller
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $transaction
* @return array<string, mixed>
*/
protected function extractCustomData(array $order): array
protected function extractCustomData(array $transaction): array
{
$customData = Arr::get($order, 'attributes.custom_data', []);
$customData = Arr::get($transaction, 'custom_data', []);
return is_array($customData) ? $customData : [];
if (! is_array($customData)) {
$customData = [];
}
$legacy = Arr::get($transaction, 'customData');
if (is_array($legacy)) {
$customData = array_merge($customData, $legacy);
}
$metadata = Arr::get($transaction, 'metadata');
if (is_array($metadata)) {
$customData = array_merge($customData, $metadata);
}
return $customData;
}
protected function isSuccessStatus(string $status): bool
{
return in_array($status, ['paid', 'completed'], true);
return in_array($status, ['completed', 'paid'], true);
}
protected function resolveSafeRedirect(?string $target, string $fallback): string

View File

@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class LemonSqueezyWebhookController extends Controller
class PaddleWebhookController extends Controller
{
public function __construct(
private readonly CheckoutWebhookService $webhooks,
@@ -22,7 +22,7 @@ class LemonSqueezyWebhookController extends Controller
{
try {
if (! $this->verify($request)) {
Log::warning('Lemon Squeezy webhook signature verification failed');
Log::warning('Paddle webhook signature verification failed');
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
}
@@ -33,27 +33,29 @@ class LemonSqueezyWebhookController extends Controller
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
}
$eventType = $payload['meta']['event_name'] ?? $request->headers->get('X-Event-Name');
$eventId = $payload['meta']['event_id'] ?? $payload['data']['id'] ?? null;
$eventType = $payload['event_type'] ?? null;
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
$webhookEvent = $this->recorder->recordReceived(
'lemonsqueezy',
'paddle',
$eventId ? (string) $eventId : null,
$eventType ? (string) $eventType : null,
);
$handled = false;
$this->logDev('Lemon Squeezy webhook received', [
$this->logDev('Paddle webhook received', [
'event_type' => $eventType,
'order_id' => data_get($payload, 'data.id'),
'has_signature' => (string) $request->headers->get('X-Signature', '') !== '',
'checkout_id' => data_get($payload, 'data.checkout_id'),
'transaction_id' => data_get($payload, 'data.id'),
'has_billing_signature' => (string) $request->headers->get('Paddle-Signature', '') !== '',
'has_legacy_signature' => (string) $request->headers->get('Paddle-Webhook-Signature', '') !== '',
]);
if ($eventType) {
$handled = $this->webhooks->handleLemonSqueezyEvent($payload);
$handled = $this->webhooks->handlePaddleEvent($payload);
$handled = $this->addonWebhooks->handle($payload) || $handled;
}
Log::info('Lemon Squeezy webhook processed', [
Log::info('Paddle webhook processed', [
'event_type' => $eventType,
'handled' => $handled,
]);
@@ -69,13 +71,13 @@ class LemonSqueezyWebhookController extends Controller
} catch (\Throwable $exception) {
$eventId = $this->captureWebhookException($exception);
Log::error('Lemon Squeezy webhook processing failed', [
Log::error('Paddle webhook processing failed', [
'message' => $exception->getMessage(),
'event_type' => (string) data_get($request->json()->all(), 'meta.event_name'),
'event_type' => (string) $request->json('event_type'),
'sentry_event_id' => $eventId,
]);
$this->logDev('Lemon Squeezy webhook error payload', $this->reducePayload($request->json()->all()));
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
if (isset($webhookEvent)) {
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
@@ -87,33 +89,85 @@ class LemonSqueezyWebhookController extends Controller
protected function verify(Request $request): bool
{
$secret = config('lemonsqueezy.webhook_secret');
$secret = config('paddle.webhook_secret');
if (! $secret) {
// Allow processing in sandbox or when secret not configured
return true;
}
$signature = (string) $request->headers->get('X-Signature', '');
$billingSignature = (string) $request->headers->get('Paddle-Signature', '');
if ($billingSignature !== '') {
$parts = $this->parseSignatureHeader($billingSignature);
$timestamp = $parts['ts'] ?? null;
$hash = $parts['h1'] ?? null;
if (! $timestamp || ! $hash) {
$this->logDev('Paddle webhook signature missing parts', [
'has_timestamp' => (bool) $timestamp,
'has_hash' => (bool) $hash,
]);
return false;
}
$payload = $request->getContent();
$expected = hash_hmac('sha256', $timestamp.':'.$payload, $secret);
$valid = hash_equals($expected, $hash);
if (! $valid) {
$this->logDev('Paddle webhook signature mismatch (billing)', [
'timestamp' => $timestamp,
]);
}
return $valid;
}
$payload = $request->getContent();
$signature = (string) $request->headers->get('Paddle-Webhook-Signature', '');
if ($signature === '') {
$this->logDev('Lemon Squeezy webhook missing signature header', [
'header' => 'X-Signature',
$this->logDev('Paddle webhook missing signature header', [
'header' => 'Paddle-Webhook-Signature',
]);
return false;
}
$payload = $request->getContent();
$expected = hash_hmac('sha256', $payload, $secret);
$valid = hash_equals($expected, $signature);
if (! $valid) {
$this->logDev('Lemon Squeezy webhook signature mismatch', []);
$this->logDev('Paddle webhook signature mismatch (legacy)', []);
}
return $valid;
}
/**
* @return array<string, string>
*/
protected function parseSignatureHeader(string $header): array
{
$parts = [];
foreach (explode(',', $header) as $chunk) {
$chunk = trim($chunk);
if ($chunk === '' || ! str_contains($chunk, '=')) {
continue;
}
[$key, $value] = array_map('trim', explode('=', $chunk, 2));
if ($key !== '' && $value !== '') {
$parts[$key] = $value;
}
}
return $parts;
}
/**
* @param array<string, mixed> $context
*/
@@ -123,7 +177,7 @@ class LemonSqueezyWebhookController extends Controller
return;
}
Log::info('[LemonSqueezyWebhook] '.$message, $context);
Log::info('[PaddleWebhook] '.$message, $context);
}
/**
@@ -132,11 +186,12 @@ class LemonSqueezyWebhookController extends Controller
protected function reducePayload(array $payload): array
{
return array_filter([
'event_type' => data_get($payload, 'meta.event_name'),
'order_id' => data_get($payload, 'data.id'),
'status' => data_get($payload, 'data.attributes.status'),
'customer_id' => data_get($payload, 'data.attributes.customer_id'),
'has_custom_data' => is_array(data_get($payload, 'meta.custom_data')),
'event_type' => $payload['event_type'] ?? null,
'transaction_id' => data_get($payload, 'data.id'),
'checkout_id' => data_get($payload, 'data.checkout_id'),
'status' => data_get($payload, 'data.status'),
'customer_id' => data_get($payload, 'data.customer_id'),
'has_custom_data' => is_array(data_get($payload, 'data.custom_data')),
], static fn ($value) => $value !== null);
}

View File

@@ -1,150 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\EventPackageAddon;
use App\Services\Addons\EventAddonPurchaseService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PayPalAddonReturnController extends Controller
{
public function __construct(
private readonly PayPalOrderService $orders,
private readonly EventAddonPurchaseService $addons,
) {}
public function __invoke(Request $request): RedirectResponse
{
$orderId = $this->resolveOrderId($request);
$fallback = $this->resolveFallbackUrl();
if (! $orderId) {
return redirect()->to($fallback);
}
$addon = EventPackageAddon::query()
->where('checkout_id', $orderId)
->first();
if (! $addon) {
return redirect()->to($fallback);
}
$successUrl = Arr::get($addon->metadata ?? [], 'paypal_success_url')
?? Arr::get($addon->metadata ?? [], 'success_url');
$cancelUrl = Arr::get($addon->metadata ?? [], 'paypal_cancel_url')
?? Arr::get($addon->metadata ?? [], 'cancel_url');
if ($addon->status === 'completed') {
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
}
try {
$capture = $this->orders->captureOrder($orderId, [
'request_id' => 'addon-'.$addon->id,
]);
} catch (PayPalException $exception) {
$this->addons->fail($addon, 'paypal_capture_failed', [
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
}
$captureId = $this->resolveCaptureId($capture);
$totals = $this->resolveTotals($capture);
$this->addons->complete(
$addon,
$capture,
$captureId,
$orderId,
$totals['total'] ?? null,
$totals['currency'] ?? null,
[
'paypal_order_id' => $orderId,
'paypal_capture_id' => $captureId,
'paypal_status' => $capture['status'] ?? null,
'paypal_totals' => $totals ?: null,
'paypal_captured_at' => now()->toIso8601String(),
],
);
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
}
protected function resolveOrderId(Request $request): ?string
{
$candidate = $request->query('token') ?? $request->query('order_id');
if (! is_string($candidate) || $candidate === '') {
return null;
}
return $candidate;
}
protected function resolveFallbackUrl(): string
{
return rtrim((string) config('app.url', url('/')), '/') ?: url('/');
}
protected function resolveSafeRedirect(?string $target, string $fallback): string
{
if (! $target) {
return $fallback;
}
if (Str::startsWith($target, ['/'])) {
return $target;
}
$appHost = parse_url($fallback, PHP_URL_HOST);
$targetHost = parse_url($target, PHP_URL_HOST);
if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) {
return $target;
}
return $fallback;
}
/**
* @param array<string, mixed> $capture
*/
protected function resolveCaptureId(array $capture): ?string
{
$captureId = Arr::get($capture, 'purchase_units.0.payments.captures.0.id')
?? Arr::get($capture, 'id');
return is_string($captureId) && $captureId !== '' ? $captureId : null;
}
/**
* @param array<string, mixed> $capture
* @return array{currency?: string, total?: float}
*/
protected function resolveTotals(array $capture): array
{
$amount = Arr::get($capture, 'purchase_units.0.payments.captures.0.amount')
?? Arr::get($capture, 'purchase_units.0.amount');
if (! is_array($amount)) {
return [];
}
$currency = Arr::get($amount, 'currency_code');
$total = Arr::get($amount, 'value');
return array_filter([
'currency' => is_string($currency) ? strtoupper($currency) : null,
'total' => is_numeric($total) ? (float) $total : null,
], static fn ($value) => $value !== null);
}
}

View File

@@ -1,228 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\PayPal\PayPalCaptureRequest;
use App\Http\Requests\PayPal\PayPalCheckoutRequest;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponRedemptionService;
use App\Services\Coupons\CouponService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use App\Support\CheckoutRequestContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class PayPalCheckoutController extends Controller
{
public function __construct(
private readonly PayPalOrderService $orders,
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
private readonly CouponService $coupons,
private readonly CouponRedemptionService $couponRedemptions,
) {}
public function create(PayPalCheckoutRequest $request): JsonResponse
{
$data = $request->validated();
$user = $request->user();
$tenant = $user?->tenant;
if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
}
$package = Package::findOrFail((int) $data['package_id']);
$session = $this->sessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
'tenant' => $tenant,
'locale' => $data['locale'] ?? null,
]
));
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
$now = now();
$session->forceFill([
'accepted_terms_at' => $now,
'accepted_privacy_at' => $now,
'accepted_withdrawal_notice_at' => $now,
'digital_content_waiver_at' => null,
'legal_version' => $this->resolveLegalVersion(),
])->save();
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
if ($couponCode !== '') {
$preview = $this->coupons->preview($couponCode, $package, $tenant, CheckoutSession::PROVIDER_PAYPAL);
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
}
$successUrl = $data['return_url'] ?? null;
$cancelUrl = $data['cancel_url'] ?? $successUrl;
$paypalReturnUrl = route('paypal.return', absolute: true);
try {
$order = $this->orders->createOrder($session, $package, [
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => $data['locale'] ?? $session->locale,
'request_id' => $session->id,
]);
} catch (PayPalException $exception) {
Log::warning('PayPal order creation failed', [
'session_id' => $session->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
'context' => $exception->context(),
]);
throw ValidationException::withMessages([
'paypal' => __('marketing.packages.paypal_checkout_failed', [], app()->getLocale())
?: 'Unable to create PayPal checkout.',
]);
}
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
throw ValidationException::withMessages([
'paypal' => 'PayPal order ID missing.',
]);
}
$approveUrl = $this->orders->resolveApproveUrl($order);
$session->forceFill([
'paypal_order_id' => $orderId,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_status' => $order['status'] ?? null,
'paypal_approve_url' => $approveUrl,
'paypal_success_url' => $successUrl,
'paypal_cancel_url' => $cancelUrl,
'paypal_created_at' => now()->toIso8601String(),
])),
])->save();
$this->sessions->markRequiresCustomerAction($session, 'paypal_approval');
return response()->json([
'order_id' => $orderId,
'approve_url' => $approveUrl,
'status' => $order['status'] ?? null,
'checkout_session_id' => $session->id,
]);
}
public function capture(PayPalCaptureRequest $request): JsonResponse
{
$data = $request->validated();
$session = CheckoutSession::findOrFail($data['checkout_session_id']);
$orderId = (string) $data['order_id'];
if ($session->status === CheckoutSession::STATUS_COMPLETED) {
return response()->json([
'status' => $session->status,
'completed_at' => optional($session->completed_at)->toIso8601String(),
'checkout_session_id' => $session->id,
]);
}
if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) {
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
}
try {
$capture = $this->orders->captureOrder($orderId, [
'request_id' => $session->id,
]);
} catch (PayPalException $exception) {
Log::warning('PayPal capture failed', [
'session_id' => $session->id,
'order_id' => $orderId,
'message' => $exception->getMessage(),
'status' => $exception->status(),
'context' => $exception->context(),
]);
$this->sessions->markFailed($session, 'paypal_capture_failed');
$this->couponRedemptions->recordFailure($session, 'paypal_capture_failed');
return response()->json([
'status' => CheckoutSession::STATUS_FAILED,
'checkout_session_id' => $session->id,
], 422);
}
$status = strtoupper((string) ($capture['status'] ?? ''));
$captureId = $this->orders->resolveCaptureId($capture);
$totals = $this->orders->resolveTotals($capture);
$session->forceFill([
'paypal_order_id' => $orderId,
'paypal_capture_id' => $captureId,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_capture_id' => $captureId,
'paypal_status' => $status ?: null,
'paypal_totals' => $totals !== [] ? $totals : null,
'paypal_captured_at' => now()->toIso8601String(),
])),
])->save();
if ($status === 'COMPLETED') {
$this->sessions->markProcessing($session, [
'paypal_status' => $status,
'paypal_capture_id' => $captureId,
]);
$this->assignment->finalise($session, [
'source' => 'paypal_capture',
'provider' => CheckoutSession::PROVIDER_PAYPAL,
'provider_reference' => $captureId ?? $orderId,
'payload' => $capture,
]);
$this->sessions->markCompleted($session, now());
$this->couponRedemptions->recordSuccess($session, $capture);
return response()->json([
'status' => CheckoutSession::STATUS_COMPLETED,
'completed_at' => optional($session->completed_at)->toIso8601String(),
'checkout_session_id' => $session->id,
]);
}
if (in_array($status, ['PAYER_ACTION_REQUIRED', 'PENDING'], true)) {
$this->sessions->markRequiresCustomerAction($session, 'paypal_pending');
return response()->json([
'status' => CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
'checkout_session_id' => $session->id,
], 202);
}
$this->sessions->markFailed($session, 'paypal_'.$status);
$this->couponRedemptions->recordFailure($session, 'paypal_'.$status);
return response()->json([
'status' => CheckoutSession::STATUS_FAILED,
'checkout_session_id' => $session->id,
], 422);
}
protected function resolveLegalVersion(): string
{
return config('app.legal_version', now()->toDateString());
}
}

View File

@@ -1,129 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\GiftVoucher;
use App\Services\GiftVouchers\GiftVoucherService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PayPalGiftVoucherReturnController extends Controller
{
public function __construct(
private readonly PayPalOrderService $orders,
private readonly GiftVoucherService $vouchers,
) {}
public function __invoke(Request $request): RedirectResponse
{
$orderId = $this->resolveOrderId($request);
$fallback = $this->resolveFallbackUrl();
if (! $orderId) {
return redirect()->to($fallback);
}
$voucher = GiftVoucher::query()
->where('paypal_order_id', $orderId)
->first();
if (! $voucher) {
return redirect()->to($fallback);
}
$successUrl = Arr::get($voucher->metadata ?? [], 'paypal_success_url')
?? Arr::get($voucher->metadata ?? [], 'success_url');
$cancelUrl = Arr::get($voucher->metadata ?? [], 'paypal_cancel_url')
?? Arr::get($voucher->metadata ?? [], 'return_url');
if (in_array($voucher->status, [GiftVoucher::STATUS_ISSUED, GiftVoucher::STATUS_REDEEMED], true)) {
return redirect()->to($this->resolveSafeRedirect($this->appendOrderId($successUrl, $orderId), $fallback));
}
try {
$capture = $this->orders->captureOrder($orderId, [
'request_id' => 'gift-voucher-'.$voucher->id,
]);
} catch (PayPalException $exception) {
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
}
$this->vouchers->issueFromPayPal($voucher, $capture, $orderId);
return redirect()->to($this->resolveSafeRedirect($this->appendOrderId($successUrl, $orderId), $fallback));
}
protected function resolveOrderId(Request $request): ?string
{
$candidate = $request->query('token') ?? $request->query('order_id');
if (! is_string($candidate) || $candidate === '') {
return null;
}
return $candidate;
}
protected function resolveFallbackUrl(): string
{
return rtrim((string) config('app.url', url('/')), '/') ?: url('/');
}
protected function resolveSafeRedirect(?string $target, string $fallback): string
{
if (! $target) {
return $fallback;
}
if (Str::startsWith($target, ['/'])) {
return $target;
}
$appHost = parse_url($fallback, PHP_URL_HOST);
$targetHost = parse_url($target, PHP_URL_HOST);
if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) {
return $target;
}
return $fallback;
}
protected function appendOrderId(?string $url, string $orderId): ?string
{
if (! $url) {
return null;
}
$parts = parse_url($url);
if (! $parts) {
return $url;
}
$query = [];
if (! empty($parts['query'])) {
parse_str($parts['query'], $query);
}
if (! isset($query['order_id'])) {
$query['order_id'] = $orderId;
}
$scheme = $parts['scheme'] ?? null;
$host = $parts['host'] ?? null;
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
$path = $parts['path'] ?? '';
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
$queryString = $query ? '?'.http_build_query($query) : '';
if ($scheme && $host) {
return $scheme.'://'.$host.$port.$path.$queryString.$fragment;
}
return $path.$queryString.$fragment;
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\CheckoutSession;
use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponRedemptionService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class PayPalReturnController extends Controller
{
public function __construct(
private readonly PayPalOrderService $orders,
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
private readonly CouponRedemptionService $couponRedemptions,
) {}
public function __invoke(Request $request): RedirectResponse
{
$orderId = $this->resolveOrderId($request);
$fallback = $this->resolveFallbackUrl();
if (! $orderId) {
return redirect()->to($fallback);
}
$session = CheckoutSession::query()
->where('paypal_order_id', $orderId)
->first();
if (! $session) {
return redirect()->to($fallback);
}
$successUrl = data_get($session->provider_metadata ?? [], 'paypal_success_url');
$cancelUrl = data_get($session->provider_metadata ?? [], 'paypal_cancel_url');
if ($session->status === CheckoutSession::STATUS_COMPLETED) {
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
}
try {
$capture = $this->orders->captureOrder($orderId, [
'request_id' => $session->id,
]);
} catch (PayPalException $exception) {
Log::warning('PayPal return capture failed', [
'session_id' => $session->id,
'order_id' => $orderId,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
$this->sessions->markFailed($session, 'paypal_capture_failed');
$this->couponRedemptions->recordFailure($session, 'paypal_capture_failed');
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
}
$status = strtoupper((string) ($capture['status'] ?? ''));
$captureId = $this->orders->resolveCaptureId($capture);
$totals = $this->orders->resolveTotals($capture);
$session->forceFill([
'paypal_capture_id' => $captureId,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paypal_status' => $status ?: null,
'paypal_capture_id' => $captureId,
'paypal_totals' => $totals !== [] ? $totals : null,
'paypal_captured_at' => now()->toIso8601String(),
])),
])->save();
if ($status === 'COMPLETED') {
$this->sessions->markProcessing($session, [
'paypal_status' => $status,
'paypal_capture_id' => $captureId,
]);
$this->assignment->finalise($session, [
'source' => 'paypal_return',
'provider' => CheckoutSession::PROVIDER_PAYPAL,
'provider_reference' => $captureId ?? $orderId,
'payload' => $capture,
]);
$this->sessions->markCompleted($session, now());
$this->couponRedemptions->recordSuccess($session, $capture);
return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback));
}
$this->sessions->markFailed($session, 'paypal_'.$status);
$this->couponRedemptions->recordFailure($session, 'paypal_'.$status);
return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback));
}
protected function resolveOrderId(Request $request): ?string
{
$candidate = $request->query('token') ?? $request->query('order_id');
if (! is_string($candidate) || $candidate === '') {
return null;
}
return $candidate;
}
protected function resolveFallbackUrl(): string
{
return rtrim((string) config('app.url', url('/')), '/') ?: url('/');
}
protected function resolveSafeRedirect(?string $target, string $fallback): string
{
if (! $target) {
return $fallback;
}
if (Str::startsWith($target, ['/'])) {
return $target;
}
$appHost = parse_url($fallback, PHP_URL_HOST);
$targetHost = parse_url($target, PHP_URL_HOST);
if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) {
return $target;
}
return $fallback;
}
}

View File

@@ -1,120 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Integrations\IntegrationWebhookRecorder;
use App\Services\PayPal\PayPalAddonWebhookService;
use App\Services\PayPal\PayPalGiftVoucherWebhookService;
use App\Services\PayPal\PayPalWebhookService;
use App\Services\PayPal\PayPalWebhookVerifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class PayPalWebhookController extends Controller
{
public function __construct(
private readonly PayPalWebhookVerifier $verifier,
private readonly PayPalWebhookService $webhooks,
private readonly PayPalAddonWebhookService $addonWebhooks,
private readonly PayPalGiftVoucherWebhookService $giftVoucherWebhooks,
private readonly IntegrationWebhookRecorder $recorder,
) {}
public function handle(Request $request): JsonResponse
{
try {
$payload = $this->decodePayload($request);
if (! is_array($payload)) {
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
}
if (! $this->verifier->verify($request, $payload)) {
Log::warning('PayPal webhook signature verification failed');
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
}
$eventType = $payload['event_type'] ?? null;
$eventId = $payload['id'] ?? null;
$webhookEvent = $this->recorder->recordReceived(
'paypal',
is_string($eventId) ? $eventId : null,
is_string($eventType) ? $eventType : null,
);
$handled = false;
if (is_string($eventType)) {
$handled = $this->webhooks->handle($payload) || $handled;
$handled = $this->addonWebhooks->handle($payload) || $handled;
$handled = $this->giftVoucherWebhooks->handle($payload) || $handled;
}
Log::info('PayPal webhook processed', [
'event_type' => $eventType,
'handled' => $handled,
]);
if ($handled) {
$this->recorder->markProcessed($webhookEvent, ['handled' => true]);
} else {
$this->recorder->markIgnored($webhookEvent, ['handled' => false]);
}
return response()->json([
'status' => $handled ? 'processed' : 'ignored',
], $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED);
} catch (\Throwable $exception) {
$eventId = $this->captureWebhookException($exception);
Log::error('PayPal webhook processing failed', [
'message' => $exception->getMessage(),
'event_type' => (string) data_get($request->json()->all(), 'event_type'),
'sentry_event_id' => $eventId,
]);
if (isset($webhookEvent)) {
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
}
return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* @return array<string, mixed>|null
*/
protected function decodePayload(Request $request): ?array
{
$payload = $request->getContent();
if (! is_string($payload) || $payload === '') {
return null;
}
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : null;
}
protected function captureWebhookException(\Throwable $exception): ?string
{
report($exception);
if (! app()->bound('sentry') || empty(config('sentry.dsn'))) {
return null;
}
try {
$eventId = app('sentry')->captureException($exception);
} catch (\Throwable) {
return null;
}
return $eventId ? (string) $eventId : null;
}
}

View File

@@ -100,30 +100,13 @@ class TenantAdminFacebookController extends Controller
return null;
}
if (str_starts_with($decoded, '//')) {
return null;
}
if (str_starts_with($decoded, '/')) {
return $decoded;
}
$targetHost = parse_url($decoded, PHP_URL_HOST);
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
return null;
}
return $decoded;
}
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
}

View File

@@ -100,30 +100,13 @@ class TenantAdminGoogleController extends Controller
return null;
}
if (str_starts_with($decoded, '//')) {
return null;
}
if (str_starts_with($decoded, '/')) {
return $decoded;
}
$targetHost = parse_url($decoded, PHP_URL_HOST);
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
return null;
}
return $decoded;
}
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
}

View File

@@ -61,7 +61,7 @@ class TestCheckoutController extends Controller
]);
}
public function simulateLemonSqueezy(
public function simulatePaddle(
Request $request,
CheckoutWebhookService $webhooks,
CheckoutSession $session
@@ -70,13 +70,13 @@ class TestCheckoutController extends Controller
$validated = $request->validate([
'event_type' => ['nullable', 'string'],
'order_id' => ['nullable', 'string'],
'transaction_id' => ['nullable', 'string'],
'status' => ['nullable', 'string'],
'checkout_id' => ['nullable', 'string'],
'metadata' => ['nullable', 'array'],
]);
$eventType = $validated['event_type'] ?? 'order_created';
$eventType = $validated['event_type'] ?? 'transaction.completed';
$metadata = array_merge([
'tenant_id' => $session->tenant_id,
'package_id' => $session->package_id,
@@ -84,21 +84,16 @@ class TestCheckoutController extends Controller
], $validated['metadata'] ?? []);
$payload = [
'meta' => [
'event_name' => $eventType,
'custom_data' => $metadata,
],
'event_type' => $eventType,
'data' => array_filter([
'id' => $validated['order_id'] ?? ('order_'.Str::uuid()),
'attributes' => array_filter([
'status' => $validated['status'] ?? 'paid',
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['lemonsqueezy_checkout_id'] ?? 'chk_'.Str::uuid(),
'custom_data' => $metadata,
]),
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
'status' => $validated['status'] ?? 'completed',
'custom_data' => $metadata,
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(),
]),
];
$handled = $webhooks->handleLemonSqueezyEvent($payload);
$handled = $webhooks->handlePaddleEvent($payload);
return response()->json([
'data' => [

View File

@@ -7,7 +7,7 @@ use App\Models\EventPackage;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Notifications\Customer\WithdrawalConfirmed;
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -36,7 +36,7 @@ class WithdrawalController extends Controller
public function confirm(
WithdrawalConfirmRequest $request,
LemonSqueezyOrderService $orders,
PaddleTransactionService $transactions,
string $locale
): RedirectResponse {
$user = $request->user();
@@ -60,10 +60,10 @@ class WithdrawalController extends Controller
->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale));
}
$orderId = $this->resolveOrderId($purchase);
$transactionId = $this->resolveTransactionId($purchase);
if (! $orderId) {
Log::warning('Withdrawal missing Lemon Squeezy order reference.', [
if (! $transactionId) {
Log::warning('Withdrawal missing Paddle transaction reference.', [
'purchase_id' => $purchase->id,
'provider' => $purchase->provider,
]);
@@ -74,11 +74,11 @@ class WithdrawalController extends Controller
}
try {
$orders->refund($orderId, ['reason' => 'withdrawal']);
$transactions->refund($transactionId, ['reason' => 'withdrawal']);
} catch (\Throwable $exception) {
Log::warning('Withdrawal refund failed', [
'purchase_id' => $purchase->id,
'order_id' => $orderId,
'transaction_id' => $transactionId,
'error' => $exception->getMessage(),
]);
@@ -94,13 +94,13 @@ class WithdrawalController extends Controller
$withdrawalMeta = array_merge($withdrawalMeta, [
'confirmed_at' => $confirmedAt->toIso8601String(),
'confirmed_by' => $user?->id,
'order_id' => $orderId,
'transaction_id' => $transactionId,
]);
$metadata['withdrawal'] = $withdrawalMeta;
$purchase->forceFill([
'provider_id' => $orderId,
'provider_id' => $transactionId,
'refunded' => true,
'metadata' => $metadata,
])->save();
@@ -127,7 +127,7 @@ class WithdrawalController extends Controller
->with('package')
->where('tenant_id', $tenant->id)
->where('type', 'endcustomer_event')
->where('provider', 'lemonsqueezy')
->where('provider', 'paddle')
->where('refunded', false)
->orderByDesc('purchased_at')
->orderByDesc('id')
@@ -151,7 +151,7 @@ class WithdrawalController extends Controller
$reasons[] = 'type';
}
if ($purchase->provider !== 'lemonsqueezy') {
if ($purchase->provider !== 'paddle') {
$reasons[] = 'provider';
}
@@ -159,7 +159,7 @@ class WithdrawalController extends Controller
$reasons[] = 'refunded';
}
if (! $this->resolveOrderId($purchase)) {
if (! $this->resolveTransactionId($purchase)) {
$reasons[] = 'missing_reference';
}
@@ -224,13 +224,13 @@ class WithdrawalController extends Controller
];
}
private function resolveOrderId(PackagePurchase $purchase): ?string
private function resolveTransactionId(PackagePurchase $purchase): ?string
{
if ($purchase->provider === 'lemonsqueezy' && $purchase->provider_id) {
if ($purchase->provider === 'paddle' && $purchase->provider_id) {
return (string) $purchase->provider_id;
}
return data_get($purchase->metadata, 'lemonsqueezy_order_id');
return data_get($purchase->metadata, 'paddle_transaction_id');
}
private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void

View File

@@ -37,7 +37,7 @@ class ContentSecurityPolicy
$scriptSources = [
"'self'",
"'nonce-{$scriptNonce}'",
'https://app.lemonsqueezy.com',
'https://cdn.paddle.com',
'https://global.localizecdn.com',
];
@@ -49,16 +49,21 @@ class ContentSecurityPolicy
$connectSources = [
"'self'",
'https://api.lemonsqueezy.com',
'https://app.lemonsqueezy.com',
'https://fotospiel.lemonsqueezy.com',
'https://api.paddle.com',
'https://sandbox-api.paddle.com',
'https://checkout.paddle.com',
'https://sandbox-checkout.paddle.com',
'https://checkout-service.paddle.com',
'https://sandbox-checkout-service.paddle.com',
'https://global.localizecdn.com',
];
$frameSources = [
"'self'",
'https://app.lemonsqueezy.com',
'https://fotospiel.lemonsqueezy.com',
'https://checkout.paddle.com',
'https://sandbox-checkout.paddle.com',
'https://checkout-service.paddle.com',
'https://sandbox-checkout-service.paddle.com',
];
$imgSources = [
@@ -81,23 +86,6 @@ class ContentSecurityPolicy
'https:',
];
$workerSources = [
"'self'",
'blob:',
];
$paypalSources = [
'https://www.paypal.com',
'https://www.paypalobjects.com',
'https://*.paypal.com',
'https://*.paypalobjects.com',
];
$scriptSources = array_merge($scriptSources, $paypalSources);
$connectSources = array_merge($connectSources, $paypalSources);
$frameSources = array_merge($frameSources, $paypalSources);
$imgSources = array_merge($imgSources, $paypalSources);
if ($matomoOrigin) {
$scriptSources[] = $matomoOrigin;
$connectSources[] = $matomoOrigin;
@@ -107,18 +95,6 @@ class ContentSecurityPolicy
$isDev = app()->environment(['local', 'development']) || config('app.debug');
if ($isDev) {
$paypalSandboxSources = [
'https://www.sandbox.paypal.com',
'https://www.sandbox.paypalobjects.com',
'https://*.sandbox.paypal.com',
'https://*.sandbox.paypalobjects.com',
];
$scriptSources = array_merge($scriptSources, $paypalSandboxSources);
$connectSources = array_merge($connectSources, $paypalSandboxSources);
$frameSources = array_merge($frameSources, $paypalSandboxSources);
$imgSources = array_merge($imgSources, $paypalSandboxSources);
$devHosts = [
'http://fotospiel-app.test:5173',
'http://127.0.0.1:5173',
@@ -158,7 +134,6 @@ class ContentSecurityPolicy
'font-src' => array_unique($fontSources),
'connect-src' => array_unique($connectSources),
'media-src' => array_unique($mediaSources),
'worker-src' => array_unique($workerSources),
'frame-src' => array_unique($frameSources),
'form-action' => ["'self'"],
'base-uri' => ["'self'"],

View File

@@ -14,7 +14,6 @@ class VerifyCsrfToken extends Middleware
protected $except = [
'api/v1/photos/*/like',
'api/v1/events/*/upload',
'lemonsqueezy/webhook*',
'paypal/webhook*',
'paddle/webhook*',
];
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class GuestAiEditStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'style_key' => ['nullable', 'string', 'max:120', 'required_without:prompt'],
'prompt' => ['nullable', 'string', 'max:2000', 'required_without:style_key'],
'negative_prompt' => ['nullable', 'string', 'max:2000'],
'provider_model' => ['nullable', 'string', 'max:120'],
'idempotency_key' => ['nullable', 'string', 'max:120'],
'session_id' => ['nullable', 'string', 'max:191'],
'metadata' => ['nullable', 'array'],
];
}
}

View File

@@ -35,16 +35,16 @@ class CheckoutSessionConfirmRequest extends FormRequest
public function rules(): array
{
return [
'order_id' => ['nullable', 'string', 'required_without:checkout_id'],
'checkout_id' => ['nullable', 'string', 'required_without:order_id'],
'transaction_id' => ['nullable', 'string', 'required_without:checkout_id'],
'checkout_id' => ['nullable', 'string', 'required_without:transaction_id'],
];
}
public function messages(): array
{
return [
'order_id.required_without' => 'Order ID oder Checkout ID fehlt.',
'checkout_id.required_without' => 'Checkout ID oder Order ID fehlt.',
'transaction_id.required_without' => 'Transaction ID oder Checkout ID fehlt.',
'checkout_id.required_without' => 'Checkout ID oder Transaction ID fehlt.',
];
}
}

View File

@@ -1,17 +1,17 @@
<?php
namespace App\Http\Requests\LemonSqueezy;
namespace App\Http\Requests\Paddle;
use Illuminate\Foundation\Http\FormRequest;
class LemonSqueezyCheckoutRequest extends FormRequest
class PaddleCheckoutRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return (bool) $this->user();
return true;
}
/**
@@ -25,11 +25,15 @@ class LemonSqueezyCheckoutRequest extends FormRequest
'package_id' => ['required', 'exists:packages,id'],
'success_url' => ['nullable', 'url'],
'return_url' => ['nullable', 'url'],
'inline' => ['sometimes', 'boolean'],
'coupon_code' => ['nullable', 'string', 'max:64'],
'accepted_terms' => ['required', 'boolean', 'accepted'],
];
}
/**
* Get custom validation messages.
*/
public function messages(): array
{
return [

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Http\Requests\PayPal;
use App\Models\CheckoutSession;
use Illuminate\Foundation\Http\FormRequest;
class PayPalCaptureRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$user = $this->user();
if (! $user) {
return false;
}
$sessionId = $this->input('checkout_session_id');
if (! is_string($sessionId) || $sessionId === '') {
return false;
}
$session = CheckoutSession::find($sessionId);
if (! $session) {
return false;
}
return (int) $session->user_id === (int) $user->id;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'checkout_session_id' => ['required', 'uuid', 'exists:checkout_sessions,id'],
'order_id' => ['required', 'string'],
];
}
public function messages(): array
{
return [
'checkout_session_id.required' => 'Checkout-Session fehlt.',
'order_id.required' => 'Order ID fehlt.',
];
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Http\Requests\PayPal;
use Illuminate\Foundation\Http\FormRequest;
class PayPalCheckoutRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return (bool) $this->user();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'package_id' => ['required', 'exists:packages,id'],
'return_url' => ['nullable', 'url'],
'cancel_url' => ['nullable', 'url'],
'coupon_code' => ['nullable', 'string', 'max:64'],
'accepted_terms' => ['required', 'boolean', 'accepted'],
'locale' => ['nullable', 'string', 'max:10'],
];
}
public function messages(): array
{
return [
'package_id.exists' => 'Das ausgewählte Paket ist ungültig.',
'accepted_terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.',
];
}
}

View File

@@ -19,7 +19,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
Rule::unique('tenants', 'slug')->ignore($tenantId),
],
'contact_email' => ['sometimes', 'email', 'max:255'],
'lemonsqueezy_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
'is_active' => ['sometimes', 'boolean'],
'is_suspended' => ['sometimes', 'boolean'],
'features' => ['sometimes', 'array'],
@@ -31,7 +31,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
return [
'slug',
'contact_email',
'lemonsqueezy_customer_id',
'paddle_customer_id',
'is_active',
'is_suspended',
'features',

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class AiEditIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => ['nullable', 'string', 'max:30'],
'safety_state' => ['nullable', 'string', 'max:30'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
];
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class AiEditStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'photo_id' => ['required', 'integer', 'exists:photos,id'],
'style_id' => ['nullable', 'integer', 'exists:ai_styles,id', 'required_without:style_key'],
'style_key' => ['nullable', 'string', 'max:120', 'required_without:style_id'],
'prompt' => ['nullable', 'string', 'max:2000'],
'negative_prompt' => ['nullable', 'string', 'max:2000'],
'provider_model' => ['nullable', 'string', 'max:120'],
'idempotency_key' => ['nullable', 'string', 'max:120'],
'metadata' => ['nullable', 'array'],
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class BillingAddonHistoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'event_id' => ['nullable', 'integer', 'min:1'],
'event_slug' => ['nullable', 'string', 'max:191'],
'status' => ['nullable', 'in:pending,completed,failed'],
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class EventAddonPurchaseLookupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'addon_intent' => ['nullable', 'string', 'max:191'],
'checkout_id' => ['nullable', 'string', 'max:191'],
'addon_key' => ['nullable', 'string', 'max:191'],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class EventAddonRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'extra_photos' => ['nullable', 'integer', 'min:1'],
'extra_guests' => ['nullable', 'integer', 'min:1'],
'extend_gallery_days' => ['nullable', 'integer', 'min:1'],
'reason' => ['nullable', 'string', 'max:255'],
];
}
protected function passedValidation(): void
{
if (
$this->input('extra_photos') === null
&& $this->input('extra_guests') === null
&& $this->input('extend_gallery_days') === null
) {
throw ValidationException::withMessages([
'addons' => __('Please provide at least one add-on to apply.'),
]);
}
}
}

View File

@@ -55,7 +55,6 @@ class EventStoreRequest extends FormRequest
'settings.branding.*' => ['nullable'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
'settings.guest_downloads_enabled' => ['nullable', 'boolean'],
'settings.guest_download_variant' => ['nullable', Rule::in(['preview', 'original'])],
'settings.guest_sharing_enabled' => ['nullable', 'boolean'],
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
'settings.live_show' => ['nullable', 'array'],
@@ -84,16 +83,6 @@ class EventStoreRequest extends FormRequest
'settings.control_room.force_review_uploaders' => ['nullable', 'array'],
'settings.control_room.force_review_uploaders.*.device_id' => ['required', 'string', 'max:120'],
'settings.control_room.force_review_uploaders.*.label' => ['nullable', 'string', 'max:80'],
'settings.ai_editing' => ['nullable', 'array'],
'settings.ai_editing.enabled' => ['nullable', 'boolean'],
'settings.ai_editing.allow_custom_prompt' => ['nullable', 'boolean'],
'settings.ai_editing.allowed_style_keys' => ['nullable', 'array'],
'settings.ai_editing.allowed_style_keys.*' => [
'string',
'max:120',
Rule::exists('ai_styles', 'key')->where('is_active', true),
],
'settings.ai_editing.policy_message' => ['nullable', 'string', 'max:280'],
'settings.watermark' => ['nullable', 'array'],
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],

View File

@@ -3,9 +3,6 @@
namespace App\Http\Resources\Tenant;
use App\Models\WatermarkSetting;
use App\Services\Addons\EventAddonCatalog;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\TenantMemberPermissions;
use App\Support\WatermarkConfigResolver;
@@ -52,8 +49,6 @@ class EventResource extends JsonResource
if ($eventPackage) {
$limitEvaluator = app()->make(PackageLimitEvaluator::class);
}
$aiStylingEntitlement = app()->make(AiStylingEntitlementService::class)->resolveForEvent($this->resource);
$aiEditingPolicy = app()->make(EventAiEditingPolicyService::class)->resolve($this->resource);
$settings['watermark_removal_allowed'] = WatermarkConfigResolver::determineRemovalAllowed($this->resource);
@@ -94,29 +89,17 @@ class EventResource extends JsonResource
'qr_code_url' => null,
'package' => $eventPackage ? [
'id' => $eventPackage->package_id,
'tenant_package_id' => $eventPackage->tenant_package_id,
'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name,
'price' => $eventPackage->purchased_price,
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
'branding_allowed' => (bool) optional($eventPackage->package)->branding_allowed,
'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed,
'features' => optional($eventPackage->package)->features ?? [],
] : null,
'limits' => $eventPackage && $limitEvaluator
? $limitEvaluator->summarizeEventPackage($eventPackage, $this->resolveTasksUsed())
: null,
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
'capabilities' => [
'ai_styling' => (bool) $aiStylingEntitlement['allowed'],
'ai_styling_granted_by' => $aiStylingEntitlement['granted_by'],
'ai_styling_required_feature' => $aiStylingEntitlement['required_feature'],
'ai_styling_addon_keys' => $aiStylingEntitlement['addon_keys'],
'ai_styling_event_enabled' => (bool) $aiEditingPolicy['enabled'],
'ai_styling_allow_custom_prompt' => (bool) $aiEditingPolicy['allow_custom_prompt'],
'ai_styling_allowed_style_keys' => $aiEditingPolicy['allowed_style_keys'],
'ai_styling_policy_message' => $aiEditingPolicy['policy_message'],
],
'member_permissions' => $memberPermissions,
];
}
@@ -223,19 +206,13 @@ class EventResource extends JsonResource
? $eventPackage->addons
: $eventPackage->addons()->latest()->take(10)->get();
$addonLabels = collect(app(EventAddonCatalog::class)->all())
->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null])
->all();
return $addons->map(function ($addon) use ($addonLabels) {
return $addons->map(function ($addon) {
return [
'id' => $addon->id,
'key' => $addon->addon_key,
'label' => $addon->metadata['label']
?? ($addonLabels[$addon->addon_key] ?? null)
?? $addon->addon_key,
'label' => $addon->metadata['label'] ?? null,
'status' => $addon->status,
'variant_id' => $addon->variant_id,
'price_id' => $addon->price_id,
'transaction_id' => $addon->transaction_id,
'extra_photos' => (int) $addon->extra_photos,
'extra_guests' => (int) $addon->extra_guests,

View File

@@ -1,264 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiEditOutputStorageService;
use App\Services\AiEditing\AiImageProviderManager;
use App\Services\AiEditing\AiObservabilityService;
use App\Services\AiEditing\AiStatusNotificationService;
use App\Services\AiEditing\AiUsageLedgerService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Throwable;
class PollAiEditRequest implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/**
* @var array<int, int>
*/
public array $backoff = [20, 60, 120];
public int $timeout = 60;
public function __construct(
private readonly int $requestId,
private readonly string $providerTaskId,
private readonly int $pollAttempt = 1,
) {
$this->onQueue((string) config('ai-editing.queue.name', 'default'));
}
public function handle(
AiImageProviderManager $providers,
AiSafetyPolicyService $safetyPolicy,
AiAbuseEscalationService $abuseEscalation,
AiObservabilityService $observability,
AiStatusNotificationService $statusNotifications,
AiEditOutputStorageService $outputStorage,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
$request = AiEditRequest::query()->with('outputs')->find($this->requestId);
if (! $request || $request->status !== AiEditRequest::STATUS_PROCESSING) {
return;
}
$run = AiProviderRun::query()->create([
'request_id' => $request->id,
'provider' => $request->provider,
'attempt' => ((int) $request->providerRuns()->max('attempt')) + 1,
'provider_task_id' => $this->providerTaskId,
'status' => AiProviderRun::STATUS_RUNNING,
'started_at' => now(),
]);
$result = $providers->forProvider($request->provider)->poll($request, $this->providerTaskId);
$run->forceFill([
'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED),
'http_status' => $result->httpStatus,
'finished_at' => $result->status === 'processing' ? null : now(),
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
'cost_usd' => $result->costUsd,
'request_payload' => $result->requestPayload,
'response_payload' => $result->responsePayload,
'error_message' => $result->failureMessage,
])->save();
if ($result->status === 'succeeded') {
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
if ($outputDecision->blocked) {
$abuseSignal = $abuseEscalation->recordOutputBlock(
(int) $request->tenant_id,
(int) $request->event_id,
'provider:'.$request->provider
);
$safetyReasons = $outputDecision->reasonCodes;
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
$metadata = (array) ($request->metadata ?? []);
$metadata['abuse'] = $abuseSignal;
$request->forceFill([
'status' => AiEditRequest::STATUS_BLOCKED,
'safety_state' => $outputDecision->state,
'safety_reasons' => $safetyReasons,
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
'failure_message' => $outputDecision->failureMessage,
'metadata' => $metadata,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_BLOCKED,
$run->duration_ms,
true,
'poll'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
foreach ($result->outputs as $output) {
$persistedOutput = $outputStorage->persist($request, is_array($output) ? $output : []);
AiEditOutput::query()->updateOrCreate(
[
'request_id' => $request->id,
'provider_asset_id' => (string) Arr::get($persistedOutput, 'provider_asset_id', $this->providerTaskId),
],
[
'storage_disk' => Arr::get($persistedOutput, 'storage_disk'),
'storage_path' => Arr::get($persistedOutput, 'storage_path'),
'provider_url' => Arr::get($persistedOutput, 'provider_url'),
'mime_type' => Arr::get($persistedOutput, 'mime_type'),
'width' => Arr::get($persistedOutput, 'width'),
'height' => Arr::get($persistedOutput, 'height'),
'bytes' => Arr::get($persistedOutput, 'bytes'),
'checksum' => Arr::get($persistedOutput, 'checksum'),
'is_primary' => true,
'safety_state' => 'passed',
'safety_reasons' => [],
'generated_at' => now(),
'metadata' => array_merge(
['provider' => $request->provider],
is_array(Arr::get($persistedOutput, 'metadata'))
? Arr::get($persistedOutput, 'metadata')
: []
),
]
);
}
$request->forceFill([
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'safety_reasons' => [],
'failure_code' => null,
'failure_message' => null,
'completed_at' => now(),
])->save();
$usageLedger->recordDebitForRequest($request->fresh(), $result->costUsd, [
'source' => 'poll_job',
'poll_attempt' => $this->pollAttempt,
]);
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_SUCCEEDED,
$run->duration_ms,
false,
'poll'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
if ($result->status === 'processing') {
$maxPolls = $runtimeConfig->maxPolls();
if ($this->pollAttempt < $maxPolls) {
self::dispatch($request->id, $this->providerTaskId, $this->pollAttempt + 1)
->delay(now()->addSeconds(20))
->onQueue($runtimeConfig->queueName());
return;
}
$run->forceFill([
'status' => AiProviderRun::STATUS_FAILED,
'finished_at' => now(),
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
'error_message' => sprintf('Polling exhausted after %d attempt(s).', $maxPolls),
])->save();
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'provider_poll_timeout',
'failure_message' => sprintf('Polling timed out after %d attempt(s).', $maxPolls),
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
$run->duration_ms,
false,
'poll'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
$request->forceFill([
'status' => $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
'safety_state' => $result->safetyState ?? $request->safety_state,
'safety_reasons' => $result->safetyReasons !== [] ? $result->safetyReasons : $request->safety_reasons,
'failure_code' => $result->failureCode,
'failure_message' => $result->failureMessage,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
$result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
$run->duration_ms,
$result->status === 'blocked',
'poll'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
}
public function failed(Throwable $exception): void
{
$request = AiEditRequest::query()->find($this->requestId);
if (! $request) {
return;
}
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
return;
}
$message = trim($exception->getMessage());
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'queue_job_failed',
'failure_message' => $message !== ''
? Str::limit($message, 500, '')
: 'AI edit polling failed in queue.',
'completed_at' => now(),
])->save();
app(AiObservabilityService::class)->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
null,
false,
'poll_failed_hook'
);
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh());
}
}

View File

@@ -1,317 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiEditOutputStorageService;
use App\Services\AiEditing\AiImageProviderManager;
use App\Services\AiEditing\AiObservabilityService;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\AiStatusNotificationService;
use App\Services\AiEditing\AiUsageLedgerService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
class ProcessAiEditRequest implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/**
* @var array<int, int>
*/
public array $backoff = [30, 120, 300];
public int $timeout = 90;
public function __construct(private readonly int $requestId)
{
$queue = (string) config('ai-editing.queue.name', 'default');
$this->onQueue($queue);
}
public function handle(
AiImageProviderManager $providers,
AiSafetyPolicyService $safetyPolicy,
AiAbuseEscalationService $abuseEscalation,
AiObservabilityService $observability,
AiStatusNotificationService $statusNotifications,
AiEditOutputStorageService $outputStorage,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
$request = AiEditRequest::query()->with('style')->find($this->requestId);
if (! $request) {
return;
}
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
return;
}
if ($request->status === AiEditRequest::STATUS_QUEUED) {
$request->forceFill([
'status' => AiEditRequest::STATUS_PROCESSING,
'started_at' => $request->started_at ?: now(),
])->save();
}
$attempt = ((int) $request->providerRuns()->max('attempt')) + 1;
$providerRun = AiProviderRun::query()->create([
'request_id' => $request->id,
'provider' => $request->provider,
'attempt' => $attempt,
'status' => AiProviderRun::STATUS_RUNNING,
'started_at' => now(),
]);
$result = $providers->forProvider($request->provider)->submit($request);
$this->finalizeProviderRun($providerRun, $result);
$this->applyProviderResult(
$request->fresh(['outputs']),
$providerRun,
$result,
$safetyPolicy,
$abuseEscalation,
$observability,
$statusNotifications,
$outputStorage,
$runtimeConfig,
$usageLedger
);
}
public function failed(Throwable $exception): void
{
$request = AiEditRequest::query()->find($this->requestId);
if (! $request) {
return;
}
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
return;
}
$message = trim($exception->getMessage());
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'queue_job_failed',
'failure_message' => $message !== ''
? Str::limit($message, 500, '')
: 'AI edit processing failed in queue.',
'completed_at' => now(),
])->save();
app(AiObservabilityService::class)->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
null,
false,
'process_failed_hook'
);
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh());
}
private function finalizeProviderRun(AiProviderRun $run, AiProviderResult $result): void
{
$missingTaskId = $result->status === 'processing'
&& (! is_string($result->providerTaskId) || trim($result->providerTaskId) === '');
$status = $missingTaskId
? AiProviderRun::STATUS_FAILED
: ($result->status === 'succeeded'
? AiProviderRun::STATUS_SUCCEEDED
: ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED));
$run->forceFill([
'provider_task_id' => $result->providerTaskId,
'status' => $status,
'http_status' => $result->httpStatus,
'finished_at' => $status === AiProviderRun::STATUS_RUNNING ? null : now(),
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
'cost_usd' => $result->costUsd,
'request_payload' => $result->requestPayload,
'response_payload' => $result->responsePayload,
'error_message' => $missingTaskId
? 'Provider returned processing state without task identifier.'
: $result->failureMessage,
])->save();
}
private function applyProviderResult(
AiEditRequest $request,
AiProviderRun $providerRun,
AiProviderResult $result,
AiSafetyPolicyService $safetyPolicy,
AiAbuseEscalationService $abuseEscalation,
AiObservabilityService $observability,
AiStatusNotificationService $statusNotifications,
AiEditOutputStorageService $outputStorage,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
if ($result->status === 'succeeded') {
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
if ($outputDecision->blocked) {
$abuseSignal = $abuseEscalation->recordOutputBlock(
(int) $request->tenant_id,
(int) $request->event_id,
'provider:'.$request->provider
);
$safetyReasons = $outputDecision->reasonCodes;
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
$metadata = (array) ($request->metadata ?? []);
$metadata['abuse'] = $abuseSignal;
$request->forceFill([
'status' => AiEditRequest::STATUS_BLOCKED,
'safety_state' => $outputDecision->state,
'safety_reasons' => $safetyReasons,
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
'failure_message' => $outputDecision->failureMessage,
'metadata' => $metadata,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_BLOCKED,
$providerRun->duration_ms,
true,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
DB::transaction(function () use ($request, $result, $outputStorage): void {
foreach ($result->outputs as $output) {
$persistedOutput = $outputStorage->persist($request, is_array($output) ? $output : []);
AiEditOutput::query()->updateOrCreate(
[
'request_id' => $request->id,
'provider_asset_id' => (string) Arr::get($persistedOutput, 'provider_asset_id', ''),
],
[
'storage_disk' => Arr::get($persistedOutput, 'storage_disk'),
'storage_path' => Arr::get($persistedOutput, 'storage_path'),
'provider_url' => Arr::get($persistedOutput, 'provider_url'),
'mime_type' => Arr::get($persistedOutput, 'mime_type'),
'width' => Arr::get($persistedOutput, 'width'),
'height' => Arr::get($persistedOutput, 'height'),
'bytes' => Arr::get($persistedOutput, 'bytes'),
'checksum' => Arr::get($persistedOutput, 'checksum'),
'is_primary' => true,
'safety_state' => 'passed',
'safety_reasons' => [],
'generated_at' => now(),
'metadata' => array_merge(
['provider' => $request->provider],
is_array(Arr::get($persistedOutput, 'metadata'))
? Arr::get($persistedOutput, 'metadata')
: []
),
]
);
}
$request->forceFill([
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'safety_reasons' => [],
'failure_code' => null,
'failure_message' => null,
'completed_at' => now(),
])->save();
});
$usageLedger->recordDebitForRequest($request->fresh(), $result->costUsd, [
'source' => 'process_job',
]);
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_SUCCEEDED,
$providerRun->duration_ms,
false,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
if ($result->status === 'processing') {
$providerTaskId = trim((string) ($result->providerTaskId ?? ''));
if ($providerTaskId === '') {
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'provider_task_id_missing',
'failure_message' => 'Provider returned processing state without a task identifier.',
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
$providerRun->duration_ms,
false,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
$request->forceFill([
'status' => AiEditRequest::STATUS_PROCESSING,
'failure_code' => null,
'failure_message' => null,
])->save();
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)
->delay(now()->addSeconds(20))
->onQueue($runtimeConfig->queueName());
return;
}
$request->forceFill([
'status' => $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
'safety_state' => $result->safetyState ?? $request->safety_state,
'safety_reasons' => $result->safetyReasons !== [] ? $result->safetyReasons : $request->safety_reasons,
'failure_code' => $result->failureCode,
'failure_message' => $result->failureMessage,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
$result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
$providerRun->duration_ms,
$result->status === 'blocked',
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Jobs;
use App\Models\Package;
use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
use App\Services\Paddle\PaddleCatalogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -13,7 +13,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Throwable;
class PullPackageFromLemonSqueezy implements ShouldQueue
class PullPackageFromPaddle implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
@@ -22,7 +22,7 @@ class PullPackageFromLemonSqueezy implements ShouldQueue
public function __construct(private readonly int $packageId) {}
public function handle(LemonSqueezyCatalogService $catalog): void
public function handle(PaddleCatalogService $catalog): void
{
$package = Package::query()->find($this->packageId);
@@ -30,8 +30,8 @@ class PullPackageFromLemonSqueezy implements ShouldQueue
return;
}
if (! $package->lemonsqueezy_product_id && ! $package->lemonsqueezy_variant_id) {
Log::channel('lemonsqueezy-sync')->warning('Lemon Squeezy pull skipped for package without linkage', [
if (! $package->paddle_product_id && ! $package->paddle_price_id) {
Log::channel('paddle-sync')->warning('Paddle pull skipped for package without linkage', [
'package_id' => $package->id,
]);
@@ -39,41 +39,41 @@ class PullPackageFromLemonSqueezy implements ShouldQueue
}
try {
$product = $package->lemonsqueezy_product_id ? $catalog->fetchProduct($package->lemonsqueezy_product_id) : null;
$price = $package->lemonsqueezy_variant_id ? $catalog->fetchPrice($package->lemonsqueezy_variant_id) : null;
$product = $package->paddle_product_id ? $catalog->fetchProduct($package->paddle_product_id) : null;
$price = $package->paddle_price_id ? $catalog->fetchPrice($package->paddle_price_id) : null;
$snapshot = $package->lemonsqueezy_snapshot ?? [];
$snapshot = $package->paddle_snapshot ?? [];
$snapshot['remote'] = array_filter([
'product' => $product,
'price' => $price,
], static fn ($value) => $value !== null);
$package->forceFill([
'lemonsqueezy_sync_status' => 'pulled',
'lemonsqueezy_synced_at' => now(),
'lemonsqueezy_snapshot' => $snapshot,
'paddle_sync_status' => 'pulled',
'paddle_synced_at' => now(),
'paddle_snapshot' => $snapshot,
])->save();
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package pull completed', [
Log::channel('paddle-sync')->info('Paddle package pull completed', [
'package_id' => $package->id,
]);
} catch (Throwable $exception) {
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package pull failed', [
Log::channel('paddle-sync')->error('Paddle package pull failed', [
'package_id' => $package->id,
'message' => $exception->getMessage(),
'exception' => $exception,
]);
$snapshot = $package->lemonsqueezy_snapshot ?? [];
$snapshot = $package->paddle_snapshot ?? [];
$snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [
'message' => $exception->getMessage(),
'class' => $exception::class,
]);
$package->forceFill([
'lemonsqueezy_sync_status' => 'pull-failed',
'lemonsqueezy_synced_at' => now(),
'lemonsqueezy_snapshot' => $snapshot,
'paddle_sync_status' => 'pull-failed',
'paddle_synced_at' => now(),
'paddle_snapshot' => $snapshot,
])->save();
throw $exception;

View File

@@ -3,8 +3,8 @@
namespace App\Jobs;
use App\Models\Coupon;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezyDiscountService;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleDiscountService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -12,7 +12,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SyncCouponToLemonSqueezy implements ShouldQueue
class SyncCouponToPaddle implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
@@ -24,16 +24,16 @@ class SyncCouponToLemonSqueezy implements ShouldQueue
public bool $archive = false,
) {}
public function handle(LemonSqueezyDiscountService $discounts): void
public function handle(PaddleDiscountService $discounts): void
{
try {
if ($this->archive) {
$discounts->archiveDiscount($this->coupon);
$this->coupon->forceFill([
'lemonsqueezy_discount_id' => null,
'lemonsqueezy_snapshot' => null,
'lemonsqueezy_last_synced_at' => now(),
'paddle_discount_id' => null,
'paddle_snapshot' => null,
'paddle_last_synced_at' => now(),
])->save();
return;
@@ -42,12 +42,12 @@ class SyncCouponToLemonSqueezy implements ShouldQueue
$data = $discounts->updateDiscount($this->coupon);
$this->coupon->forceFill([
'lemonsqueezy_discount_id' => $data['id'] ?? $this->coupon->lemonsqueezy_discount_id,
'lemonsqueezy_snapshot' => $data,
'lemonsqueezy_last_synced_at' => now(),
'paddle_discount_id' => $data['id'] ?? $this->coupon->paddle_discount_id,
'paddle_snapshot' => $data,
'paddle_last_synced_at' => now(),
])->save();
} catch (LemonSqueezyException $exception) {
Log::channel('lemonsqueezy-sync')->error('Failed syncing coupon to Lemon Squeezy', [
} catch (PaddleException $exception) {
Log::channel('paddle-sync')->error('Failed syncing coupon to Paddle', [
'coupon_id' => $this->coupon->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
@@ -55,7 +55,7 @@ class SyncCouponToLemonSqueezy implements ShouldQueue
]);
$this->coupon->forceFill([
'lemonsqueezy_snapshot' => [
'paddle_snapshot' => [
'error' => $exception->getMessage(),
'status' => $exception->status(),
'context' => $exception->context(),

View File

@@ -3,8 +3,8 @@
namespace App\Jobs;
use App\Models\PackageAddon;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezyAddonCatalogService;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleAddonCatalogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Throwable;
class SyncPackageAddonToLemonSqueezy implements ShouldQueue
class SyncPackageAddonToPaddle implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
@@ -26,7 +26,7 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
*/
public function __construct(private readonly int $addonId, private readonly array $options = []) {}
public function handle(LemonSqueezyAddonCatalogService $catalog): void
public function handle(PaddleAddonCatalogService $catalog): void
{
$addon = PackageAddon::query()->find($this->addonId);
@@ -39,7 +39,7 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
$priceOverrides = Arr::get($this->options, 'price', []);
if ($dryRun) {
$this->storeDryRunSnapshot($addon, $productOverrides, $priceOverrides);
$this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides);
return;
}
@@ -47,41 +47,41 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
// Mark syncing (metadata)
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'lemonsqueezy_sync_status' => 'syncing',
'lemonsqueezy_synced_at' => now()->toIso8601String(),
'paddle_sync_status' => 'syncing',
'paddle_synced_at' => now()->toIso8601String(),
]),
])->save();
try {
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
$productResponse = $addon->metadata['lemonsqueezy_product_id'] ?? null
? $catalog->updateProduct($addon->metadata['lemonsqueezy_product_id'], $addon, $payloadOverrides['product'])
$productResponse = $addon->metadata['paddle_product_id'] ?? null
? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product'])
: $catalog->createProduct($addon, $payloadOverrides['product']);
$productId = (string) ($productResponse['id'] ?? $addon->metadata['lemonsqueezy_product_id'] ?? null);
$productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null);
if (! $productId) {
throw new LemonSqueezyException('Lemon Squeezy product ID missing after addon sync.');
throw new PaddleException('Paddle product ID missing after addon sync.');
}
$priceResponse = $addon->variant_id
? $catalog->updatePrice($addon->variant_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
$priceResponse = $addon->price_id
? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
$priceId = (string) ($priceResponse['id'] ?? $addon->variant_id);
$priceId = (string) ($priceResponse['id'] ?? $addon->price_id);
if (! $priceId) {
throw new LemonSqueezyException('Lemon Squeezy variant ID missing after addon sync.');
throw new PaddleException('Paddle price ID missing after addon sync.');
}
$addon->forceFill([
'variant_id' => $priceId,
'price_id' => $priceId,
'metadata' => array_merge($addon->metadata ?? [], [
'lemonsqueezy_sync_status' => 'synced',
'lemonsqueezy_synced_at' => now()->toIso8601String(),
'lemonsqueezy_product_id' => $productId,
'lemonsqueezy_snapshot' => [
'paddle_sync_status' => 'synced',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_product_id' => $productId,
'paddle_snapshot' => [
'product' => $productResponse,
'price' => $priceResponse,
'payload' => $payloadOverrides,
@@ -89,7 +89,7 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
]),
])->save();
} catch (Throwable $exception) {
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy addon sync failed', [
Log::channel('paddle-sync')->error('Paddle addon sync failed', [
'addon_id' => $addon->id,
'message' => $exception->getMessage(),
'exception' => $exception,
@@ -97,9 +97,9 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'lemonsqueezy_sync_status' => 'failed',
'lemonsqueezy_synced_at' => now()->toIso8601String(),
'lemonsqueezy_error' => [
'paddle_sync_status' => 'failed',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_error' => [
'message' => $exception->getMessage(),
'class' => $exception::class,
],
@@ -145,22 +145,22 @@ class SyncPackageAddonToLemonSqueezy implements ShouldQueue
* @param array<string, mixed> $productOverrides
* @param array<string, mixed> $priceOverrides
*/
protected function storeDryRunSnapshot(PackageAddon $addon, array $productOverrides, array $priceOverrides): void
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void
{
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'lemonsqueezy_sync_status' => 'dry-run',
'lemonsqueezy_synced_at' => now()->toIso8601String(),
'lemonsqueezy_snapshot' => [
'paddle_sync_status' => 'dry-run',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_snapshot' => [
'dry_run' => true,
'payload' => $payloadOverrides,
],
]),
])->save();
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy addon dry-run snapshot generated', [
Log::channel('paddle-sync')->info('Paddle addon dry-run snapshot generated', [
'addon_id' => $addon->id,
]);
}

View File

@@ -3,8 +3,8 @@
namespace App\Jobs;
use App\Models\Package;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleCatalogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Throwable;
class SyncPackageToLemonSqueezy implements ShouldQueue
class SyncPackageToPaddle implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
@@ -26,7 +26,7 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
*/
public function __construct(private readonly int $packageId, private readonly array $options = []) {}
public function handle(LemonSqueezyCatalogService $catalog): void
public function handle(PaddleCatalogService $catalog): void
{
$package = Package::query()->find($this->packageId);
@@ -45,37 +45,37 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
}
$package->forceFill([
'lemonsqueezy_sync_status' => 'syncing',
'paddle_sync_status' => 'syncing',
])->save();
try {
$productResponse = $package->lemonsqueezy_product_id
? $catalog->updateProduct($package->lemonsqueezy_product_id, $package, $productOverrides)
$productResponse = $package->paddle_product_id
? $catalog->updateProduct($package->paddle_product_id, $package, $productOverrides)
: $catalog->createProduct($package, $productOverrides);
$productId = (string) ($productResponse['id'] ?? $package->lemonsqueezy_product_id);
$productId = (string) ($productResponse['id'] ?? $package->paddle_product_id);
if (! $productId) {
throw new LemonSqueezyException('Lemon Squeezy product ID missing after sync.');
throw new PaddleException('Paddle product ID missing after sync.');
}
$package->lemonsqueezy_product_id = $productId;
$package->paddle_product_id = $productId;
$priceResponse = $package->lemonsqueezy_variant_id
? $catalog->updatePrice($package->lemonsqueezy_variant_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
$priceResponse = $package->paddle_price_id
? $catalog->updatePrice($package->paddle_price_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
: $catalog->createPrice($package, $productId, $priceOverrides);
$priceId = (string) ($priceResponse['id'] ?? $package->lemonsqueezy_variant_id);
$priceId = (string) ($priceResponse['id'] ?? $package->paddle_price_id);
if (! $priceId) {
throw new LemonSqueezyException('Lemon Squeezy variant ID missing after sync.');
throw new PaddleException('Paddle price ID missing after sync.');
}
$package->forceFill([
'lemonsqueezy_variant_id' => $priceId,
'lemonsqueezy_sync_status' => 'synced',
'lemonsqueezy_synced_at' => now(),
'lemonsqueezy_snapshot' => [
'paddle_price_id' => $priceId,
'paddle_sync_status' => 'synced',
'paddle_synced_at' => now(),
'paddle_snapshot' => [
'product' => $productResponse,
'price' => $priceResponse,
'payload' => [
@@ -85,16 +85,16 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
],
])->save();
} catch (Throwable $exception) {
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package sync failed', [
Log::channel('paddle-sync')->error('Paddle package sync failed', [
'package_id' => $package->id,
'message' => $exception->getMessage(),
'exception' => $exception,
]);
$package->forceFill([
'lemonsqueezy_sync_status' => 'failed',
'lemonsqueezy_synced_at' => now(),
'lemonsqueezy_snapshot' => array_merge($package->lemonsqueezy_snapshot ?? [], [
'paddle_sync_status' => 'failed',
'paddle_synced_at' => now(),
'paddle_snapshot' => array_merge($package->paddle_snapshot ?? [], [
'error' => [
'message' => $exception->getMessage(),
'class' => $exception::class,
@@ -110,19 +110,19 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
* @param array<string, mixed> $productOverrides
* @param array<string, mixed> $priceOverrides
*/
protected function storeDryRunSnapshot(LemonSqueezyCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
{
$productPayload = $catalog->buildProductPayload($package, $productOverrides);
$pricePayload = $catalog->buildPricePayload(
$package,
$package->lemonsqueezy_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
$package->paddle_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
$priceOverrides
);
$package->forceFill([
'lemonsqueezy_sync_status' => 'dry-run',
'lemonsqueezy_synced_at' => now(),
'lemonsqueezy_snapshot' => [
'paddle_sync_status' => 'dry-run',
'paddle_synced_at' => now(),
'paddle_snapshot' => [
'dry_run' => true,
'payload' => [
'product' => $productPayload,
@@ -131,7 +131,7 @@ class SyncPackageToLemonSqueezy implements ShouldQueue
],
])->save();
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package dry-run snapshot generated', [
Log::channel('paddle-sync')->info('Paddle package dry-run snapshot generated', [
'package_id' => $package->id,
]);
}

View File

@@ -60,7 +60,7 @@ class PurchaseConfirmation extends Mailable
private function formattedTotal(): string
{
$totals = $this->purchase->metadata['lemonsqueezy_totals'] ?? [];
$totals = $this->purchase->metadata['paddle_totals'] ?? [];
$currency = $totals['currency']
?? $this->purchase->metadata['currency']
?? $this->purchase->package?->currency
@@ -113,7 +113,7 @@ class PurchaseConfirmation extends Mailable
private function providerLabel(): string
{
$provider = $this->purchase->provider ?? 'lemonsqueezy';
$provider = $this->purchase->provider ?? 'paddle';
$labelKey = 'emails.purchase.provider.'.$provider;
$label = __($labelKey);

View File

@@ -1,51 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiEditOutput extends Model
{
use HasFactory;
protected $fillable = [
'request_id',
'photo_id',
'storage_disk',
'storage_path',
'mime_type',
'width',
'height',
'bytes',
'checksum',
'provider_asset_id',
'provider_url',
'is_primary',
'safety_state',
'safety_reasons',
'generated_at',
'metadata',
];
protected function casts(): array
{
return [
'is_primary' => 'boolean',
'safety_reasons' => 'array',
'metadata' => 'array',
'generated_at' => 'datetime',
];
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
public function photo(): BelongsTo
{
return $this->belongsTo(Photo::class);
}
}

View File

@@ -1,103 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AiEditRequest extends Model
{
use HasFactory;
public const STATUS_QUEUED = 'queued';
public const STATUS_PROCESSING = 'processing';
public const STATUS_SUCCEEDED = 'succeeded';
public const STATUS_FAILED = 'failed';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_CANCELED = 'canceled';
protected $fillable = [
'tenant_id',
'event_id',
'photo_id',
'style_id',
'requested_by_user_id',
'provider',
'provider_model',
'status',
'safety_state',
'prompt',
'negative_prompt',
'input_image_path',
'requested_by_device_id',
'requested_by_session_id',
'idempotency_key',
'safety_reasons',
'failure_code',
'failure_message',
'queued_at',
'started_at',
'completed_at',
'expires_at',
'metadata',
];
protected function casts(): array
{
return [
'safety_reasons' => 'array',
'metadata' => 'array',
'queued_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function photo(): BelongsTo
{
return $this->belongsTo(Photo::class);
}
public function style(): BelongsTo
{
return $this->belongsTo(AiStyle::class, 'style_id');
}
public function requestedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by_user_id');
}
public function outputs(): HasMany
{
return $this->hasMany(AiEditOutput::class, 'request_id');
}
public function providerRuns(): HasMany
{
return $this->hasMany(AiProviderRun::class, 'request_id');
}
public function usageLedgers(): HasMany
{
return $this->hasMany(AiUsageLedger::class, 'request_id');
}
}

View File

@@ -1,63 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Throwable;
class AiEditingSetting extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'is_enabled' => 'boolean',
'queue_auto_dispatch' => 'boolean',
'queue_max_polls' => 'integer',
'blocked_terms' => 'array',
];
}
protected static function booted(): void
{
static::saved(fn () => static::flushCache());
static::deleted(fn () => static::flushCache());
}
public static function current(): self
{
/** @var self */
return Cache::remember('ai_editing.settings', now()->addMinutes(10), static function (): self {
try {
return static::query()->firstOrCreate(['id' => 1], static::defaults());
} catch (Throwable) {
return new static(static::defaults());
}
});
}
/**
* @return array<string, mixed>
*/
public static function defaults(): array
{
return [
'is_enabled' => true,
'default_provider' => (string) config('ai-editing.default_provider', 'runware'),
'fallback_provider' => null,
'runware_mode' => (string) config('ai-editing.providers.runware.mode', 'live'),
'queue_auto_dispatch' => (bool) config('ai-editing.queue.auto_dispatch', false),
'queue_name' => (string) config('ai-editing.queue.name', 'default'),
'queue_max_polls' => max(1, (int) config('ai-editing.queue.max_polls', 6)),
'blocked_terms' => array_values(array_filter((array) config('ai-editing.safety.prompt.blocked_terms', []))),
'status_message' => null,
];
}
public static function flushCache(): void
{
Cache::forget('ai_editing.settings');
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiProviderRun extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCEEDED = 'succeeded';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'request_id',
'provider',
'attempt',
'provider_task_id',
'status',
'http_status',
'started_at',
'finished_at',
'duration_ms',
'cost_usd',
'tokens_input',
'tokens_output',
'request_payload',
'response_payload',
'error_message',
'metadata',
];
protected function casts(): array
{
return [
'request_payload' => 'array',
'response_payload' => 'array',
'metadata' => 'array',
'cost_usd' => 'decimal:5',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
}

View File

@@ -1,73 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AiStyle extends Model
{
use HasFactory;
protected static function booted(): void
{
static::creating(function (self $style): void {
if ((int) ($style->version ?? 0) < 1) {
$style->version = 1;
}
});
static::updating(function (self $style): void {
$versionedFields = [
'prompt_template',
'negative_prompt_template',
'provider',
'provider_model',
'metadata',
'is_premium',
'requires_source_image',
];
if ($style->isDirty($versionedFields)) {
$current = max(1, (int) ($style->getOriginal('version') ?? 1));
$requested = max(1, (int) ($style->version ?? 0));
$style->version = max($requested, $current + 1);
}
});
}
protected $fillable = [
'key',
'name',
'version',
'category',
'description',
'prompt_template',
'negative_prompt_template',
'provider',
'provider_model',
'requires_source_image',
'is_premium',
'is_active',
'sort',
'metadata',
];
protected function casts(): array
{
return [
'requires_source_image' => 'boolean',
'is_premium' => 'boolean',
'is_active' => 'boolean',
'version' => 'integer',
'sort' => 'integer',
'metadata' => 'array',
];
}
public function editRequests(): HasMany
{
return $this->hasMany(AiEditRequest::class, 'style_id');
}
}

View File

@@ -1,60 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiUsageLedger extends Model
{
use HasFactory;
public const TYPE_DEBIT = 'debit';
public const TYPE_CREDIT = 'credit';
public const TYPE_REFUND = 'refund';
public const TYPE_ADJUSTMENT = 'adjustment';
protected $fillable = [
'tenant_id',
'event_id',
'request_id',
'entry_type',
'quantity',
'unit_cost_usd',
'amount_usd',
'currency',
'package_context',
'notes',
'recorded_at',
'metadata',
];
protected function casts(): array
{
return [
'unit_cost_usd' => 'decimal:5',
'amount_usd' => 'decimal:5',
'recorded_at' => 'datetime',
'metadata' => 'array',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
}

View File

@@ -30,9 +30,7 @@ class CheckoutSession extends Model
public const PROVIDER_NONE = 'none';
public const PROVIDER_LEMONSQUEEZY = 'lemonsqueezy';
public const PROVIDER_PAYPAL = 'paypal';
public const PROVIDER_PADDLE = 'paddle';
public const PROVIDER_FREE = 'free';

Some files were not shown because too many files have changed in this diff Show More