Compare commits
130 Commits
941931934f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6062b4201b | ||
|
|
298a8375b6 | ||
|
|
10c99de1e2 | ||
|
|
c96a73d884 | ||
|
|
7c6e14ffe2 | ||
|
|
2e78f3ab8d | ||
|
|
386d0004ed | ||
|
|
e233cddcc8 | ||
|
|
e39ddd2143 | ||
|
|
b1f9f7cee0 | ||
|
|
916b204688 | ||
|
|
d45cb6a087 | ||
|
|
f574ffaf38 | ||
|
|
b866179521 | ||
|
|
3ba784154b | ||
|
|
96aaea23e4 | ||
|
|
4b1785fb85 | ||
|
|
8aba034344 | ||
|
|
19425c0f62 | ||
|
|
1443ff0d3a | ||
|
|
e48ec3c564 | ||
|
|
eeffe4c6f1 | ||
|
|
9a8305d986 | ||
|
|
6ca0b50403 | ||
|
|
ce7da1ff66 | ||
|
|
87f348462b | ||
|
|
dba0cd5882 | ||
|
|
78af7838bf | ||
|
|
b8bb7926c0 | ||
|
|
6e4656946c | ||
|
|
c94fbe4ab8 | ||
|
|
9ccf079a3a | ||
|
|
e0e9723b11 | ||
|
|
0d2759b0d4 | ||
|
|
f0e8cee850 | ||
|
|
981df2ee45 | ||
|
|
6bc1d86009 | ||
|
|
53a6500e6a | ||
|
|
75c4dbd1f0 | ||
|
|
5d48b804a5 | ||
|
|
80dca9fe67 | ||
|
|
78bd3c9267 | ||
|
|
c4ac38e41a | ||
|
|
84e253b61c | ||
|
|
8414305ea3 | ||
|
|
694ce218c9 | ||
|
|
ec98086e23 | ||
|
|
d87d22fa22 | ||
|
|
a21321bb3c | ||
|
|
7a91e40bb3 | ||
|
|
71604c6e41 | ||
|
|
2b4d9e9411 | ||
|
|
35d8c94c11 | ||
|
|
ce43cac145 | ||
|
|
b11f010938 | ||
|
|
e3b356e810 | ||
|
|
6bd75b0788 | ||
|
|
14bb375674 | ||
|
|
a33bf0e3a4 | ||
|
|
1241f5092e | ||
|
|
73728f6baf | ||
|
|
db90b9af2e | ||
|
|
7dd8bc4c91 | ||
|
|
ee6fb7a5bb | ||
|
|
1c4c93c547 | ||
|
|
bdb1789a10 | ||
|
|
4bf0d5052c | ||
|
|
d629b745c4 | ||
|
|
72dd1409e8 | ||
|
|
2729c3c713 | ||
|
|
4135deb110 | ||
|
|
fda97b3c05 | ||
|
|
55608c311d | ||
|
|
ead80025fc | ||
|
|
d000d9b456 | ||
|
|
ebfcc090d6 | ||
|
|
49c4f9ad7d | ||
|
|
0089a14204 | ||
|
|
0eb3b85f06 | ||
|
|
db0fdc58a1 | ||
|
|
0db0ddf3c4 | ||
|
|
df5e8204fa | ||
|
|
6f7bf818dd | ||
|
|
b3ea522e31 | ||
|
|
b267ae2c15 | ||
|
|
4706b21d22 | ||
|
|
96e65ffc0b | ||
|
|
348834250a | ||
|
|
31a5148263 | ||
|
|
35f28fd48d | ||
|
|
53a90fec33 | ||
|
|
e1a2850768 | ||
|
|
03ee16bb87 | ||
|
|
1313135020 | ||
|
|
85f2c42fc5 | ||
|
|
6318aec3cb | ||
|
|
056d864f80 | ||
|
|
ef88342bd0 | ||
|
|
d76b26b7ad | ||
|
|
c1dfbaa51e | ||
|
|
32644eb41e | ||
|
|
db5fea9f2a | ||
|
|
fba9714ede | ||
|
|
cebc1d1ec5 | ||
|
|
5aa79b587d | ||
|
|
2e089f7f77 | ||
|
|
fd52f8e13d | ||
|
|
8ac38cf264 | ||
|
|
66193a6461 | ||
|
|
64c9d7357a | ||
|
|
8aa2efdd9a | ||
|
|
4f3503e3f4 | ||
|
|
4235eda49a | ||
|
|
ad0e8b7923 | ||
|
|
446eb15c6b | ||
|
|
02a24877f7 | ||
|
|
f016004b2b | ||
|
|
a0248d976b | ||
|
|
99a880854a | ||
|
|
a3747138a4 | ||
|
|
287cc8a532 | ||
|
|
191f39cf5b | ||
|
|
543b3015ca | ||
|
|
9d3c866562 | ||
|
|
911880f1a0 | ||
|
|
b9d91c8f40 | ||
|
|
23193a3452 | ||
|
|
da6f95aead | ||
|
|
2f9a700e00 | ||
|
|
50cc4e76df |
@@ -17,6 +17,7 @@
|
||||
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
|
||||
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
|
||||
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
|
||||
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
||||
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
|
||||
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
fotospiel-app-5veo
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"database": "beads.db",
|
||||
"jsonl_export": "issues.jsonl"
|
||||
}
|
||||
"jsonl_export": "issues.jsonl",
|
||||
"last_bd_version": "0.49.0"
|
||||
}
|
||||
33
.env.example
33
.env.example
@@ -97,6 +97,11 @@ GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
|
||||
|
||||
# Facebook OAuth (Checkout comfort login)
|
||||
FACEBOOK_CLIENT_ID=
|
||||
FACEBOOK_CLIENT_SECRET=
|
||||
FACEBOOK_REDIRECT_URI=${APP_URL}/checkout/auth/facebook/callback
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_ENABLE_TENANT_SWITCHER=false
|
||||
REVENUECAT_WEBHOOK_SECRET=
|
||||
@@ -112,14 +117,22 @@ PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_SANDBOX=true
|
||||
|
||||
# Paddle Billing
|
||||
PADDLE_SANDBOX=true
|
||||
PADDLE_API_KEY=
|
||||
PADDLE_CLIENT_ID=
|
||||
PADDLE_WEBHOOK_SECRET=
|
||||
PADDLE_PUBLIC_KEY=
|
||||
PADDLE_BASE_URL=
|
||||
PADDLE_CONSOLE_URL=
|
||||
# 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=
|
||||
|
||||
# Sanctum / SPA auth
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
|
||||
@@ -187,5 +200,9 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
|
||||
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
|
||||
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
|
||||
STORAGE_QUEUE_GUEST_ALERT_TTL=30
|
||||
STORAGE_CHECKSUM_VALIDATION=true
|
||||
STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES=60
|
||||
STORAGE_CHECKSUM_WARNING=1
|
||||
STORAGE_CHECKSUM_CRITICAL=5
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
881264
.tamagui/tamagui.config.json
881264
.tamagui/tamagui.config.json
File diff suppressed because it is too large
Load Diff
@@ -118,319 +118,8 @@ var isWindowDefined = typeof window < "u";
|
||||
var isClient = isWeb && isWindowDefined;
|
||||
var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
|
||||
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||
var isAndroid = false;
|
||||
var isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
|
||||
|
||||
// node_modules/@tamagui/helpers/dist/esm/validStyleProps.mjs
|
||||
var textColors = {
|
||||
color: true,
|
||||
textDecorationColor: true,
|
||||
textShadowColor: true
|
||||
};
|
||||
var tokenCategories = {
|
||||
radius: {
|
||||
borderRadius: true,
|
||||
borderTopLeftRadius: true,
|
||||
borderTopRightRadius: true,
|
||||
borderBottomLeftRadius: true,
|
||||
borderBottomRightRadius: true,
|
||||
// logical
|
||||
borderStartStartRadius: true,
|
||||
borderStartEndRadius: true,
|
||||
borderEndStartRadius: true,
|
||||
borderEndEndRadius: true
|
||||
},
|
||||
size: {
|
||||
width: true,
|
||||
height: true,
|
||||
minWidth: true,
|
||||
minHeight: true,
|
||||
maxWidth: true,
|
||||
maxHeight: true,
|
||||
blockSize: true,
|
||||
minBlockSize: true,
|
||||
maxBlockSize: true,
|
||||
inlineSize: true,
|
||||
minInlineSize: true,
|
||||
maxInlineSize: true
|
||||
},
|
||||
zIndex: {
|
||||
zIndex: true
|
||||
},
|
||||
color: {
|
||||
backgroundColor: true,
|
||||
borderColor: true,
|
||||
borderBlockStartColor: true,
|
||||
borderBlockEndColor: true,
|
||||
borderBlockColor: true,
|
||||
borderBottomColor: true,
|
||||
borderInlineColor: true,
|
||||
borderInlineStartColor: true,
|
||||
borderInlineEndColor: true,
|
||||
borderTopColor: true,
|
||||
borderLeftColor: true,
|
||||
borderRightColor: true,
|
||||
borderEndColor: true,
|
||||
borderStartColor: true,
|
||||
shadowColor: true,
|
||||
...textColors,
|
||||
outlineColor: true,
|
||||
caretColor: true
|
||||
}
|
||||
};
|
||||
var stylePropsUnitless = {
|
||||
WebkitLineClamp: true,
|
||||
animationIterationCount: true,
|
||||
aspectRatio: true,
|
||||
borderImageOutset: true,
|
||||
borderImageSlice: true,
|
||||
borderImageWidth: true,
|
||||
columnCount: true,
|
||||
flex: true,
|
||||
flexGrow: true,
|
||||
flexOrder: true,
|
||||
flexPositive: true,
|
||||
flexShrink: true,
|
||||
flexNegative: true,
|
||||
fontWeight: true,
|
||||
gridRow: true,
|
||||
gridRowEnd: true,
|
||||
gridRowGap: true,
|
||||
gridRowStart: true,
|
||||
gridColumn: true,
|
||||
gridColumnEnd: true,
|
||||
gridColumnGap: true,
|
||||
gridColumnStart: true,
|
||||
gridTemplateColumns: true,
|
||||
gridTemplateAreas: true,
|
||||
lineClamp: true,
|
||||
opacity: true,
|
||||
order: true,
|
||||
orphans: true,
|
||||
tabSize: true,
|
||||
widows: true,
|
||||
zIndex: true,
|
||||
zoom: true,
|
||||
scale: true,
|
||||
scaleX: true,
|
||||
scaleY: true,
|
||||
scaleZ: true,
|
||||
shadowOpacity: true
|
||||
};
|
||||
var stylePropsTransform = {
|
||||
x: true,
|
||||
y: true,
|
||||
scale: true,
|
||||
perspective: true,
|
||||
scaleX: true,
|
||||
scaleY: true,
|
||||
skewX: true,
|
||||
skewY: true,
|
||||
matrix: true,
|
||||
rotate: true,
|
||||
rotateY: true,
|
||||
rotateX: true,
|
||||
rotateZ: true
|
||||
};
|
||||
var stylePropsView = {
|
||||
backfaceVisibility: true,
|
||||
borderBottomEndRadius: true,
|
||||
borderBottomStartRadius: true,
|
||||
borderBottomWidth: true,
|
||||
borderLeftWidth: true,
|
||||
borderRightWidth: true,
|
||||
borderBlockWidth: true,
|
||||
borderBlockEndWidth: true,
|
||||
borderBlockStartWidth: true,
|
||||
borderInlineWidth: true,
|
||||
borderInlineEndWidth: true,
|
||||
borderInlineStartWidth: true,
|
||||
borderStyle: true,
|
||||
borderBlockStyle: true,
|
||||
borderBlockEndStyle: true,
|
||||
borderBlockStartStyle: true,
|
||||
borderInlineStyle: true,
|
||||
borderInlineEndStyle: true,
|
||||
borderInlineStartStyle: true,
|
||||
borderTopEndRadius: true,
|
||||
borderTopStartRadius: true,
|
||||
borderTopWidth: true,
|
||||
borderWidth: true,
|
||||
transform: true,
|
||||
transformOrigin: true,
|
||||
alignContent: true,
|
||||
alignItems: true,
|
||||
alignSelf: true,
|
||||
borderEndWidth: true,
|
||||
borderStartWidth: true,
|
||||
bottom: true,
|
||||
display: true,
|
||||
end: true,
|
||||
flexBasis: true,
|
||||
flexDirection: true,
|
||||
flexWrap: true,
|
||||
gap: true,
|
||||
columnGap: true,
|
||||
rowGap: true,
|
||||
justifyContent: true,
|
||||
left: true,
|
||||
margin: true,
|
||||
marginBlock: true,
|
||||
marginBlockEnd: true,
|
||||
marginBlockStart: true,
|
||||
marginInline: true,
|
||||
marginInlineStart: true,
|
||||
marginInlineEnd: true,
|
||||
marginBottom: true,
|
||||
marginEnd: true,
|
||||
marginHorizontal: true,
|
||||
marginLeft: true,
|
||||
marginRight: true,
|
||||
marginStart: true,
|
||||
marginTop: true,
|
||||
marginVertical: true,
|
||||
overflow: true,
|
||||
padding: true,
|
||||
paddingBottom: true,
|
||||
paddingInline: true,
|
||||
paddingBlock: true,
|
||||
paddingBlockStart: true,
|
||||
paddingInlineEnd: true,
|
||||
paddingInlineStart: true,
|
||||
paddingEnd: true,
|
||||
paddingHorizontal: true,
|
||||
paddingLeft: true,
|
||||
paddingRight: true,
|
||||
paddingStart: true,
|
||||
paddingTop: true,
|
||||
paddingVertical: true,
|
||||
position: true,
|
||||
right: true,
|
||||
start: true,
|
||||
top: true,
|
||||
inset: true,
|
||||
insetBlock: true,
|
||||
insetBlockEnd: true,
|
||||
insetBlockStart: true,
|
||||
insetInline: true,
|
||||
insetInlineEnd: true,
|
||||
insetInlineStart: true,
|
||||
direction: true,
|
||||
shadowOffset: true,
|
||||
shadowRadius: true,
|
||||
...tokenCategories.color,
|
||||
...tokenCategories.radius,
|
||||
...tokenCategories.size,
|
||||
...tokenCategories.radius,
|
||||
...stylePropsTransform,
|
||||
...stylePropsUnitless,
|
||||
boxShadow: true,
|
||||
filter: true,
|
||||
// RN 0.77+ style props (set REACT_NATIVE_PRE_77=1 for older RN)
|
||||
...!process.env.REACT_NATIVE_PRE_77 && {
|
||||
boxSizing: true,
|
||||
mixBlendMode: true,
|
||||
outlineColor: true,
|
||||
outlineSpread: true,
|
||||
outlineStyle: true,
|
||||
outlineWidth: true
|
||||
},
|
||||
// RN doesn't support specific border styles per-edge
|
||||
transition: true,
|
||||
textWrap: true,
|
||||
backdropFilter: true,
|
||||
WebkitBackdropFilter: true,
|
||||
background: true,
|
||||
backgroundAttachment: true,
|
||||
backgroundBlendMode: true,
|
||||
backgroundClip: true,
|
||||
backgroundColor: true,
|
||||
backgroundImage: true,
|
||||
backgroundOrigin: true,
|
||||
backgroundPosition: true,
|
||||
backgroundRepeat: true,
|
||||
backgroundSize: true,
|
||||
borderBottomStyle: true,
|
||||
borderImage: true,
|
||||
borderLeftStyle: true,
|
||||
borderRightStyle: true,
|
||||
borderTopStyle: true,
|
||||
caretColor: true,
|
||||
clipPath: true,
|
||||
contain: true,
|
||||
containerType: true,
|
||||
content: true,
|
||||
cursor: true,
|
||||
float: true,
|
||||
mask: true,
|
||||
maskBorder: true,
|
||||
maskBorderMode: true,
|
||||
maskBorderOutset: true,
|
||||
maskBorderRepeat: true,
|
||||
maskBorderSlice: true,
|
||||
maskBorderSource: true,
|
||||
maskBorderWidth: true,
|
||||
maskClip: true,
|
||||
maskComposite: true,
|
||||
maskImage: true,
|
||||
maskMode: true,
|
||||
maskOrigin: true,
|
||||
maskPosition: true,
|
||||
maskRepeat: true,
|
||||
maskSize: true,
|
||||
maskType: true,
|
||||
objectFit: true,
|
||||
objectPosition: true,
|
||||
outlineOffset: true,
|
||||
overflowBlock: true,
|
||||
overflowInline: true,
|
||||
overflowX: true,
|
||||
overflowY: true,
|
||||
pointerEvents: true,
|
||||
scrollbarWidth: true,
|
||||
textEmphasis: true,
|
||||
touchAction: true,
|
||||
transformStyle: true,
|
||||
userSelect: true,
|
||||
willChange: true,
|
||||
...isAndroid ? {
|
||||
elevationAndroid: true
|
||||
} : {}
|
||||
};
|
||||
var stylePropsFont = {
|
||||
fontFamily: true,
|
||||
fontSize: true,
|
||||
fontStyle: true,
|
||||
fontWeight: true,
|
||||
fontVariant: true,
|
||||
letterSpacing: true,
|
||||
lineHeight: true,
|
||||
textTransform: true
|
||||
};
|
||||
var stylePropsTextOnly = {
|
||||
...stylePropsFont,
|
||||
textAlign: true,
|
||||
textDecorationLine: true,
|
||||
textDecorationStyle: true,
|
||||
...textColors,
|
||||
textShadowOffset: true,
|
||||
textShadowRadius: true,
|
||||
userSelect: true,
|
||||
selectable: true,
|
||||
verticalAlign: true,
|
||||
whiteSpace: true,
|
||||
wordWrap: true,
|
||||
textOverflow: true,
|
||||
textDecorationDistance: true,
|
||||
cursor: true,
|
||||
WebkitLineClamp: true,
|
||||
WebkitBoxOrient: true
|
||||
};
|
||||
var stylePropsText = {
|
||||
...stylePropsView,
|
||||
...stylePropsTextOnly
|
||||
};
|
||||
|
||||
// node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs
|
||||
var import_react2 = __toESM(require("react"), 1);
|
||||
var Decorated = Symbol();
|
||||
@@ -755,7 +444,10 @@ var SizableText2 = (0, import_web4.styled)(import_web4.Text, {
|
||||
}
|
||||
});
|
||||
SizableText2.staticConfig.variants.fontFamily = {
|
||||
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||
"...": /* @__PURE__ */ __name((val, extras) => {
|
||||
if (val === "inherit") return {
|
||||
fontFamily: "inherit"
|
||||
};
|
||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||
return getFontSized(size, extras);
|
||||
}, "...")
|
||||
|
||||
@@ -112,7 +112,10 @@ var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
|
||||
}
|
||||
});
|
||||
SizableText2.staticConfig.variants.fontFamily = {
|
||||
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||
"...": /* @__PURE__ */ __name((val, extras) => {
|
||||
if (val === "inherit") return {
|
||||
fontFamily: "inherit"
|
||||
};
|
||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||
return getFontSized(size, extras);
|
||||
}, "...")
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
|
||||
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
|
||||
|
||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; 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).
|
||||
- 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).
|
||||
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
||||
|
||||
## Repo Structure (high-level)
|
||||
@@ -38,6 +38,9 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||
- resources/js/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):
|
||||
@@ -58,7 +61,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.
|
||||
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run).
|
||||
- lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
|
||||
- coupons:export — export coupon redemptions.
|
||||
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
|
||||
|
||||
@@ -93,7 +96,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
|
||||
- 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: Paddle webhooks, RevenueCat mobile subscriptions.
|
||||
- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
|
||||
|
||||
## PWA Architecture
|
||||
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
||||
|
||||
@@ -100,6 +100,8 @@ 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 \
|
||||
|
||||
130
app/Console/Commands/LemonSqueezyRegisterWebhooks.php
Normal file
130
app/Console/Commands/LemonSqueezyRegisterWebhooks.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\LemonSqueezy\LemonSqueezyClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LemonSqueezyRegisterWebhooks extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'lemonsqueezy:webhooks:register
|
||||
{--url= : Destination URL for Lemon Squeezy webhooks}
|
||||
{--events=* : Override event types to subscribe}
|
||||
{--secret= : Override the webhook signing secret}
|
||||
{--test-mode : Register the webhook in test mode}
|
||||
{--dry-run : Output payload without creating the destination}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Register Lemon Squeezy webhook notification settings.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(LemonSqueezyClient $client): int
|
||||
{
|
||||
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
|
||||
|
||||
if ($destination === '') {
|
||||
$this->error('Webhook destination URL is required. Use --url=...');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$events = collect((array) $this->option('events'))
|
||||
->filter()
|
||||
->map(fn ($event) => trim((string) $event))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($events === []) {
|
||||
$events = config('lemonsqueezy.webhook_events', []);
|
||||
}
|
||||
|
||||
if ($events === [] || ! is_array($events)) {
|
||||
$this->error('No webhook events configured. Set config(lemonsqueezy.webhook_events) or pass --events.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$secret = (string) ($this->option('secret') ?: config('lemonsqueezy.webhook_secret'));
|
||||
if ($secret === '') {
|
||||
$this->error('Webhook signing secret is required. Set LEMONSQUEEZY_WEBHOOK_SECRET or pass --secret.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$storeId = (string) config('lemonsqueezy.store_id');
|
||||
if ($storeId === '') {
|
||||
$this->error('Lemon Squeezy store id is required. Set LEMONSQUEEZY_STORE_ID.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$testMode = (bool) $this->option('test-mode') || (bool) config('lemonsqueezy.test_mode', false);
|
||||
|
||||
$attributes = array_filter([
|
||||
'url' => $destination,
|
||||
'events' => $events,
|
||||
'secret' => $secret,
|
||||
'test_mode' => $testMode ? true : null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$payload = [
|
||||
'data' => [
|
||||
'type' => 'webhooks',
|
||||
'attributes' => $attributes,
|
||||
'relationships' => [
|
||||
'store' => [
|
||||
'data' => [
|
||||
'type' => 'stores',
|
||||
'id' => $storeId,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ((bool) $this->option('dry-run')) {
|
||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$response = $client->post('/webhooks', $payload);
|
||||
$data = Arr::get($response, 'data', $response);
|
||||
$id = Arr::get($data, 'id');
|
||||
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy webhook registered', [
|
||||
'webhook_id' => $id,
|
||||
'destination' => $destination,
|
||||
'test_mode' => $testMode,
|
||||
]);
|
||||
|
||||
$this->info('Lemon Squeezy webhook registered.');
|
||||
|
||||
if ($id) {
|
||||
$this->line('ID: '.$id);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function defaultWebhookUrl(): string
|
||||
{
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
|
||||
return $base !== '' ? $base.'/lemonsqueezy/webhook' : '';
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\PullPackageFromPaddle;
|
||||
use App\Jobs\SyncPackageToPaddle;
|
||||
use App\Jobs\PullPackageFromLemonSqueezy;
|
||||
use App\Jobs\SyncPackageToLemonSqueezy;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleSyncPackages extends Command
|
||||
class LemonSqueezySyncPackages extends Command
|
||||
{
|
||||
protected $signature = 'paddle:sync-packages
|
||||
protected $signature = 'lemonsqueezy:sync-packages
|
||||
{--package=* : Limit sync to the given package IDs or slugs}
|
||||
{--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}
|
||||
{--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}
|
||||
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
|
||||
|
||||
protected $description = 'Synchronise local packages with Paddle products and prices.';
|
||||
protected $description = 'Synchronise local packages with Lemon Squeezy products and variants.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -52,7 +52,7 @@ class PaddleSyncPackages extends Command
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Queued %d package %s for Paddle %s.',
|
||||
'Queued %d package %s for Lemon Squeezy %s.',
|
||||
$packages->count(),
|
||||
Str::plural('entry', $packages->count()),
|
||||
$pull ? 'pull' : 'sync'
|
||||
@@ -97,22 +97,22 @@ class PaddleSyncPackages extends Command
|
||||
|
||||
protected function guardUnmappedPackages(Collection $packages): bool
|
||||
{
|
||||
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id));
|
||||
$unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id));
|
||||
|
||||
if ($unmapped->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.');
|
||||
$this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.');
|
||||
$this->table(
|
||||
['ID', 'Slug', 'Missing'],
|
||||
$unmapped->map(function (Package $package): array {
|
||||
$missing = [];
|
||||
if (blank($package->paddle_product_id)) {
|
||||
if (blank($package->lemonsqueezy_product_id)) {
|
||||
$missing[] = 'product_id';
|
||||
}
|
||||
if (blank($package->paddle_price_id)) {
|
||||
$missing[] = 'price_id';
|
||||
if (blank($package->lemonsqueezy_variant_id)) {
|
||||
$missing[] = 'variant_id';
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -133,26 +133,26 @@ class PaddleSyncPackages extends Command
|
||||
];
|
||||
|
||||
if ($queue) {
|
||||
SyncPackageToPaddle::dispatch($package->id, $context);
|
||||
SyncPackageToLemonSqueezy::dispatch($package->id, $context);
|
||||
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPackageToPaddle::dispatchSync($package->id, $context);
|
||||
SyncPackageToLemonSqueezy::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) {
|
||||
PullPackageFromPaddle::dispatch($package->id);
|
||||
PullPackageFromLemonSqueezy::dispatch($package->id);
|
||||
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
PullPackageFromPaddle::dispatchSync($package->id);
|
||||
PullPackageFromLemonSqueezy::dispatchSync($package->id);
|
||||
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ class MonitorStorageCommand extends Command
|
||||
|
||||
$assetStats = $this->buildAssetStatistics();
|
||||
$thresholds = $this->capacityThresholds();
|
||||
$checksumConfig = $this->checksumAlertConfig();
|
||||
$checksumWindowMinutes = $checksumConfig['window_minutes'];
|
||||
$checksumThresholds = $checksumConfig['thresholds'];
|
||||
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
|
||||
? $this->checksumMismatchCounts($checksumWindowMinutes)
|
||||
: [];
|
||||
$alerts = [];
|
||||
$snapshotTargets = [];
|
||||
|
||||
@@ -78,6 +84,7 @@ class MonitorStorageCommand extends Command
|
||||
];
|
||||
}
|
||||
|
||||
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
|
||||
$snapshotTargets[] = [
|
||||
'id' => $target->id,
|
||||
'key' => $target->key,
|
||||
@@ -85,13 +92,35 @@ class MonitorStorageCommand extends Command
|
||||
'is_hot' => (bool) $target->is_hot,
|
||||
'capacity' => $capacity,
|
||||
'assets' => $assets,
|
||||
'checksum_mismatches' => [
|
||||
'count' => $targetChecksumMismatches,
|
||||
'window_minutes' => $checksumWindowMinutes,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($checksumConfig['enabled'] && $checksumWindowMinutes > 0) {
|
||||
$totalMismatches = array_sum($checksumMismatches);
|
||||
$checksumSeverity = $this->determineChecksumSeverity($totalMismatches, $checksumThresholds);
|
||||
|
||||
if ($checksumSeverity !== 'ok') {
|
||||
$alerts[] = [
|
||||
'type' => 'checksum_mismatch',
|
||||
'severity' => $checksumSeverity,
|
||||
'count' => $totalMismatches,
|
||||
'window_minutes' => $checksumWindowMinutes,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$snapshot = [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'targets' => $snapshotTargets,
|
||||
'alerts' => $alerts,
|
||||
'checksum' => [
|
||||
'window_minutes' => $checksumWindowMinutes,
|
||||
'mismatch_total' => array_sum($checksumMismatches),
|
||||
],
|
||||
];
|
||||
|
||||
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
|
||||
@@ -191,4 +220,62 @@ class MonitorStorageCommand extends Command
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
private function checksumAlertConfig(): array
|
||||
{
|
||||
$enabled = (bool) config('storage-monitor.checksum_validation.enabled', true);
|
||||
$windowMinutes = max(0, (int) config('storage-monitor.checksum_validation.alert_window_minutes', 60));
|
||||
$warning = (int) config('storage-monitor.checksum_validation.thresholds.warning', 1);
|
||||
$critical = (int) config('storage-monitor.checksum_validation.thresholds.critical', 5);
|
||||
|
||||
if ($warning > $critical && $critical > 0) {
|
||||
[$warning, $critical] = [$critical, $warning];
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'window_minutes' => $windowMinutes,
|
||||
'thresholds' => [
|
||||
'warning' => $warning,
|
||||
'critical' => $critical,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function checksumMismatchCounts(int $windowMinutes): array
|
||||
{
|
||||
$query = EventMediaAsset::query()
|
||||
->selectRaw('media_storage_target_id, COUNT(*) as total_count')
|
||||
->where('status', 'failed')
|
||||
->where('meta->checksum_status', 'mismatch');
|
||||
|
||||
if ($windowMinutes > 0) {
|
||||
$query->where('updated_at', '>=', now()->subMinutes($windowMinutes));
|
||||
}
|
||||
|
||||
return $query->groupBy('media_storage_target_id')
|
||||
->get()
|
||||
->mapWithKeys(fn ($row) => [(int) $row->media_storage_target_id => (int) $row->total_count])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function determineChecksumSeverity(int $count, array $thresholds): string
|
||||
{
|
||||
$warning = (int) ($thresholds['warning'] ?? 1);
|
||||
$critical = (int) ($thresholds['critical'] ?? 5);
|
||||
|
||||
if ($count <= 0) {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
if ($critical > 0 && $count >= $critical) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ($warning > 0 && $count >= $warning) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaddleRegisterWebhooks extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'paddle:webhooks:register
|
||||
{--url= : Destination URL for Paddle webhooks}
|
||||
{--description= : Description for the webhook destination}
|
||||
{--events=* : Override event types to subscribe}
|
||||
{--traffic-source=all : platform|simulation|all}
|
||||
{--include-sensitive : Include sensitive fields in webhook payloads}
|
||||
{--show-secret : Output the endpoint secret key}
|
||||
{--dry-run : Output payload without creating the destination}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Register Paddle webhook notification settings.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(PaddleClient $client): int
|
||||
{
|
||||
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
|
||||
|
||||
if ($destination === '') {
|
||||
$this->error('Webhook destination URL is required. Use --url=...');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$events = collect((array) $this->option('events'))
|
||||
->filter()
|
||||
->map(fn ($event) => trim((string) $event))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($events === []) {
|
||||
$events = config('paddle.webhook_events', []);
|
||||
}
|
||||
|
||||
if ($events === [] || ! is_array($events)) {
|
||||
$this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$trafficSource = (string) $this->option('traffic-source');
|
||||
$allowedSources = ['platform', 'simulation', 'all'];
|
||||
|
||||
if (! in_array($trafficSource, $allowedSources, true)) {
|
||||
$this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources)));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'type' => 'url',
|
||||
'destination' => $destination,
|
||||
'description' => $this->resolveDescription(),
|
||||
'subscribed_events' => $events,
|
||||
'traffic_source' => $trafficSource,
|
||||
'include_sensitive_fields' => (bool) $this->option('include-sensitive'),
|
||||
];
|
||||
|
||||
if ((bool) $this->option('dry-run')) {
|
||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$response = $client->post('/notification-settings', $payload);
|
||||
$data = Arr::get($response, 'data', $response);
|
||||
$id = Arr::get($data, 'id');
|
||||
$secret = Arr::get($data, 'endpoint_secret_key');
|
||||
|
||||
Log::channel('paddle-sync')->info('Paddle webhook registered', [
|
||||
'notification_setting_id' => $id,
|
||||
'destination' => $destination,
|
||||
'traffic_source' => $trafficSource,
|
||||
]);
|
||||
|
||||
$this->info('Paddle webhook registered.');
|
||||
|
||||
if ($id) {
|
||||
$this->line('ID: '.$id);
|
||||
}
|
||||
|
||||
if ($secret && $this->option('show-secret')) {
|
||||
$this->line('Secret: '.$secret);
|
||||
} elseif ($secret) {
|
||||
$this->line('Secret returned (hidden). Use --show-secret to display.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function defaultWebhookUrl(): string
|
||||
{
|
||||
$base = rtrim((string) config('app.url'), '/');
|
||||
|
||||
return $base !== '' ? $base.'/paddle/webhook' : '';
|
||||
}
|
||||
|
||||
protected function resolveDescription(): string
|
||||
{
|
||||
$description = (string) $this->option('description');
|
||||
|
||||
if ($description !== '') {
|
||||
return $description;
|
||||
}
|
||||
|
||||
$environment = (string) config('paddle.environment', 'production');
|
||||
|
||||
return sprintf('Fotospiel Paddle webhooks (%s)', $environment);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class SyncGoogleFonts extends Command
|
||||
{
|
||||
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files}';
|
||||
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files} {--from-disk : Rebuild manifest + CSS from existing font files without downloading}';
|
||||
|
||||
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
||||
|
||||
@@ -20,6 +20,17 @@ class SyncGoogleFonts extends Command
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$fromDisk = (bool) $this->option('from-disk');
|
||||
$pathOption = $this->option('path');
|
||||
$basePath = $pathOption
|
||||
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
||||
: public_path('fonts/google');
|
||||
|
||||
if ($fromDisk) {
|
||||
return $this->syncFromDisk($basePath, $dryRun);
|
||||
}
|
||||
|
||||
$apiKey = config('services.google_fonts.key');
|
||||
|
||||
if (! $apiKey) {
|
||||
@@ -32,16 +43,10 @@ class SyncGoogleFonts extends Command
|
||||
$weights = $this->prepareWeights($this->option('weights'));
|
||||
$includeItalic = (bool) $this->option('italic');
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$families = $this->normalizeFamilyOption($this->option('family'));
|
||||
$categories = $this->prepareCategories($this->option('category'));
|
||||
$prune = (bool) $this->option('prune');
|
||||
|
||||
$pathOption = $this->option('path');
|
||||
$basePath = $pathOption
|
||||
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
||||
: public_path('fonts/google');
|
||||
|
||||
if (count($families)) {
|
||||
$label = count($families) > 1 ? 'families' : 'family';
|
||||
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
||||
@@ -206,6 +211,204 @@ class SyncGoogleFonts extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function syncFromDisk(string $basePath, bool $dryRun): int
|
||||
{
|
||||
if (! File::isDirectory($basePath)) {
|
||||
$this->error(sprintf('Font directory not found: %s', $basePath));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('prune')) {
|
||||
$this->warn('Ignoring --prune when rebuilding from disk.');
|
||||
}
|
||||
|
||||
$fonts = $this->buildManifestFromDisk($basePath);
|
||||
|
||||
if (! count($fonts)) {
|
||||
$this->warn('No fonts found on disk.');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info(sprintf('Dry run complete: %d font families would be written to %s', count($fonts), $basePath));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->writeManifest($basePath, $fonts);
|
||||
$this->writeCss($basePath, $fonts);
|
||||
Cache::forget('fonts:manifest');
|
||||
|
||||
$this->info(sprintf('Rebuilt manifest for %d font families from %s', count($fonts), $basePath));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildManifestFromDisk(string $basePath): array
|
||||
{
|
||||
$directories = File::directories($basePath);
|
||||
$fonts = [];
|
||||
|
||||
foreach ($directories as $dir) {
|
||||
$slug = basename($dir);
|
||||
$files = collect(File::files($dir))
|
||||
->filter(function (\SplFileInfo $file) {
|
||||
$extension = strtolower($file->getExtension());
|
||||
|
||||
return in_array($extension, ['woff2', 'woff', 'otf', 'ttf'], true);
|
||||
})
|
||||
->values();
|
||||
|
||||
if (! $files->count()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$variantsByKey = [];
|
||||
foreach ($files as $file) {
|
||||
$filename = $file->getFilename();
|
||||
$extension = strtolower($file->getExtension());
|
||||
$style = $this->extractStyleFromFilename($filename);
|
||||
$weight = $this->extractWeightFromFilename($filename);
|
||||
$variantKey = $this->buildVariantKey($weight, $style);
|
||||
$priority = $this->extensionPriority($extension);
|
||||
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
|
||||
|
||||
$existing = $variantsByKey[$variantKey] ?? null;
|
||||
if ($existing && ($existing['priority'] ?? 0) >= $priority) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$variantsByKey[$variantKey] = [
|
||||
'variant' => $variantKey,
|
||||
'weight' => $weight,
|
||||
'style' => $style,
|
||||
'url' => $relativePath,
|
||||
'priority' => $priority,
|
||||
];
|
||||
}
|
||||
|
||||
if (! count($variantsByKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$variants = array_values(array_map(function (array $variant) {
|
||||
unset($variant['priority']);
|
||||
|
||||
return $variant;
|
||||
}, $variantsByKey));
|
||||
|
||||
usort($variants, function (array $left, array $right) {
|
||||
$weightCompare = ($left['weight'] ?? 400) <=> ($right['weight'] ?? 400);
|
||||
if ($weightCompare !== 0) {
|
||||
return $weightCompare;
|
||||
}
|
||||
|
||||
return strcmp((string) ($left['style'] ?? 'normal'), (string) ($right['style'] ?? 'normal'));
|
||||
});
|
||||
|
||||
$fonts[] = [
|
||||
'family' => $this->familyFromSlug($slug),
|
||||
'slug' => $slug,
|
||||
'category' => null,
|
||||
'variants' => $variants,
|
||||
];
|
||||
}
|
||||
|
||||
usort($fonts, fn (array $left, array $right) => strcmp((string) $left['family'], (string) $right['family']));
|
||||
|
||||
return $fonts;
|
||||
}
|
||||
|
||||
private function familyFromSlug(string $slug): string
|
||||
{
|
||||
$parts = array_filter(explode('-', $slug), fn ($part) => $part !== '');
|
||||
|
||||
$words = array_map(function (string $part) {
|
||||
if (is_numeric($part)) {
|
||||
return $part;
|
||||
}
|
||||
|
||||
if (strlen($part) <= 3) {
|
||||
return strtoupper($part);
|
||||
}
|
||||
|
||||
return ucfirst(strtolower($part));
|
||||
}, $parts);
|
||||
|
||||
return trim(implode(' ', $words));
|
||||
}
|
||||
|
||||
private function extractStyleFromFilename(string $filename): string
|
||||
{
|
||||
$lower = strtolower($filename);
|
||||
|
||||
return str_contains($lower, 'italic') || str_contains($lower, 'oblique') ? 'italic' : 'normal';
|
||||
}
|
||||
|
||||
private function extractWeightFromFilename(string $filename): int
|
||||
{
|
||||
if (preg_match('/(?:^|[^0-9])(100|200|300|400|500|600|700|800|900)(?:[^0-9]|$)/', $filename, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
$lower = strtolower($filename);
|
||||
$weightMap = [
|
||||
'thin' => 100,
|
||||
'extralight' => 200,
|
||||
'ultralight' => 200,
|
||||
'light' => 300,
|
||||
'regular' => 400,
|
||||
'book' => 400,
|
||||
'medium' => 500,
|
||||
'semibold' => 600,
|
||||
'demibold' => 600,
|
||||
'bold' => 700,
|
||||
'extrabold' => 800,
|
||||
'ultrabold' => 800,
|
||||
'black' => 900,
|
||||
'heavy' => 900,
|
||||
];
|
||||
|
||||
foreach ($weightMap as $label => $weight) {
|
||||
if (str_contains($lower, $label)) {
|
||||
return $weight;
|
||||
}
|
||||
}
|
||||
|
||||
return 400;
|
||||
}
|
||||
|
||||
private function buildVariantKey(int $weight, string $style): string
|
||||
{
|
||||
if ($weight === 400 && $style === 'normal') {
|
||||
return 'regular';
|
||||
}
|
||||
|
||||
if ($weight === 400 && $style === 'italic') {
|
||||
return 'italic';
|
||||
}
|
||||
|
||||
if ($style === 'italic') {
|
||||
return $weight.'italic';
|
||||
}
|
||||
|
||||
return (string) $weight;
|
||||
}
|
||||
|
||||
private function extensionPriority(string $extension): int
|
||||
{
|
||||
return match ($extension) {
|
||||
'woff2' => 4,
|
||||
'woff' => 3,
|
||||
'otf' => 2,
|
||||
'ttf' => 1,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
@@ -79,9 +79,10 @@ class PostResource extends Resource
|
||||
->label('Inhalt')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
TextInput::make('excerpt.de')
|
||||
Textarea::make('excerpt.de')
|
||||
->label('Auszug')
|
||||
->maxLength(255),
|
||||
->maxLength(65535)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('meta_title.de')
|
||||
->label('Meta-Titel')
|
||||
->maxLength(255),
|
||||
@@ -99,9 +100,10 @@ class PostResource extends Resource
|
||||
MarkdownEditor::make('content.en')
|
||||
->label('Inhalt')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('excerpt.en')
|
||||
Textarea::make('excerpt.en')
|
||||
->label('Auszug')
|
||||
->maxLength(255),
|
||||
->maxLength(65535)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('meta_title.en')
|
||||
->label('Meta-Titel')
|
||||
->maxLength(255),
|
||||
@@ -121,9 +123,10 @@ class PostResource extends Resource
|
||||
->unique(BlogPost::class, 'slug', ignoreRecord: true)
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
FileUpload::make('featured_image')
|
||||
FileUpload::make('banner')
|
||||
->label('Featured Image')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('blog')
|
||||
->visibility('public'),
|
||||
Select::make('blog_category_id')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\TenantLemonSqueezyHealthResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantLemonSqueezyHealths extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantLemonSqueezyHealthResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Tables;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\TenantLemonSqueezyHealthResource;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@@ -15,7 +15,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantPaddleHealthTable
|
||||
class TenantLemonSqueezyHealthTable
|
||||
{
|
||||
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
|
||||
|
||||
@@ -35,8 +35,8 @@ class TenantPaddleHealthTable
|
||||
->label(__('admin.tenants.fields.contact_email'))
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_customer_id')
|
||||
->label('Paddle customer')
|
||||
TextColumn::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy customer')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->copyable()
|
||||
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||
@@ -56,27 +56,27 @@ class TenantPaddleHealthTable
|
||||
->badge()
|
||||
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_subscription_id')
|
||||
->label('Paddle subscription')
|
||||
TextColumn::make('lemonsqueezy_subscription_id')
|
||||
->label('Lemon Squeezy subscription')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->copyable()
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id)
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->lemonsqueezy_subscription_id)
|
||||
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||
IconColumn::make('missing_paddle_subscription')
|
||||
->label('Missing Paddle subscription')
|
||||
IconColumn::make('missing_lemonsqueezy_subscription')
|
||||
->label('Missing Lemon Squeezy subscription')
|
||||
->boolean()
|
||||
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)),
|
||||
->getStateUsing(fn (Tenant $record) => self::missingLemonSqueezySubscription($record)),
|
||||
IconColumn::make('status_mismatch')
|
||||
->label('Status mismatch')
|
||||
->boolean()
|
||||
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
||||
TextColumn::make('paddle_customer_duplicates')
|
||||
->label('Paddle duplicates')
|
||||
TextColumn::make('lemonsqueezy_customer_duplicates')
|
||||
->label('Lemon Squeezy duplicates')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
|
||||
TextColumn::make('paddle_sync_status')
|
||||
->label('Paddle sync')
|
||||
TextColumn::make('lemonsqueezy_sync_status')
|
||||
->label('Lemon Squeezy sync')
|
||||
->badge()
|
||||
->color(fn (?string $state) => match ($state) {
|
||||
'synced' => 'success',
|
||||
@@ -87,101 +87,101 @@ class TenantPaddleHealthTable
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status)
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_sync_status)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_synced_at')
|
||||
->label('Paddle synced')
|
||||
TextColumn::make('lemonsqueezy_synced_at')
|
||||
->label('Lemon Squeezy 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')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_synced_at),
|
||||
TextColumn::make('last_lemonsqueezy_transaction_at')
|
||||
->label('Last Lemon Squeezy tx')
|
||||
->badge()
|
||||
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
||||
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
|
||||
? Carbon::parse($record->last_paddle_transaction_at)
|
||||
->getStateUsing(fn (Tenant $record) => $record->last_lemonsqueezy_transaction_at
|
||||
? Carbon::parse($record->last_lemonsqueezy_transaction_at)
|
||||
: null)
|
||||
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_transaction_count_window')
|
||||
->label('Paddle tx (30d)')
|
||||
TextColumn::make('lemonsqueezy_transaction_count_window')
|
||||
->label('Lemon Squeezy tx (30d)')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('paddle_transaction_total_window')
|
||||
->label('Paddle total (30d)')
|
||||
TextColumn::make('lemonsqueezy_transaction_total_window')
|
||||
->label('Lemon Squeezy total (30d)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('paddle_refund_count_window')
|
||||
TextColumn::make('lemonsqueezy_refund_count_window')
|
||||
->label('Refunds (30d)')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_refund_total_window')
|
||||
TextColumn::make('lemonsqueezy_refund_total_window')
|
||||
->label('Refund total (30d)')
|
||||
->default(0)
|
||||
->money('EUR')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_checkout_requires_action_count')
|
||||
TextColumn::make('lemonsqueezy_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('paddle_checkout_processing_count')
|
||||
TextColumn::make('lemonsqueezy_checkout_processing_count')
|
||||
->label('Checkout processing')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_checkout_expired_count')
|
||||
TextColumn::make('lemonsqueezy_checkout_expired_count')
|
||||
->label('Checkout expired')
|
||||
->badge()
|
||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_transaction_count')
|
||||
->label('Paddle tx (all)')
|
||||
TextColumn::make('lemonsqueezy_transaction_count')
|
||||
->label('Lemon Squeezy tx (all)')
|
||||
->default('0')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_transaction_total')
|
||||
->label('Paddle total (all)')
|
||||
TextColumn::make('lemonsqueezy_transaction_total')
|
||||
->label('Lemon Squeezy 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')
|
||||
Filter::make('missing_lemonsqueezy_customer')
|
||||
->label('Missing Lemon Squeezy customer')
|
||||
->indicator('Missing Lemon Squeezy customer')
|
||||
->query(fn (Builder $query) => $query->whereNull('lemonsqueezy_customer_id')),
|
||||
Filter::make('missing_lemonsqueezy_subscription')
|
||||
->label('Missing Lemon Squeezy subscription')
|
||||
->indicator('Missing Lemon Squeezy 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')
|
||||
->whereNull('lemonsqueezy_subscription_id'))),
|
||||
Filter::make('duplicate_lemonsqueezy_customer')
|
||||
->label('Duplicate Lemon Squeezy customer')
|
||||
->indicator('Duplicate Lemon Squeezy customer')
|
||||
->query(fn (Builder $query) => $query
|
||||
->whereNotNull('paddle_customer_id')
|
||||
->whereIn('paddle_customer_id', function ($subquery) {
|
||||
$subquery->select('paddle_customer_id')
|
||||
->whereNotNull('lemonsqueezy_customer_id')
|
||||
->whereIn('lemonsqueezy_customer_id', function ($subquery) {
|
||||
$subquery->select('lemonsqueezy_customer_id')
|
||||
->from('tenants')
|
||||
->whereNotNull('paddle_customer_id')
|
||||
->groupBy('paddle_customer_id')
|
||||
->whereNotNull('lemonsqueezy_customer_id')
|
||||
->groupBy('lemonsqueezy_customer_id')
|
||||
->havingRaw('count(*) > 1');
|
||||
})),
|
||||
Filter::make('status_mismatch')
|
||||
@@ -205,39 +205,39 @@ class TenantPaddleHealthTable
|
||||
->where('is_suspended', false)
|
||||
->whereNull('pending_deletion_at')
|
||||
->whereNull('anonymized_at')),
|
||||
Filter::make('paddle_sync_failed')
|
||||
->label('Paddle sync failed')
|
||||
->indicator('Paddle sync failed')
|
||||
Filter::make('lemonsqueezy_sync_failed')
|
||||
->label('Lemon Squeezy sync failed')
|
||||
->indicator('Lemon Squeezy 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')
|
||||
->whereIn('lemonsqueezy_sync_status', self::FAILED_SYNC_STATUSES))),
|
||||
Filter::make('lemonsqueezy_sync_stale')
|
||||
->label('Lemon Squeezy sync stale')
|
||||
->indicator('Lemon Squeezy 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')
|
||||
->whereNotNull('lemonsqueezy_synced_at')
|
||||
->where('lemonsqueezy_synced_at', '<', now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS)))),
|
||||
Filter::make('lemonsqueezy_sync_missing')
|
||||
->label('Missing Lemon Squeezy sync timestamp')
|
||||
->indicator('Missing Lemon Squeezy 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')
|
||||
->whereNull('lemonsqueezy_synced_at'))),
|
||||
Filter::make('lemonsqueezy_transaction_stale')
|
||||
->label('Stale Lemon Squeezy transactions')
|
||||
->indicator('Stale Lemon Squeezy transactions')
|
||||
->query(function (Builder $query): Builder {
|
||||
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
$cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return $query
|
||||
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
|
||||
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'lemonsqueezy'))
|
||||
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->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', 'paddle')
|
||||
$query->where('provider', 'lemonsqueezy')
|
||||
->where(function (Builder $query) {
|
||||
$query->whereIn('status', [
|
||||
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
||||
@@ -274,10 +274,10 @@ class TenantPaddleHealthTable
|
||||
return $query;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
$cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||
|
||||
return $query->whereHas('purchases', fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
||||
}),
|
||||
@@ -314,11 +314,11 @@ class TenantPaddleHealthTable
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function missingPaddleSubscription(Tenant $record): bool
|
||||
private static function missingLemonSqueezySubscription(Tenant $record): bool
|
||||
{
|
||||
$package = $record->activeResellerPackage;
|
||||
|
||||
return $package && $package->active && ! $package->paddle_subscription_id;
|
||||
return $package && $package->active && ! $package->lemonsqueezy_subscription_id;
|
||||
}
|
||||
|
||||
private static function applyStatusMismatchFilter(Builder $query): Builder
|
||||
@@ -344,7 +344,7 @@ class TenantPaddleHealthTable
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
|
||||
if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS))) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ class TenantPaddleHealthTable
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||
if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Pages\ListTenantLemonSqueezyHealths;
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Tables\TenantLemonSqueezyHealthTable;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use BackedEnum;
|
||||
@@ -13,7 +13,7 @@ use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantPaddleHealthResource extends Resource
|
||||
class TenantLemonSqueezyHealthResource extends Resource
|
||||
{
|
||||
public const STALE_SYNC_DAYS = 30;
|
||||
|
||||
@@ -25,13 +25,13 @@ class TenantPaddleHealthResource extends Resource
|
||||
|
||||
protected static ?string $cluster = DailyOpsCluster::class;
|
||||
|
||||
protected static ?string $slug = 'paddle-health';
|
||||
protected static ?string $slug = 'lemonsqueezy-health';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return TenantPaddleHealthTable::configure($table);
|
||||
return TenantLemonSqueezyHealthTable::configure($table);
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
@@ -41,7 +41,7 @@ class TenantPaddleHealthResource extends Resource
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.paddle_health.navigation.label');
|
||||
return __('admin.lemonsqueezy_health.navigation.label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
@@ -57,31 +57,31 @@ class TenantPaddleHealthResource extends Resource
|
||||
->with(['activeResellerPackage.package'])
|
||||
->withExists('activeResellerPackage as has_active_reseller_package')
|
||||
->addSelect([
|
||||
'paddle_customer_duplicates' => Tenant::query()
|
||||
'lemonsqueezy_customer_duplicates' => Tenant::query()
|
||||
->selectRaw('count(*)')
|
||||
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id')
|
||||
->whereNotNull('paddle_customer_id'),
|
||||
->whereColumn('lemonsqueezy_customer_id', 'tenants.lemonsqueezy_customer_id')
|
||||
->whereNotNull('lemonsqueezy_customer_id'),
|
||||
])
|
||||
->withCount([
|
||||
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
'purchases as lemonsqueezy_transaction_count' => fn (Builder $query) => $query
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', false),
|
||||
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
'purchases as lemonsqueezy_transaction_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', false)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
'purchases as lemonsqueezy_refund_count_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
'checkoutSessions as lemonsqueezy_checkout_requires_action_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
|
||||
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
||||
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
'checkoutSessions as lemonsqueezy_checkout_processing_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
|
||||
->where('status', CheckoutSession::STATUS_PROCESSING),
|
||||
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||
'checkoutSessions as lemonsqueezy_checkout_expired_count' => fn (Builder $query) => $query
|
||||
->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
|
||||
->whereNotIn('status', [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
@@ -90,32 +90,32 @@ class TenantPaddleHealthResource extends Resource
|
||||
->where('expires_at', '<', now()),
|
||||
])
|
||||
->withSum([
|
||||
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
'purchases as lemonsqueezy_transaction_total' => fn (Builder $query) => $query
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', false),
|
||||
], 'price')
|
||||
->withSum([
|
||||
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
'purchases as lemonsqueezy_transaction_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', false)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
], 'price')
|
||||
->withSum([
|
||||
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle')
|
||||
'purchases as lemonsqueezy_refund_total_window' => fn (Builder $query) => $query
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', true)
|
||||
->where('purchased_at', '>=', $windowStart),
|
||||
], 'price')
|
||||
->withMax([
|
||||
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
|
||||
->where('provider', 'paddle'),
|
||||
'purchases as last_lemonsqueezy_transaction_at' => fn (Builder $query) => $query
|
||||
->where('provider', 'lemonsqueezy'),
|
||||
], 'purchased_at');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListTenantPaddleHealths::route('/'),
|
||||
'index' => ListTenantLemonSqueezyHealths::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages;
|
||||
|
||||
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTenantPaddleHealths extends ListRecords
|
||||
{
|
||||
protected static string $resource = TenantPaddleHealthResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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\SyncCouponToPaddle;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
|
||||
class CreateCoupon extends AuditedCreateRecord
|
||||
{
|
||||
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
|
||||
{
|
||||
parent::afterCreate();
|
||||
|
||||
SyncCouponToPaddle::dispatch($this->record);
|
||||
SyncCouponToLemonSqueezy::dispatch($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
|
||||
source: static::class
|
||||
);
|
||||
|
||||
SyncCouponToPaddle::dispatch($record, true);
|
||||
SyncCouponToLemonSqueezy::dispatch($record, true);
|
||||
}),
|
||||
ForceDeleteAction::make()
|
||||
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
|
||||
{
|
||||
parent::afterSave();
|
||||
|
||||
SyncCouponToPaddle::dispatch($this->record);
|
||||
SyncCouponToLemonSqueezy::dispatch($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class RedemptionsRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('paddle_transaction_id')
|
||||
->recordTitleAttribute('lemonsqueezy_order_id')
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('Tenant'))
|
||||
@@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
|
||||
'failed' => 'danger',
|
||||
default => 'warning',
|
||||
}),
|
||||
TextColumn::make('paddle_transaction_id')
|
||||
TextColumn::make('lemonsqueezy_order_id')
|
||||
->label(__('Transaction'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
@@ -123,22 +123,22 @@ class CouponForm
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make(__('Paddle sync'))
|
||||
Section::make(__('Lemon Squeezy sync'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('paddle_mode')
|
||||
->label(__('Paddle mode'))
|
||||
Select::make('lemonsqueezy_mode')
|
||||
->label(__('Lemon Squeezy mode'))
|
||||
->options([
|
||||
'standard' => __('Standard'),
|
||||
'custom' => __('Custom (one-off)'),
|
||||
])
|
||||
->default('standard'),
|
||||
Placeholder::make('paddle_discount_id')
|
||||
->label(__('Paddle Discount ID'))
|
||||
->content(fn ($record) => $record?->paddle_discount_id ?? '—'),
|
||||
Placeholder::make('paddle_last_synced_at')
|
||||
Placeholder::make('lemonsqueezy_discount_id')
|
||||
->label(__('Lemon Squeezy Discount ID'))
|
||||
->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'),
|
||||
Placeholder::make('lemonsqueezy_last_synced_at')
|
||||
->label(__('Last synced'))
|
||||
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||
->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
||||
Placeholder::make('redemptions_count')
|
||||
->label(__('Total redemptions'))
|
||||
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
|
||||
|
||||
@@ -63,17 +63,17 @@ class CouponInfolist
|
||||
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
|
||||
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
|
||||
]),
|
||||
Section::make(__('Paddle'))
|
||||
Section::make(__('Lemon Squeezy'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('paddle_discount_id')
|
||||
TextEntry::make('lemonsqueezy_discount_id')
|
||||
->label(__('Discount ID'))
|
||||
->copyable()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('paddle_last_synced_at')
|
||||
TextEntry::make('lemonsqueezy_last_synced_at')
|
||||
->label(__('Last synced'))
|
||||
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||
TextEntry::make('paddle_mode')
|
||||
->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
||||
TextEntry::make('lemonsqueezy_mode')
|
||||
->label(__('Mode'))
|
||||
->badge()
|
||||
->placeholder('standard'),
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
||||
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 Paddle'))
|
||||
->label(__('Sync to Lemon Squeezy'))
|
||||
->icon('heroicon-m-arrow-path')
|
||||
->action(fn ($record) => SyncCouponToPaddle::dispatch($record))
|
||||
->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record))
|
||||
->requiresConfirmation(),
|
||||
])
|
||||
->toolbarActions([
|
||||
|
||||
@@ -109,8 +109,9 @@ class EventResource extends Resource
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
|
||||
Tables\Columns\TextColumn::make('name.de')
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label(__('admin.events.fields.name'))
|
||||
->formatStateUsing(fn (mixed $state): string => static::formatEventName($state))
|
||||
->limit(30),
|
||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||
Tables\Columns\TextColumn::make('date')->date(),
|
||||
@@ -278,6 +279,30 @@ class EventResource extends Resource
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $name
|
||||
*/
|
||||
private static function formatEventName(mixed $name): string
|
||||
{
|
||||
if (is_array($name)) {
|
||||
$candidates = [
|
||||
$name['de'] ?? null,
|
||||
$name['en'] ?? null,
|
||||
reset($name) ?: null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
return is_string($name) ? $name : '';
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource
|
||||
->label('Empfänger')
|
||||
->toggleable()
|
||||
->searchable(),
|
||||
TextColumn::make('paddle_transaction_id')
|
||||
->label('Paddle Tx')
|
||||
TextColumn::make('lemonsqueezy_order_id')
|
||||
->label('Lemon Squeezy Order')
|
||||
->toggleable()
|
||||
->copyable()
|
||||
->wrap(),
|
||||
|
||||
@@ -46,24 +46,27 @@ class ListGiftVouchers extends ListRecords
|
||||
])
|
||||
->action(function (array $data, GiftVoucherService $service): void {
|
||||
$payload = [
|
||||
'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,
|
||||
'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,
|
||||
],
|
||||
],
|
||||
'currency_code' => $data['currency'] ?? 'EUR',
|
||||
'totals' => [
|
||||
'grand_total' => [
|
||||
'amount' => (float) $data['amount'],
|
||||
'data' => [
|
||||
'id' => 'manual_'.Str::uuid(),
|
||||
'attributes' => [
|
||||
'currency' => $data['currency'] ?? 'EUR',
|
||||
'total' => (float) $data['amount'] * 100,
|
||||
'user_email' => $data['purchaser_email'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$voucher = $service->issueFromPaddle($payload);
|
||||
$voucher = $service->issueFromLemonSqueezy($payload);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'issued',
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||
use App\Filament\Resources\PackageAddonResource\Pages;
|
||||
use App\Jobs\SyncPackageAddonToPaddle;
|
||||
use App\Jobs\SyncPackageAddonToLemonSqueezy;
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions;
|
||||
@@ -50,9 +50,9 @@ class PackageAddonResource extends Resource
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(191),
|
||||
TextInput::make('price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
->helperText('Paddle Billing Preis-ID für dieses Add-on')
|
||||
TextInput::make('variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
|
||||
->maxLength(191),
|
||||
TextInput::make('sort')
|
||||
->label('Sortierung')
|
||||
@@ -96,8 +96,8 @@ class PackageAddonResource extends Resource
|
||||
->label('Schlüssel')
|
||||
->copyable()
|
||||
->sortable(),
|
||||
TextColumn::make('price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
TextColumn::make('variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
->toggleable()
|
||||
->copyable(),
|
||||
TextColumn::make('extra_photos')->label('Fotos +'),
|
||||
@@ -120,16 +120,16 @@ class PackageAddonResource extends Resource
|
||||
->label('Aktiv'),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('syncPaddle')
|
||||
->label('Mit Paddle synchronisieren')
|
||||
Actions\Action::make('syncLemonSqueezy')
|
||||
->label('Mit Lemon Squeezy synchronisieren')
|
||||
->icon('heroicon-o-cloud-arrow-up')
|
||||
->action(function (PackageAddon $record) {
|
||||
SyncPackageAddonToPaddle::dispatch($record->id);
|
||||
SyncPackageAddonToLemonSqueezy::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Sync gestartet')
|
||||
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
|
||||
->title('Lemon Squeezy-Sync gestartet')
|
||||
->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make()
|
||||
|
||||
@@ -4,8 +4,8 @@ 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\Jobs\PullPackageFromLemonSqueezy;
|
||||
use App\Jobs\SyncPackageToLemonSqueezy;
|
||||
use App\Models\Package;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use BackedEnum;
|
||||
@@ -172,31 +172,31 @@ class PackageResource extends Resource
|
||||
->columnSpanFull()
|
||||
->default([]),
|
||||
]),
|
||||
Section::make('Paddle Billing')
|
||||
Section::make('Lemon Squeezy Billing')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('paddle_product_id')
|
||||
->label('Paddle Produkt-ID')
|
||||
TextInput::make('lemonsqueezy_product_id')
|
||||
->label('Lemon Squeezy Produkt-ID')
|
||||
->maxLength(191)
|
||||
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
|
||||
->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
|
||||
->placeholder('nicht verknüpft'),
|
||||
TextInput::make('paddle_price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
TextInput::make('lemonsqueezy_variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
->maxLength(191)
|
||||
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
|
||||
->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
|
||||
->placeholder('nicht verknüpft'),
|
||||
Placeholder::make('paddle_sync_status')
|
||||
Placeholder::make('lemonsqueezy_sync_status')
|
||||
->label('Sync-Status')
|
||||
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–')
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '–')
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('paddle_synced_at')
|
||||
Placeholder::make('lemonsqueezy_synced_at')
|
||||
->label('Zuletzt synchronisiert')
|
||||
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '–')
|
||||
->columnSpanFull(),
|
||||
Placeholder::make('paddle_sync_error')
|
||||
Placeholder::make('lemonsqueezy_sync_error')
|
||||
->label('Letzter Fehler')
|
||||
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '–')
|
||||
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
|
||||
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '–')
|
||||
->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
@@ -263,15 +263,15 @@ class PackageResource extends Resource
|
||||
->label('Features')
|
||||
->wrap()
|
||||
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
||||
TextColumn::make('paddle_product_id')
|
||||
->label('Paddle Produkt')
|
||||
TextColumn::make('lemonsqueezy_product_id')
|
||||
->label('Lemon Squeezy Produkt')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
TextColumn::make('paddle_price_id')
|
||||
->label('Paddle Preis')
|
||||
TextColumn::make('lemonsqueezy_variant_id')
|
||||
->label('Lemon Squeezy Variant')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
BadgeColumn::make('paddle_sync_status')
|
||||
BadgeColumn::make('lemonsqueezy_sync_status')
|
||||
->label('Sync-Status')
|
||||
->colors([
|
||||
'success' => 'synced',
|
||||
@@ -281,13 +281,13 @@ class PackageResource extends Resource
|
||||
])
|
||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_synced_at')
|
||||
TextColumn::make('lemonsqueezy_synced_at')
|
||||
->label('Sync am')
|
||||
->dateTime()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('paddle_sync_error_message')
|
||||
TextColumn::make('lemonsqueezy_sync_error_message')
|
||||
->label('Sync-Fehler')
|
||||
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
|
||||
->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
|
||||
->wrap()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
@@ -301,43 +301,43 @@ class PackageResource extends Resource
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('syncPaddle')
|
||||
->label('Mit Paddle abgleichen')
|
||||
Actions\Action::make('syncLemonSqueezy')
|
||||
->label('Mit Lemon Squeezy abgleichen')
|
||||
->icon('heroicon-o-cloud-arrow-up')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing')
|
||||
->disabled(fn (Package $record) => $record->lemonsqueezy_sync_status === 'syncing')
|
||||
->action(function (Package $record) {
|
||||
SyncPackageToPaddle::dispatch($record->id);
|
||||
SyncPackageToLemonSqueezy::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Sync gestartet')
|
||||
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
||||
->title('Lemon Squeezy-Sync gestartet')
|
||||
->body('Das Paket wird im Hintergrund mit Lemon Squeezy abgeglichen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('linkPaddle')
|
||||
->label('Paddle verknüpfen')
|
||||
Actions\Action::make('linkLemonSqueezy')
|
||||
->label('Lemon Squeezy verknüpfen')
|
||||
->icon('heroicon-o-link')
|
||||
->color('info')
|
||||
->form([
|
||||
TextInput::make('paddle_product_id')
|
||||
->label('Paddle Produkt-ID')
|
||||
TextInput::make('lemonsqueezy_product_id')
|
||||
->label('Lemon Squeezy Produkt-ID')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
TextInput::make('paddle_price_id')
|
||||
->label('Paddle Preis-ID')
|
||||
TextInput::make('lemonsqueezy_variant_id')
|
||||
->label('Lemon Squeezy Variant-ID')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
])
|
||||
->fillForm(fn (Package $record) => [
|
||||
'paddle_product_id' => $record->paddle_product_id,
|
||||
'paddle_price_id' => $record->paddle_price_id,
|
||||
'lemonsqueezy_product_id' => $record->lemonsqueezy_product_id,
|
||||
'lemonsqueezy_variant_id' => $record->lemonsqueezy_variant_id,
|
||||
])
|
||||
->action(function (Package $record, array $data): void {
|
||||
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']);
|
||||
$record->linkLemonSqueezyIds($data['lemonsqueezy_product_id'], $data['lemonsqueezy_variant_id']);
|
||||
|
||||
PullPackageFromPaddle::dispatch($record->id);
|
||||
PullPackageFromLemonSqueezy::dispatch($record->id);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'linked',
|
||||
@@ -348,22 +348,22 @@ class PackageResource extends Resource
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Paddle-Verknüpfung gespeichert')
|
||||
->title('Lemon Squeezy-Verknüpfung gespeichert')
|
||||
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('pullPaddle')
|
||||
->label('Status von Paddle holen')
|
||||
Actions\Action::make('pullLemonSqueezy')
|
||||
->label('Status von Lemon Squeezy holen')
|
||||
->icon('heroicon-o-cloud-arrow-down')
|
||||
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id)
|
||||
->disabled(fn (Package $record) => ! $record->lemonsqueezy_product_id && ! $record->lemonsqueezy_variant_id)
|
||||
->requiresConfirmation()
|
||||
->action(function (Package $record) {
|
||||
PullPackageFromPaddle::dispatch($record->id);
|
||||
PullPackageFromLemonSqueezy::dispatch($record->id);
|
||||
|
||||
Notification::make()
|
||||
->info()
|
||||
->title('Paddle-Abgleich angefordert')
|
||||
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.')
|
||||
->title('Lemon Squeezy-Abgleich angefordert')
|
||||
->body('Der aktuelle Stand aus Lemon Squeezy wird geladen und hier hinterlegt.')
|
||||
->send();
|
||||
}),
|
||||
ViewAction::make(),
|
||||
|
||||
@@ -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\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
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 === 'paddle' && $record->provider_id) {
|
||||
if ($record->provider === 'lemonsqueezy' && $record->provider_id) {
|
||||
try {
|
||||
/** @var PaddleTransactionService $paddle */
|
||||
$paddle = App::make(PaddleTransactionService::class);
|
||||
$paddle->refund($record->provider_id, ['reason' => $reason]);
|
||||
/** @var LemonSqueezyOrderService $lemonsqueezy */
|
||||
$lemonsqueezy = App::make(LemonSqueezyOrderService::class);
|
||||
$lemonsqueezy->refund($record->provider_id, ['reason' => $reason]);
|
||||
} catch (\Throwable $exception) {
|
||||
$refundSuccess = false;
|
||||
$errorMessage = $exception->getMessage();
|
||||
Log::warning('Paddle refund failed', [
|
||||
Log::warning('Lemon Squeezy refund failed', [
|
||||
'purchase_id' => $record->id,
|
||||
'provider_id' => $record->provider_id,
|
||||
'error' => $exception->getMessage(),
|
||||
|
||||
@@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord
|
||||
->visible(fn ($record): bool => ! $record->refunded)
|
||||
->action(function ($record) {
|
||||
$record->update(['refunded' => true]);
|
||||
// TODO: Call Paddle API for actual refund
|
||||
// TODO: Call Lemon Squeezy API for actual refund
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'purchase.refunded',
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelat
|
||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
||||
use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\InactiveTenantDeletionWarning;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
@@ -72,10 +73,10 @@ class TenantResource extends Resource
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('paddle_customer_id')
|
||||
->label('Paddle Customer ID')
|
||||
TextInput::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer ID')
|
||||
->maxLength(191)
|
||||
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
|
||||
->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
|
||||
->nullable(),
|
||||
TextInput::make('total_revenue')
|
||||
->label(__('admin.tenants.fields.total_revenue'))
|
||||
@@ -134,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('paddle_customer_id')
|
||||
->label('Paddle Customer')
|
||||
Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
||||
@@ -205,11 +206,13 @@ class TenantResource extends Resource
|
||||
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
|
||||
])
|
||||
->action(function (Tenant $record, array $data) {
|
||||
$package = Package::query()->find($data['package_id']);
|
||||
\App\Models\TenantPackage::create([
|
||||
'tenant_id' => $record->id,
|
||||
'package_id' => $data['package_id'],
|
||||
'expires_at' => $data['expires_at'],
|
||||
'active' => true,
|
||||
'price' => $package?->price ?? 0,
|
||||
'reason' => $data['reason'] ?? null,
|
||||
]);
|
||||
\App\Models\PackagePurchase::create([
|
||||
|
||||
@@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
Select::make('provider')
|
||||
->label('Anbieter')
|
||||
->options([
|
||||
'paddle' => 'Paddle',
|
||||
'lemonsqueezy' => 'Lemon Squeezy',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
])
|
||||
@@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
TextColumn::make('provider')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'paddle' => 'success',
|
||||
'lemonsqueezy' => 'success',
|
||||
'manual' => 'gray',
|
||||
'free' => 'success',
|
||||
default => 'gray',
|
||||
@@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
]),
|
||||
SelectFilter::make('provider')
|
||||
->options([
|
||||
'paddle' => 'Paddle',
|
||||
'lemonsqueezy' => 'Lemon Squeezy',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
]),
|
||||
|
||||
@@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Ablaufdatum')
|
||||
->required(),
|
||||
TextInput::make('paddle_subscription_id')
|
||||
->label('Paddle Subscription ID')
|
||||
TextInput::make('lemonsqueezy_subscription_id')
|
||||
->label('Lemon Squeezy Subscription ID')
|
||||
->maxLength(191)
|
||||
->helperText('Abonnement-ID aus Paddle Billing.')
|
||||
->helperText('Abonnement-ID aus Lemon Squeezy.')
|
||||
->nullable(),
|
||||
Toggle::make('active')
|
||||
->label('Aktiv'),
|
||||
@@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager
|
||||
TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('paddle_subscription_id')
|
||||
->label('Paddle Subscription')
|
||||
TextColumn::make('lemonsqueezy_subscription_id')
|
||||
->label('Lemon Squeezy Subscription')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||
IconColumn::make('active')
|
||||
|
||||
@@ -22,8 +22,8 @@ class TenantInfolist
|
||||
TextEntry::make('user.full_name')
|
||||
->label(__('admin.tenants.fields.owner'))
|
||||
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
|
||||
TextEntry::make('paddle_customer_id')
|
||||
->label('Paddle Customer ID')
|
||||
TextEntry::make('lemonsqueezy_customer_id')
|
||||
->label('Lemon Squeezy Customer ID')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('total_revenue')
|
||||
->label(__('admin.tenants.fields.total_revenue'))
|
||||
|
||||
@@ -3,39 +3,78 @@
|
||||
namespace App\Filament\SuperAdmin\Pages\Auth;
|
||||
|
||||
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Schemas\Components\Livewire;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EditProfile extends BaseEditProfile
|
||||
{
|
||||
public function mount(): void
|
||||
protected function getPasswordConfirmationFormComponent(): Component
|
||||
{
|
||||
Log::info('EditProfile class loaded for superadmin');
|
||||
parent::mount();
|
||||
return TextInput::make('passwordConfirmation')
|
||||
->label(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.label'))
|
||||
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.validation_attribute'))
|
||||
->password()
|
||||
->autocomplete('new-password')
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => filled($get('password')))
|
||||
->dehydrated(false);
|
||||
}
|
||||
|
||||
protected function getCurrentPasswordFormComponent(): Component
|
||||
{
|
||||
return TextInput::make('currentPassword')
|
||||
->label(__('filament-panels::auth/pages/edit-profile.form.current_password.label'))
|
||||
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.current_password.validation_attribute'))
|
||||
->belowContent(__('filament-panels::auth/pages/edit-profile.form.current_password.below_content'))
|
||||
->password()
|
||||
->autocomplete('current-password')
|
||||
->currentPassword(guard: Filament::getAuthGuard())
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => filled($get('password')))
|
||||
->dehydrated(false);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
$this->getNameFormComponent(),
|
||||
$this->getEmailFormComponent(),
|
||||
TextInput::make('username')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
Select::make('preferred_locale')
|
||||
->options([
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
Section::make('Profile')
|
||||
->schema([
|
||||
$this->getNameFormComponent(),
|
||||
$this->getEmailFormComponent(),
|
||||
TextInput::make('username')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
Select::make('preferred_locale')
|
||||
->options([
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
])
|
||||
->default('de')
|
||||
->required(),
|
||||
])
|
||||
->default('de')
|
||||
->required(),
|
||||
$this->getPasswordFormComponent(),
|
||||
$this->getPasswordConfirmationFormComponent(),
|
||||
$this->getCurrentPasswordFormComponent(),
|
||||
->columns(2),
|
||||
Section::make('Security')
|
||||
->schema([
|
||||
$this->getPasswordFormComponent(),
|
||||
$this->getPasswordConfirmationFormComponent(),
|
||||
$this->getCurrentPasswordFormComponent(),
|
||||
])
|
||||
->columns(1),
|
||||
Section::make('Support API Tokens')
|
||||
->description('Manage bearer tokens for external support tooling.')
|
||||
->schema([
|
||||
Livewire::make('support-api-token-manager'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
280
app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php
Normal file
280
app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Widgets;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Sanctum\NewAccessToken;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class SupportApiTokenManager extends TableWidget
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->heading('Support API Tokens')
|
||||
->query(fn (): Builder => $this->getTokenQuery())
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Name')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('abilities')
|
||||
->label('Abilities')
|
||||
->formatStateUsing(fn ($state): string => $this->formatAbilities($state))
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('last_used_at')
|
||||
->label('Last used')
|
||||
->since()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->label('Expires')
|
||||
->dateTime('Y-m-d H:i')
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since(),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('create_support_token')
|
||||
->label('Create token')
|
||||
->icon('heroicon-o-key')
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->label('Token name')
|
||||
->default($this->defaultTokenName())
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Existing tokens with the same name will be revoked.'),
|
||||
CheckboxList::make('abilities')
|
||||
->label('Abilities')
|
||||
->options($this->abilityOptions())
|
||||
->columns(2)
|
||||
->required()
|
||||
->default($this->defaultAbilities()),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Expires at')
|
||||
->displayFormat('Y-m-d H:i')
|
||||
->seconds(false),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$user = $this->getUser();
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->normalizeTokenName($data['name'] ?? null);
|
||||
$abilities = $this->normalizeAbilities($data['abilities'] ?? []);
|
||||
$expiresAt = $this->normalizeExpiresAt($data['expires_at'] ?? null);
|
||||
|
||||
$user->tokens()->where('name', $name)->delete();
|
||||
|
||||
$token = $user->createToken($name, $abilities, $expiresAt);
|
||||
|
||||
$this->recordTokenCreated($token, $abilities, $user);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Token created')
|
||||
->body('Copy this token now. It will not be shown again: '.$token->plainTextToken)
|
||||
->persistent()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('revoke')
|
||||
->label('Revoke')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (PersonalAccessToken $record): bool => $this->ownsToken($record))
|
||||
->action(function (PersonalAccessToken $record): void {
|
||||
if (! $this->ownsToken($record)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'support-api-token.revoked',
|
||||
$record,
|
||||
['fields' => ['name', 'abilities', 'expires_at']],
|
||||
actor: $this->getUser(),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Token revoked')
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No support API tokens')
|
||||
->emptyStateDescription('Create a token for external support tooling.');
|
||||
}
|
||||
|
||||
private function getTokenQuery(): Builder
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
if (! $user) {
|
||||
return PersonalAccessToken::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return PersonalAccessToken::query()
|
||||
->where('tokenable_id', $user->getKey())
|
||||
->where('tokenable_type', $user->getMorphClass());
|
||||
}
|
||||
|
||||
private function getUser(): ?User
|
||||
{
|
||||
$user = Filament::auth()->user();
|
||||
|
||||
return $user instanceof User ? $user : null;
|
||||
}
|
||||
|
||||
private function formatAbilities(mixed $state): string
|
||||
{
|
||||
if (is_array($state)) {
|
||||
return implode(', ', $state);
|
||||
}
|
||||
|
||||
if (is_string($state)) {
|
||||
return $state;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function defaultAbilities(): array
|
||||
{
|
||||
$abilities = config('support-api.token.default_abilities', []);
|
||||
|
||||
if (! is_array($abilities)) {
|
||||
return ['support-admin'];
|
||||
}
|
||||
|
||||
$abilities = array_values(array_filter($abilities, fn ($ability) => is_string($ability) && $ability !== ''));
|
||||
|
||||
if (! in_array('support-admin', $abilities, true)) {
|
||||
$abilities[] = 'support-admin';
|
||||
}
|
||||
|
||||
return array_values(array_unique($abilities));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function abilityOptions(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($this->defaultAbilities() as $ability) {
|
||||
$options[$ability] = $ability;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $abilities
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeAbilities(array $abilities): array
|
||||
{
|
||||
$allowed = $this->defaultAbilities();
|
||||
$filtered = array_values(array_intersect($abilities, $allowed));
|
||||
|
||||
if (! in_array('support-admin', $filtered, true)) {
|
||||
$filtered[] = 'support-admin';
|
||||
}
|
||||
|
||||
sort($filtered);
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private function defaultTokenName(): string
|
||||
{
|
||||
$name = config('support-api.token.name');
|
||||
|
||||
if (is_string($name) && $name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return 'support-api';
|
||||
}
|
||||
|
||||
private function normalizeTokenName(?string $name): string
|
||||
{
|
||||
$name = $name ? trim($name) : '';
|
||||
|
||||
return $name !== '' ? $name : $this->defaultTokenName();
|
||||
}
|
||||
|
||||
private function normalizeExpiresAt(mixed $expiresAt): ?Carbon
|
||||
{
|
||||
if ($expiresAt instanceof Carbon) {
|
||||
return $expiresAt;
|
||||
}
|
||||
|
||||
if ($expiresAt instanceof \DateTimeInterface) {
|
||||
return Carbon::instance($expiresAt);
|
||||
}
|
||||
|
||||
if (is_string($expiresAt) && $expiresAt !== '') {
|
||||
return Carbon::parse($expiresAt);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function recordTokenCreated(NewAccessToken $token, array $abilities, User $user): void
|
||||
{
|
||||
$actionLog = app(SuperAdminAuditLogger::class);
|
||||
|
||||
$actionLog->record(
|
||||
'support-api-token.created',
|
||||
$token->accessToken,
|
||||
[
|
||||
'fields' => ['name', 'abilities', 'expires_at'],
|
||||
'abilities' => $abilities,
|
||||
],
|
||||
actor: $user,
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
private function ownsToken(PersonalAccessToken $token): bool
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $token->tokenable_id === (int) $user->getKey()
|
||||
&& $token->tokenable_type === $user->getMorphClass();
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,88 @@ class DokployPlatformHealth extends Widget
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$projects = $this->loadProjects();
|
||||
|
||||
return [
|
||||
'composes' => $this->loadComposes(),
|
||||
'projects' => $projects,
|
||||
'composes' => empty($projects) ? $this->loadComposes() : [],
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadProjects(): array
|
||||
{
|
||||
$client = app(DokployClient::class);
|
||||
$projectMap = config('dokploy.projects', []);
|
||||
$results = [];
|
||||
|
||||
if (empty($projectMap)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($projectMap as $label => $projectId) {
|
||||
$project = [];
|
||||
$projectIdString = (string) $projectId;
|
||||
|
||||
try {
|
||||
$project = $client->project($projectIdString);
|
||||
} catch (\Throwable $exception) {
|
||||
$project = [];
|
||||
}
|
||||
|
||||
if (empty($project)) {
|
||||
$project = $client->findProject($projectIdString) ?? [];
|
||||
|
||||
$resolvedProjectId = Arr::get($project, 'projectId');
|
||||
|
||||
if ($resolvedProjectId) {
|
||||
try {
|
||||
$project = $client->project((string) $resolvedProjectId);
|
||||
} catch (\Throwable $exception) {
|
||||
$project = $project;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $project) {
|
||||
$results[] = [
|
||||
'label' => ucfirst((string) $label),
|
||||
'project_id' => $projectIdString,
|
||||
'name' => $projectIdString,
|
||||
'status' => 'unreachable',
|
||||
'error' => "Project {$projectIdString} not found.",
|
||||
'applications' => [],
|
||||
'services' => [],
|
||||
'composes' => [],
|
||||
'updated_at' => null,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$environments = $this->extractEnvironments($project);
|
||||
$applications = $this->formatEnvironmentApplications($environments, $client);
|
||||
$composes = $this->formatEnvironmentComposes($environments, $client);
|
||||
$services = $this->formatEnvironmentServices($environments);
|
||||
|
||||
$results[] = [
|
||||
'label' => ucfirst((string) $label),
|
||||
'project_id' => Arr::get($project, 'projectId', $projectIdString),
|
||||
'name' => Arr::get($project, 'name') ?? Arr::get($project, 'projectName') ?? $projectIdString,
|
||||
'description' => Arr::get($project, 'description'),
|
||||
'status' => $this->deriveProjectStatus($applications, $services, $composes),
|
||||
'applications' => $applications,
|
||||
'composes' => $composes,
|
||||
'services' => $services,
|
||||
'updated_at' => Arr::get($project, 'updatedAt') ?? Arr::get($project, 'createdAt'),
|
||||
'applications_count' => count($applications),
|
||||
'composes_count' => count($composes),
|
||||
'services_count' => count($services),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function loadComposes(): array
|
||||
{
|
||||
$client = app(DokployClient::class);
|
||||
@@ -62,7 +139,7 @@ class DokployPlatformHealth extends Widget
|
||||
'label' => 'Dokploy',
|
||||
'compose_id' => '-',
|
||||
'status' => 'unconfigured',
|
||||
'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
|
||||
'error' => 'Set DOKPLOY_PROJECT_IDS or DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -70,6 +147,252 @@ class DokployPlatformHealth extends Widget
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function extractEnvironments(array $project): array
|
||||
{
|
||||
$environments = Arr::get($project, 'environments', []);
|
||||
|
||||
if (is_array($environments) && ! empty($environments)) {
|
||||
return $environments;
|
||||
}
|
||||
|
||||
return [[
|
||||
'name' => Arr::get($project, 'name'),
|
||||
'applications' => Arr::get($project, 'applications', []),
|
||||
'compose' => Arr::get($project, 'compose', []),
|
||||
'mysql' => Arr::get($project, 'mysql', []),
|
||||
'postgres' => Arr::get($project, 'postgres', []),
|
||||
'mariadb' => Arr::get($project, 'mariadb', []),
|
||||
'mongo' => Arr::get($project, 'mongo', []),
|
||||
'redis' => Arr::get($project, 'redis', []),
|
||||
]];
|
||||
}
|
||||
|
||||
protected function formatEnvironmentApplications(array $environments, DokployClient $client): array
|
||||
{
|
||||
return collect($environments)
|
||||
->flatMap(function (array $environment) use ($client) {
|
||||
$applications = Arr::get($environment, 'applications', []);
|
||||
$environmentName = Arr::get($environment, 'name');
|
||||
|
||||
return $this->formatApplications(is_array($applications) ? $applications : [], $client, $environmentName);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatEnvironmentComposes(array $environments, DokployClient $client): array
|
||||
{
|
||||
return collect($environments)
|
||||
->flatMap(function (array $environment) use ($client) {
|
||||
$composes = Arr::get($environment, 'compose', []);
|
||||
$environmentName = Arr::get($environment, 'name');
|
||||
|
||||
return collect(is_array($composes) ? $composes : [])
|
||||
->map(function (array $compose) use ($client, $environmentName) {
|
||||
$composeId = Arr::get($compose, 'composeId') ?? Arr::get($compose, 'id');
|
||||
$statusPayload = [];
|
||||
$deployments = [];
|
||||
|
||||
if ($composeId) {
|
||||
try {
|
||||
$statusPayload = $client->composeStatus($composeId);
|
||||
$deployments = $client->composeDeployments($composeId, 1);
|
||||
} catch (\Throwable $exception) {
|
||||
$statusPayload = [];
|
||||
$deployments = [];
|
||||
}
|
||||
}
|
||||
|
||||
$composeDetails = Arr::get($statusPayload, 'compose', []);
|
||||
|
||||
return [
|
||||
'id' => $composeId,
|
||||
'name' => Arr::get($compose, 'name')
|
||||
?? Arr::get($compose, 'appName')
|
||||
?? Arr::get($composeDetails, 'name')
|
||||
?? Arr::get($composeDetails, 'appName')
|
||||
?? $composeId,
|
||||
'status' => Arr::get($compose, 'composeStatus')
|
||||
?? Arr::get($compose, 'status')
|
||||
?? Arr::get($composeDetails, 'composeStatus')
|
||||
?? Arr::get($composeDetails, 'status')
|
||||
?? 'unknown',
|
||||
'environment' => $environmentName,
|
||||
'last_deploy' => Arr::get($deployments, '0.createdAt')
|
||||
?? Arr::get($deployments, '0.created_at')
|
||||
?? Arr::get($compose, 'updatedAt')
|
||||
?? Arr::get($composeDetails, 'updatedAt'),
|
||||
'services' => $this->formatServices(Arr::get($statusPayload, 'services', [])),
|
||||
];
|
||||
})
|
||||
->filter(fn (array $compose) => filled($compose['name']))
|
||||
->values()
|
||||
->all();
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatEnvironmentServices(array $environments): array
|
||||
{
|
||||
return collect($environments)
|
||||
->flatMap(function (array $environment) {
|
||||
$environmentName = Arr::get($environment, 'name');
|
||||
|
||||
return collect([
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'compose', []), 'compose', 'composeId', 'composeStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mysql', []), 'mysql', 'mysqlId', 'applicationStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'postgres', []), 'postgres', 'postgresId', 'applicationStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mariadb', []), 'mariadb', 'mariadbId', 'applicationStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mongo', []), 'mongo', 'mongoId', 'applicationStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'redis', []), 'redis', 'redisId', 'applicationStatus', $environmentName),
|
||||
]);
|
||||
})
|
||||
->filter(fn (array $service) => filled($service['name']))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatApplications(array $applications, DokployClient $client, ?string $environment = null): array
|
||||
{
|
||||
return collect($applications)
|
||||
->map(function (array $application) use ($client, $environment) {
|
||||
$applicationId = $this->extractApplicationId($application);
|
||||
$statusPayload = [];
|
||||
|
||||
if ($applicationId) {
|
||||
try {
|
||||
$statusPayload = $client->applicationStatus($applicationId);
|
||||
} catch (\Throwable $exception) {
|
||||
$statusPayload = [];
|
||||
}
|
||||
}
|
||||
|
||||
$applicationDetails = Arr::get($statusPayload, 'application', []);
|
||||
$monitoring = Arr::get($statusPayload, 'monitoring', []);
|
||||
|
||||
$status = Arr::get($application, 'applicationStatus')
|
||||
?? Arr::get($application, 'status')
|
||||
?? Arr::get($applicationDetails, 'applicationStatus')
|
||||
?? Arr::get($applicationDetails, 'status')
|
||||
?? 'unknown';
|
||||
|
||||
return [
|
||||
'id' => $applicationId ?? Arr::get($application, 'id'),
|
||||
'name' => Arr::get($application, 'name')
|
||||
?? Arr::get($application, 'appName')
|
||||
?? Arr::get($applicationDetails, 'name')
|
||||
?? Arr::get($applicationDetails, 'appName')
|
||||
?? $applicationId,
|
||||
'status' => $status,
|
||||
'repository' => Arr::get($application, 'repository')
|
||||
?? Arr::get($applicationDetails, 'repository')
|
||||
?? Arr::get($application, 'repo')
|
||||
?? Arr::get($applicationDetails, 'repo'),
|
||||
'branch' => Arr::get($application, 'branch')
|
||||
?? Arr::get($applicationDetails, 'branch')
|
||||
?? Arr::get($application, 'gitBranch')
|
||||
?? Arr::get($applicationDetails, 'gitBranch'),
|
||||
'url' => Arr::get($application, 'url')
|
||||
?? Arr::get($applicationDetails, 'url')
|
||||
?? Arr::get($application, 'domain')
|
||||
?? Arr::get($applicationDetails, 'domain'),
|
||||
'server' => Arr::get($application, 'serverName')
|
||||
?? Arr::get($applicationDetails, 'serverName')
|
||||
?? Arr::get($application, 'server'),
|
||||
'environment' => $environment,
|
||||
'last_deploy' => Arr::get($application, 'lastDeploymentAt')
|
||||
?? Arr::get($applicationDetails, 'lastDeploymentAt')
|
||||
?? Arr::get($application, 'updatedAt')
|
||||
?? Arr::get($applicationDetails, 'updatedAt')
|
||||
?? Arr::get($application, 'createdAt'),
|
||||
'monitoring' => $this->formatMonitoring($monitoring),
|
||||
];
|
||||
})
|
||||
->filter(fn (array $application) => filled($application['name']))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function extractApplicationId(array $application): ?string
|
||||
{
|
||||
return Arr::get($application, 'applicationId')
|
||||
?? Arr::get($application, 'appId')
|
||||
?? Arr::get($application, 'id');
|
||||
}
|
||||
|
||||
protected function normalizeServiceList(array $services, string $type, string $idKey, string $statusKey, ?string $environment = null): array
|
||||
{
|
||||
return collect($services)
|
||||
->map(function (array $service) use ($type, $idKey, $statusKey, $environment) {
|
||||
return [
|
||||
'type' => $type,
|
||||
'id' => Arr::get($service, $idKey) ?? Arr::get($service, 'id'),
|
||||
'name' => Arr::get($service, 'name') ?? Arr::get($service, 'appName') ?? Arr::get($service, 'serviceName'),
|
||||
'status' => Arr::get($service, $statusKey) ?? Arr::get($service, 'status') ?? Arr::get($service, 'composeStatus', 'unknown'),
|
||||
'version' => Arr::get($service, 'dockerImage') ?? Arr::get($service, 'image'),
|
||||
'external_port' => Arr::get($service, 'externalPort'),
|
||||
'environment' => $environment,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatMonitoring(array $monitoring): array
|
||||
{
|
||||
$metrics = [];
|
||||
$allowed = [
|
||||
'cpuPercent' => 'CPU',
|
||||
'cpu' => 'CPU',
|
||||
'memoryPercent' => 'Memory',
|
||||
'memory' => 'Memory',
|
||||
'uptime' => 'Uptime',
|
||||
];
|
||||
|
||||
foreach ($allowed as $key => $label) {
|
||||
$value = Arr::get($monitoring, $key);
|
||||
|
||||
if (filled($value) && ! is_array($value)) {
|
||||
$metrics[] = [
|
||||
'label' => $label,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
protected function deriveProjectStatus(array $applications, array $services, array $composes): string
|
||||
{
|
||||
$statuses = collect($applications)
|
||||
->pluck('status')
|
||||
->merge(collect($services)->pluck('status'))
|
||||
->merge(collect($composes)->pluck('status'))
|
||||
->filter()
|
||||
->map(fn ($status) => strtolower((string) $status))
|
||||
->values();
|
||||
|
||||
if ($statuses->contains(fn ($status) => in_array($status, ['error', 'failed', 'unreachable', 'unhealthy'], true))) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if ($statuses->contains(fn ($status) => in_array($status, ['deploying', 'pending', 'starting'], true))) {
|
||||
return 'deploying';
|
||||
}
|
||||
|
||||
if ($statuses->contains(fn ($status) => in_array($status, ['stopped', 'inactive', 'paused'], true))) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if ($statuses->contains(fn ($status) => in_array($status, ['done', 'running', 'healthy'], true))) {
|
||||
return 'done';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
protected function formatServices(array $services): array
|
||||
{
|
||||
return collect($services)
|
||||
|
||||
@@ -12,6 +12,8 @@ class QueueHealthWidget extends Widget
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$snapshot = Cache::get('storage:queue-health:last');
|
||||
|
||||
@@ -41,6 +41,7 @@ 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
|
||||
@@ -185,6 +186,57 @@ class EventPublicController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
||||
|
||||
if ($event->id ?? null) {
|
||||
$eventModel = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])->find($event->id);
|
||||
if ($eventModel && $eventModel->tenant) {
|
||||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
|
||||
$eventModel->tenant,
|
||||
$eventModel->id,
|
||||
$eventModel
|
||||
);
|
||||
$maxGuests = $eventPackage?->effectiveGuestLimit();
|
||||
|
||||
if ($eventPackage && $maxGuests !== null) {
|
||||
$grace = (int) config('package-limits.guest_grace', 10);
|
||||
$hardLimit = $maxGuests + max(0, $grace);
|
||||
$usedGuests = (int) $eventPackage->used_guests;
|
||||
$isReturningGuest = $this->joinTokenService->hasSeenGuest($eventModel->id, $deviceId, $request->ip());
|
||||
|
||||
if ($usedGuests >= $hardLimit && ! $isReturningGuest) {
|
||||
$this->recordTokenEvent(
|
||||
$joinToken,
|
||||
$request,
|
||||
'guest_limit_exceeded',
|
||||
[
|
||||
'event_id' => $eventModel->id,
|
||||
'used' => $usedGuests,
|
||||
'limit' => $maxGuests,
|
||||
'hard_limit' => $hardLimit,
|
||||
],
|
||||
$token,
|
||||
Response::HTTP_PAYMENT_REQUIRED
|
||||
);
|
||||
|
||||
return ApiError::response(
|
||||
'guest_limit_exceeded',
|
||||
__('api.packages.guest_limit_exceeded.title'),
|
||||
__('api.packages.guest_limit_exceeded.message'),
|
||||
Response::HTTP_PAYMENT_REQUIRED,
|
||||
[
|
||||
'event_id' => $eventModel->id,
|
||||
'used' => $usedGuests,
|
||||
'limit' => $maxGuests,
|
||||
'hard_limit' => $hardLimit,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RateLimiter::clear($rateLimiterKey);
|
||||
|
||||
if (isset($event->status)) {
|
||||
@@ -1003,6 +1055,7 @@ 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,
|
||||
@@ -1042,12 +1095,8 @@ 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
|
||||
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
|
||||
: [[]];
|
||||
$sources = $brandingAllowed ? [$eventBranding] : [[]];
|
||||
|
||||
$primary = $this->normalizeHexColor(
|
||||
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
||||
@@ -1070,6 +1119,7 @@ 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)) {
|
||||
@@ -1131,6 +1181,7 @@ 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,
|
||||
@@ -1687,6 +1738,7 @@ class EventPublicController extends BaseController
|
||||
'name' => $event->name,
|
||||
'city' => $event->city,
|
||||
] : null,
|
||||
'branding' => $event ? $this->resolveBrandingPayload($event) : null,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
@@ -1906,7 +1958,9 @@ class EventPublicController extends BaseController
|
||||
$policy = $this->guestPolicy();
|
||||
|
||||
if ($joinToken) {
|
||||
$this->joinTokenService->incrementUsage($joinToken);
|
||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
||||
$this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip());
|
||||
}
|
||||
|
||||
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
|
||||
@@ -1931,6 +1985,47 @@ class EventPublicController extends BaseController
|
||||
])->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']);
|
||||
@@ -2547,6 +2642,15 @@ 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
|
||||
@@ -2557,6 +2661,8 @@ class EventPublicController extends BaseController
|
||||
$payload = [
|
||||
'online_guests' => $onlineGuests,
|
||||
'tasks_solved' => $tasksSolved,
|
||||
'guest_count' => $guestCount,
|
||||
'likes_count' => $likesCount,
|
||||
'latest_photo_at' => $latestPhotoAt,
|
||||
'engagement_mode' => $engagementMode,
|
||||
];
|
||||
|
||||
@@ -24,7 +24,7 @@ class CouponPreviewController extends Controller
|
||||
|
||||
$package = Package::findOrFail($data['package_id']);
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.package_not_configured'),
|
||||
]);
|
||||
|
||||
@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
|
||||
|
||||
if (! $checkout['checkout_url']) {
|
||||
throw ValidationException::withMessages([
|
||||
'tier_key' => __('Unable to create Paddle checkout.'),
|
||||
'tier_key' => __('Unable to create Lemon Squeezy checkout.'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,19 +46,19 @@ class GiftVoucherCheckoutController extends Controller
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'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'],
|
||||
'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'],
|
||||
]);
|
||||
|
||||
$voucherQuery = GiftVoucher::query();
|
||||
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']);
|
||||
$voucherQuery->where('lemonsqueezy_checkout_id', $data['checkout_id']);
|
||||
}
|
||||
|
||||
if (! empty($data['transaction_id'])) {
|
||||
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']);
|
||||
if (! empty($data['order_id'])) {
|
||||
$voucherQuery->orWhere('lemonsqueezy_order_id', $data['order_id']);
|
||||
}
|
||||
|
||||
if (! empty($data['code'])) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -18,7 +18,7 @@ use Illuminate\Validation\ValidationException;
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
) {}
|
||||
|
||||
@@ -53,7 +53,7 @@ class PackageController extends Controller
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'type' => 'required|in:endcustomer,reseller',
|
||||
'payment_method' => 'required|in:paddle',
|
||||
'payment_method' => 'required|in:lemonsqueezy',
|
||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||
'success_url' => 'nullable|url',
|
||||
'return_url' => 'nullable|url',
|
||||
@@ -79,7 +79,7 @@ class PackageController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'paddle_transaction_id' => 'required|string',
|
||||
'lemonsqueezy_order_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
@@ -89,14 +89,14 @@ class PackageController extends Controller
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||
}
|
||||
|
||||
$provider = 'paddle';
|
||||
$provider = 'lemonsqueezy';
|
||||
|
||||
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => $provider,
|
||||
'provider_id' => $request->input('paddle_transaction_id'),
|
||||
'provider_id' => $request->input('lemonsqueezy_order_id'),
|
||||
'price' => $package->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'purchased_at' => now(),
|
||||
@@ -161,7 +161,7 @@ class PackageController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function createPaddleCheckout(Request $request): JsonResponse
|
||||
public function createLemonSqueezyCheckout(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
@@ -181,15 +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.']);
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -211,14 +211,14 @@ class PackageController extends Controller
|
||||
],
|
||||
];
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||
$checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, $payload);
|
||||
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
||||
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
@@ -239,7 +239,7 @@ class PackageController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
|
||||
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
@@ -297,11 +297,11 @@ class PackageController extends Controller
|
||||
|
||||
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
{
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
||||
}
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||
$checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
'metadata' => array_filter([
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\SupportGuestPolicyRequest;
|
||||
use App\Models\GuestPolicySetting;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Support\SupportApiAuthorizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SupportGuestPolicyController extends Controller
|
||||
{
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = GuestPolicySetting::current();
|
||||
|
||||
return response()->json([
|
||||
'data' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(SupportGuestPolicyRequest $request): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = GuestPolicySetting::query()->firstOrNew(['id' => 1]);
|
||||
|
||||
$settings->fill($request->validated());
|
||||
$settings->save();
|
||||
|
||||
$changed = $settings->getChanges();
|
||||
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'guest_policy.updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $settings->refresh(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
401
app/Http/Controllers/Api/Support/SupportResourceController.php
Normal file
401
app/Http/Controllers/Api/Support/SupportResourceController.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\Resources\SupportResourceFormRequest;
|
||||
use App\Http\Requests\Support\SupportResourceRequest;
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\SupportApiAuthorizer;
|
||||
use App\Support\SupportApiRegistry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SupportResourceController extends Controller
|
||||
{
|
||||
public function index(Request $request, string $resource): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$config = SupportApiRegistry::get($resource);
|
||||
if (! $config) {
|
||||
return $this->resourceNotFoundResponse($resource);
|
||||
}
|
||||
|
||||
$modelClass = $config['model'];
|
||||
/** @var Builder $query */
|
||||
$query = $modelClass::query();
|
||||
|
||||
$relations = SupportApiRegistry::withRelations($resource);
|
||||
if ($relations !== []) {
|
||||
$query->with($relations);
|
||||
}
|
||||
|
||||
$this->applySearch($request, $query, $resource);
|
||||
$this->applySorting($request, $query, $resource);
|
||||
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->items(),
|
||||
'meta' => [
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $resource, string $record): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$model = $this->resolveRecord($resource, $record);
|
||||
|
||||
if (! $model) {
|
||||
return $this->resourceNotFoundResponse($resource, $record);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $model,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(SupportResourceRequest $request, string $resource): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! SupportApiRegistry::allowsMutation($resource, 'create')) {
|
||||
return $this->mutationNotAllowedResponse($resource, 'create');
|
||||
}
|
||||
|
||||
$config = SupportApiRegistry::get($resource);
|
||||
if (! $config) {
|
||||
return $this->resourceNotFoundResponse($resource);
|
||||
}
|
||||
|
||||
$modelClass = $config['model'];
|
||||
/** @var Model $model */
|
||||
$model = new $modelClass;
|
||||
|
||||
$payload = $this->validatedPayload($request, $resource, 'create', $model);
|
||||
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if ($payload === []) {
|
||||
return $this->emptyPayloadResponse($resource);
|
||||
}
|
||||
|
||||
if ($resource === 'data-exports') {
|
||||
$payload = $this->normalizeDataExportPayload($request, $payload);
|
||||
}
|
||||
|
||||
$record = $modelClass::query()->create($payload);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
SupportApiRegistry::auditAction($resource, 'created'),
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata($payload),
|
||||
actor: $request->user(),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
if ($resource === 'data-exports') {
|
||||
GenerateDataExport::dispatch($record->id);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $record,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! SupportApiRegistry::allowsMutation($resource, 'update')) {
|
||||
return $this->mutationNotAllowedResponse($resource, 'update');
|
||||
}
|
||||
|
||||
$model = $this->resolveRecord($resource, $record);
|
||||
|
||||
if (! $model) {
|
||||
return $this->resourceNotFoundResponse($resource, $record);
|
||||
}
|
||||
|
||||
$payload = $this->validatedPayload($request, $resource, 'update', $model);
|
||||
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if ($payload === []) {
|
||||
return $this->emptyPayloadResponse($resource);
|
||||
}
|
||||
|
||||
$model->fill($payload);
|
||||
$model->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
SupportApiRegistry::auditAction($resource, 'updated'),
|
||||
$model,
|
||||
SuperAdminAuditLogger::fieldsMetadata($payload),
|
||||
actor: $request->user(),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $model->refresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $resource, string $record): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! SupportApiRegistry::allowsMutation($resource, 'delete')) {
|
||||
return $this->mutationNotAllowedResponse($resource, 'delete');
|
||||
}
|
||||
|
||||
$model = $this->resolveRecord($resource, $record);
|
||||
|
||||
if (! $model) {
|
||||
return $this->resourceNotFoundResponse($resource, $record);
|
||||
}
|
||||
|
||||
$model->delete();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
SupportApiRegistry::auditAction($resource, 'deleted'),
|
||||
$model,
|
||||
SuperAdminAuditLogger::fieldsMetadata([]),
|
||||
actor: $request->user(),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function resolveRecord(string $resource, string $record): ?Model
|
||||
{
|
||||
$config = SupportApiRegistry::get($resource);
|
||||
|
||||
if (! $config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modelClass = $config['model'];
|
||||
|
||||
$query = $modelClass::query();
|
||||
|
||||
if (is_numeric($record)) {
|
||||
return $query->find($record);
|
||||
}
|
||||
|
||||
$keyName = (new $modelClass)->getKeyName();
|
||||
|
||||
return $query->where($keyName, $record)->first();
|
||||
}
|
||||
|
||||
private function validatedPayload(SupportResourceRequest $request, string $resource, string $action, Model $model): array|JsonResponse
|
||||
{
|
||||
$payload = $request->validated('data');
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$validationClass = SupportApiRegistry::validationClass($resource, $action);
|
||||
|
||||
if ($validationClass && is_subclass_of($validationClass, SupportResourceFormRequest::class)) {
|
||||
$allowedFields = $validationClass::allowedFields($action);
|
||||
|
||||
if ($allowedFields !== []) {
|
||||
$unexpected = array_diff(array_keys($payload), $allowedFields);
|
||||
if ($unexpected !== []) {
|
||||
return $this->invalidFieldResponse($resource, $unexpected);
|
||||
}
|
||||
}
|
||||
|
||||
$rules = $validationClass::rulesFor($action, $model);
|
||||
if ($rules !== []) {
|
||||
$payload = Validator::make($payload, $rules)->validate();
|
||||
}
|
||||
|
||||
if ($allowedFields !== []) {
|
||||
$payload = Arr::only($payload, $allowedFields);
|
||||
}
|
||||
}
|
||||
|
||||
$fillable = $model->getFillable();
|
||||
|
||||
if ($fillable === [] && method_exists($model, 'getGuarded') && $model->getGuarded() !== ['*']) {
|
||||
$columns = Schema::getColumnListing($model->getTable());
|
||||
|
||||
return Arr::only($payload, $columns);
|
||||
}
|
||||
|
||||
if ($fillable === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Arr::only($payload, $fillable);
|
||||
}
|
||||
|
||||
private function applySearch(Request $request, Builder $query, string $resource): void
|
||||
{
|
||||
$term = $request->string('search')->trim()->value();
|
||||
|
||||
if ($term === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = SupportApiRegistry::searchFields($resource);
|
||||
|
||||
if ($fields === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = Schema::getColumnListing($query->getModel()->getTable());
|
||||
$fields = array_values(array_intersect($fields, $columns));
|
||||
|
||||
if ($fields === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function (Builder $builder) use ($fields, $term): void {
|
||||
foreach ($fields as $field) {
|
||||
if ($field === 'id' && is_numeric($term)) {
|
||||
$builder->orWhere($field, (int) $term);
|
||||
} else {
|
||||
$builder->orWhere($field, 'like', "%{$term}%");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function applySorting(Request $request, Builder $query, string $resource): void
|
||||
{
|
||||
$sort = $request->string('sort')->trim()->value();
|
||||
|
||||
if ($sort === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = 'asc';
|
||||
$field = $sort;
|
||||
|
||||
if (str_starts_with($sort, '-')) {
|
||||
$direction = 'desc';
|
||||
$field = ltrim($sort, '-');
|
||||
}
|
||||
|
||||
$allowed = SupportApiRegistry::searchFields($resource);
|
||||
$allowed[] = 'id';
|
||||
|
||||
$columns = Schema::getColumnListing($query->getModel()->getTable());
|
||||
$allowed = array_values(array_intersect($allowed, $columns));
|
||||
|
||||
if (! in_array($field, $allowed, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->orderBy($field, $direction);
|
||||
}
|
||||
|
||||
private function resolvePerPage(Request $request): int
|
||||
{
|
||||
$default = (int) config('support-api.pagination.default_per_page', 50);
|
||||
$max = (int) config('support-api.pagination.max_per_page', 200);
|
||||
|
||||
$perPage = (int) $request->input('per_page', $default);
|
||||
|
||||
if ($perPage < 1) {
|
||||
$perPage = $default;
|
||||
}
|
||||
|
||||
return min($perPage, $max);
|
||||
}
|
||||
|
||||
private function mutationNotAllowedResponse(string $resource, string $action): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'support_mutation_not_allowed',
|
||||
'Mutation Not Allowed',
|
||||
"{$resource} does not allow {$action} operations in support API.",
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
private function emptyPayloadResponse(string $resource): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'support_invalid_payload',
|
||||
'Invalid Payload',
|
||||
"No mutable fields provided for {$resource}.",
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
private function invalidFieldResponse(string $resource, array $fields): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'support_invalid_fields',
|
||||
'Invalid Fields',
|
||||
"Unsupported fields provided for {$resource}.",
|
||||
422,
|
||||
[
|
||||
'fields' => array_values($fields),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function resourceNotFoundResponse(string $resource, ?string $record = null): JsonResponse
|
||||
{
|
||||
$message = $record
|
||||
? "{$resource} record not found."
|
||||
: "Support resource {$resource} is not registered.";
|
||||
|
||||
return ApiError::response(
|
||||
'support_resource_not_found',
|
||||
'Not Found',
|
||||
$message,
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeDataExportPayload(Request $request, array $payload): array
|
||||
{
|
||||
$payload['user_id'] = $request->user()?->id;
|
||||
$payload['status'] = DataExport::STATUS_PENDING;
|
||||
|
||||
if (($payload['scope'] ?? null) !== DataExportScope::EVENT->value) {
|
||||
$payload['event_id'] = null;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantAddPackageRequest;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantScheduleDeletionRequest;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantSetGracePeriodRequest;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantUpdateLimitsRequest;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantUpdateSubscriptionRequest;
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\InactiveTenantDeletionWarning;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Services\Tenant\TenantLifecycleLogger;
|
||||
use App\Support\SupportApiAuthorizer;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
|
||||
class SupportTenantActionsController extends Controller
|
||||
{
|
||||
public function activate(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$updated = $tenant->update(['is_active' => true]);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'activated',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.activated',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => $updated]);
|
||||
}
|
||||
|
||||
public function deactivate(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$updated = $tenant->update(['is_active' => false]);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'deactivated',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.deactivated',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => $updated]);
|
||||
}
|
||||
|
||||
public function suspend(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$updated = $tenant->update(['is_suspended' => true]);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'suspended',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.suspended',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => $updated]);
|
||||
}
|
||||
|
||||
public function unsuspend(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$updated = $tenant->update(['is_suspended' => false]);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'unsuspended',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.unsuspended',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => $updated]);
|
||||
}
|
||||
|
||||
public function scheduleDeletion(SupportTenantScheduleDeletionRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$plannedDeletion = Carbon::parse($request->string('pending_deletion_at')->value());
|
||||
$update = [
|
||||
'pending_deletion_at' => $plannedDeletion,
|
||||
];
|
||||
|
||||
if ($request->boolean('send_warning', true)) {
|
||||
$email = $tenant->contact_email
|
||||
?? $tenant->email
|
||||
?? $tenant->user?->email;
|
||||
|
||||
if ($email) {
|
||||
NotificationFacade::route('mail', $email)
|
||||
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
|
||||
$update['deletion_warning_sent_at'] = now();
|
||||
} else {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title(__('admin.tenants.actions.send_warning_missing_title'))
|
||||
->body(__('admin.tenants.actions.send_warning_missing_body'))
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
$tenant->forceFill($update)->save();
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'deletion_scheduled',
|
||||
[
|
||||
'pending_deletion_at' => $plannedDeletion->toDateTimeString(),
|
||||
'send_warning' => $request->boolean('send_warning', true),
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.deletion_scheduled',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function cancelDeletion(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$previous = $tenant->pending_deletion_at?->toDateTimeString();
|
||||
|
||||
$tenant->forceFill([
|
||||
'pending_deletion_at' => null,
|
||||
'deletion_warning_sent_at' => null,
|
||||
])->save();
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'deletion_cancelled',
|
||||
['pending_deletion_at' => $previous],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.deletion_cancelled',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['pending_deletion_at', 'deletion_warning_sent_at']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function anonymize(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
AnonymizeAccount::dispatch(null, $tenant->id);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'anonymize_requested',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.anonymize_requested',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata([]),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function addPackage(SupportTenantAddPackageRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$package = Package::query()->find($request->integer('package_id'));
|
||||
|
||||
TenantPackage::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $request->integer('package_id'),
|
||||
'expires_at' => $request->date('expires_at'),
|
||||
'active' => true,
|
||||
'price' => $package?->price ?? 0,
|
||||
'reason' => $request->string('reason')->value(),
|
||||
]);
|
||||
|
||||
PackagePurchase::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $request->integer('package_id'),
|
||||
'provider' => 'manual',
|
||||
'provider_id' => 'manual',
|
||||
'type' => 'reseller_subscription',
|
||||
'price' => 0,
|
||||
'metadata' => ['reason' => $request->string('reason')->value() ?: 'manual assignment'],
|
||||
]);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.package_added',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateLimits(SupportTenantUpdateLimitsRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$before = [
|
||||
'max_photos_per_event' => $tenant->max_photos_per_event,
|
||||
'max_storage_mb' => $tenant->max_storage_mb,
|
||||
];
|
||||
|
||||
$tenant->forceFill([
|
||||
'max_photos_per_event' => $request->integer('max_photos_per_event'),
|
||||
'max_storage_mb' => $request->integer('max_storage_mb'),
|
||||
])->save();
|
||||
|
||||
$after = [
|
||||
'max_photos_per_event' => $tenant->max_photos_per_event,
|
||||
'max_storage_mb' => $tenant->max_storage_mb,
|
||||
];
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'limits_updated',
|
||||
[
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'note' => $request->string('note')->value(),
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.limits_updated',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateSubscriptionExpiresAt(SupportTenantUpdateSubscriptionRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$before = [
|
||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
|
||||
];
|
||||
|
||||
$tenant->forceFill([
|
||||
'subscription_expires_at' => $request->date('subscription_expires_at'),
|
||||
])->save();
|
||||
|
||||
$after = [
|
||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
|
||||
];
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'subscription_expires_at_updated',
|
||||
[
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'note' => $request->string('note')->value(),
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.subscription_expires_at_updated',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function setGracePeriod(SupportTenantSetGracePeriodRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$tenant->forceFill([
|
||||
'grace_period_ends_at' => $request->date('grace_period_ends_at'),
|
||||
])->save();
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'grace_period_set',
|
||||
[
|
||||
'grace_period_ends_at' => optional($tenant->grace_period_ends_at)->toDateTimeString(),
|
||||
'note' => $request->string('note')->value(),
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.grace_period_set',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function clearGracePeriod(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$previous = $tenant->grace_period_ends_at?->toDateTimeString();
|
||||
|
||||
$tenant->forceFill([
|
||||
'grace_period_ends_at' => null,
|
||||
])->save();
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'grace_period_cleared',
|
||||
[
|
||||
'grace_period_ends_at' => $previous,
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.grace_period_cleared',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['grace_period_ends_at']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function authorizeAction(string $resource, string $action): ?JsonResponse
|
||||
{
|
||||
return SupportApiAuthorizer::authorizeResource(request(), $resource, $action);
|
||||
}
|
||||
}
|
||||
103
app/Http/Controllers/Api/Support/SupportTokenController.php
Normal file
103
app/Http/Controllers/Api/Support/SupportTokenController.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\SupportTokenRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SupportTokenController extends Controller
|
||||
{
|
||||
public function store(SupportTokenRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->credentials();
|
||||
|
||||
$query = User::query();
|
||||
|
||||
if (isset($credentials['email'])) {
|
||||
$query->where('email', $credentials['email']);
|
||||
}
|
||||
|
||||
if (isset($credentials['username'])) {
|
||||
$query->where('username', $credentials['username']);
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = $query->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.failed')],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
|
||||
$tokenConfig = config('support-api.token');
|
||||
$defaultAbilities = $tokenConfig['default_abilities'] ?? [];
|
||||
$abilities = $credentials['abilities'] ?? $defaultAbilities;
|
||||
|
||||
if ($abilities !== $defaultAbilities) {
|
||||
$abilities = array_values(array_intersect($abilities, $defaultAbilities));
|
||||
}
|
||||
|
||||
if (! in_array('support-admin', $abilities, true)) {
|
||||
$abilities[] = 'support-admin';
|
||||
}
|
||||
|
||||
$tokenName = (string) ($tokenConfig['name'] ?? 'support-api');
|
||||
|
||||
$user->tokens()->where('name', $tokenName)->delete();
|
||||
|
||||
$token = $user->createToken($tokenName, $abilities);
|
||||
|
||||
return response()->json([
|
||||
'token' => $token->plainTextToken,
|
||||
'token_type' => 'Bearer',
|
||||
'abilities' => $abilities,
|
||||
'user' => Arr::only($user->toArray(), [
|
||||
'id',
|
||||
'email',
|
||||
'name',
|
||||
'role',
|
||||
'tenant_id',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$token = $request->user()?->currentAccessToken();
|
||||
|
||||
if ($token) {
|
||||
$token->delete();
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'user' => $user ? Arr::only($user->toArray(), [
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'role',
|
||||
'tenant_id',
|
||||
]) : null,
|
||||
'abilities' => $user?->currentAccessToken()?->abilities ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\SupportWatermarkSettingsRequest;
|
||||
use App\Models\WatermarkSetting;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Support\SupportApiAuthorizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SupportWatermarkSettingsController extends Controller
|
||||
{
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = WatermarkSetting::query()->first();
|
||||
|
||||
return response()->json([
|
||||
'data' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(SupportWatermarkSettingsRequest $request): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = WatermarkSetting::query()->firstOrNew([]);
|
||||
$settings->fill($request->validated());
|
||||
$settings->save();
|
||||
|
||||
$changed = $settings->getChanges();
|
||||
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'watermark_settings.updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $settings->refresh(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class EventAddonCatalogController extends Controller
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$addons = collect($this->catalog->all())
|
||||
->filter(fn (array $addon) => ! empty($addon['price_id']))
|
||||
->filter(fn (array $addon) => ! empty($addon['variant_id']))
|
||||
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
@@ -161,11 +161,13 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$resolvedName = $this->resolveEventNameString($validated['name']);
|
||||
$eventData = array_merge($validated, [
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => $validated['status'] ?? 'draft',
|
||||
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
|
||||
'slug' => $this->generateUniqueSlug($resolvedName, $tenantId),
|
||||
]);
|
||||
$eventData['name'] = $this->normalizeEventName($validated['name']);
|
||||
|
||||
if (isset($eventData['event_date'])) {
|
||||
$eventData['date'] = $eventData['event_date'];
|
||||
@@ -228,7 +230,7 @@ class EventController extends Controller
|
||||
]);
|
||||
|
||||
if ($billingIsReseller && ! $isSuperAdmin) {
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $this->resolveEventNameString($event->name));
|
||||
|
||||
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
|
||||
throw new HttpException(402, 'Insufficient package allowance.');
|
||||
@@ -404,9 +406,13 @@ class EventController extends Controller
|
||||
unset($validated['event_date']);
|
||||
}
|
||||
|
||||
if ($nameProvided && $validated['name'] !== $event->name) {
|
||||
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
||||
$currentName = $this->resolveEventNameString($event->name);
|
||||
$nextName = $this->resolveEventNameString($validated['name']);
|
||||
|
||||
if ($nameProvided && $nextName !== $currentName) {
|
||||
$validated['slug'] = $this->generateUniqueSlug($nextName, $tenantId, $event->id);
|
||||
}
|
||||
$validated['name'] = $this->normalizeEventName($validated['name']);
|
||||
|
||||
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
|
||||
unset($validated[$unused]);
|
||||
@@ -935,6 +941,45 @@ class EventController extends Controller
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $name
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeEventName(mixed $name): array
|
||||
{
|
||||
if (is_array($name)) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$value = is_string($name) ? trim($name) : '';
|
||||
|
||||
return ['de' => $value];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $name
|
||||
*/
|
||||
private function resolveEventNameString(mixed $name): string
|
||||
{
|
||||
if (is_array($name)) {
|
||||
$candidates = [
|
||||
$name['de'] ?? null,
|
||||
$name['en'] ?? null,
|
||||
reset($name) ?: null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
return is_string($name) ? $name : '';
|
||||
}
|
||||
|
||||
public function search(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
@@ -22,7 +22,14 @@ class EventJoinTokenLayoutController extends Controller
|
||||
*/
|
||||
private const BACKGROUND_PRESETS = [
|
||||
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
|
||||
'bg-artdeco' => 'storage/layouts/backgrounds-portrait/bg-artdeco.png',
|
||||
'bg-eukalyptus-floral' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-floral.png',
|
||||
'bg-eukalyptus-rahmen' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-rahmen.png',
|
||||
'bg-eukalyptus' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus.png',
|
||||
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
|
||||
'bg-jugendstil' => 'storage/layouts/backgrounds-portrait/bg-jugendstil.png',
|
||||
'bg-kornblumen' => 'storage/layouts/backgrounds-portrait/bg-kornblumen.png',
|
||||
'bg-kornblumen2' => 'storage/layouts/backgrounds-portrait/bg-kornblumen2.png',
|
||||
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
|
||||
];
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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;
|
||||
@@ -115,6 +116,7 @@ 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(
|
||||
@@ -321,7 +323,7 @@ class PhotoController extends Controller
|
||||
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
||||
|
||||
// Generate unique filename
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$extension = $this->resolvePhotoExtension($file);
|
||||
$filename = Str::uuid().'.'.$extension;
|
||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||
|
||||
@@ -563,6 +565,7 @@ 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(
|
||||
@@ -779,6 +782,7 @@ 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')
|
||||
@@ -1043,4 +1047,23 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleCustomerPortalService;
|
||||
use App\Services\Paddle\PaddleCustomerService;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -16,9 +15,8 @@ use Illuminate\Support\Facades\Log;
|
||||
class TenantBillingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaddleTransactionService $paddleTransactions,
|
||||
private readonly PaddleCustomerService $paddleCustomers,
|
||||
private readonly PaddleCustomerPortalService $portalSessions,
|
||||
private readonly LemonSqueezyOrderService $orders,
|
||||
private readonly LemonSqueezySubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
public function transactions(Request $request): JsonResponse
|
||||
@@ -32,20 +30,15 @@ class TenantBillingController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Failed to resolve Paddle customer.',
|
||||
], 502);
|
||||
}
|
||||
if (! $tenant->lemonsqueezy_customer_id) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'meta' => [
|
||||
'next' => null,
|
||||
'previous' => null,
|
||||
'has_more' => false,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$cursor = $request->query('cursor');
|
||||
@@ -60,16 +53,16 @@ class TenantBillingController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query);
|
||||
$result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to load Paddle transactions', [
|
||||
Log::warning('Failed to load Lemon Squeezy transactions', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Failed to load Paddle transactions.',
|
||||
'message' => 'Failed to load Lemon Squeezy transactions.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
@@ -143,68 +136,64 @@ class TenantBillingController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
$customerId = null;
|
||||
$subscriptionId = null;
|
||||
|
||||
try {
|
||||
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
|
||||
$subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
|
||||
if (! $subscriptionId) {
|
||||
return response()->json([
|
||||
'message' => 'No active subscription found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
Log::debug('Creating Paddle customer portal session', [
|
||||
Log::debug('Fetching Lemon Squeezy subscription portal URL', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'paddle_customer_id' => $customerId,
|
||||
'paddle_environment' => config('paddle.environment'),
|
||||
'paddle_base_url' => config('paddle.base_url'),
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId,
|
||||
]);
|
||||
|
||||
$session = $this->portalSessions->createSession($customerId);
|
||||
$subscription = $this->subscriptions->retrieve($subscriptionId);
|
||||
} catch (\Throwable $exception) {
|
||||
$context = [
|
||||
'tenant_id' => $tenant->id,
|
||||
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
||||
'error' => $exception->getMessage(),
|
||||
'paddle_environment' => config('paddle.environment'),
|
||||
'paddle_base_url' => config('paddle.base_url'),
|
||||
];
|
||||
|
||||
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');
|
||||
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');
|
||||
}
|
||||
|
||||
Log::warning('Failed to create Paddle customer portal session', [
|
||||
Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
|
||||
...$context,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Failed to create Paddle customer portal session.',
|
||||
'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
$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');
|
||||
$url = $this->subscriptions->portalUrl($subscription)
|
||||
?? $this->subscriptions->updatePaymentMethodUrl($subscription);
|
||||
|
||||
if (! $url) {
|
||||
$sessionData = Arr::get($session, 'data');
|
||||
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls');
|
||||
$sessionData = Arr::get($subscription, 'data');
|
||||
$sessionUrls = Arr::get($subscription, 'attributes.urls');
|
||||
|
||||
Log::warning('Paddle customer portal session missing URL', [
|
||||
Log::warning('Lemon Squeezy subscription missing portal URL', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'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),
|
||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
||||
'subscription_keys' => array_keys($subscription),
|
||||
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
|
||||
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Paddle customer portal session missing URL.',
|
||||
'message' => 'Lemon Squeezy subscription missing portal URL.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,10 @@ class AuthenticatedSessionController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($candidate, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($candidate, '/')) {
|
||||
return $candidate;
|
||||
}
|
||||
@@ -170,7 +174,7 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -222,7 +226,7 @@ class AuthenticatedSessionController extends Controller
|
||||
$scheme = $parsed['scheme'] ?? null;
|
||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
||||
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
@@ -265,6 +269,15 @@ class AuthenticatedSessionController extends Controller
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
|
||||
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -15,8 +15,8 @@ use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
@@ -48,6 +48,9 @@ class CheckoutController extends Controller
|
||||
$googleStatus = session()->pull('checkout_google_status');
|
||||
$googleError = session()->pull('checkout_google_error');
|
||||
$googleProfile = session()->pull('checkout_google_profile');
|
||||
$facebookStatus = session()->pull('checkout_facebook_status');
|
||||
$facebookError = session()->pull('checkout_facebook_error');
|
||||
$facebookProfile = session()->pull('checkout_facebook_profile');
|
||||
|
||||
$packageOptions = Package::orderBy('price')->get()
|
||||
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
||||
@@ -66,9 +69,14 @@ class CheckoutController extends Controller
|
||||
'error' => $googleError,
|
||||
'profile' => $googleProfile,
|
||||
],
|
||||
'paddle' => [
|
||||
'environment' => config('paddle.environment'),
|
||||
'client_token' => config('paddle.client_token'),
|
||||
'facebookAuth' => [
|
||||
'status' => $facebookStatus,
|
||||
'error' => $facebookError,
|
||||
'profile' => $facebookProfile,
|
||||
],
|
||||
'lemonsqueezy' => [
|
||||
'store_id' => config('lemonsqueezy.store_id'),
|
||||
'test_mode' => config('lemonsqueezy.test_mode', false),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -263,9 +271,9 @@ class CheckoutController extends Controller
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
PaddleTransactionService $transactions,
|
||||
LemonSqueezyOrderService $orders,
|
||||
): JsonResponse {
|
||||
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
||||
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
||||
|
||||
$session->refresh();
|
||||
|
||||
@@ -280,56 +288,56 @@ class CheckoutController extends Controller
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
PaddleTransactionService $transactions,
|
||||
LemonSqueezyOrderService $orders,
|
||||
): JsonResponse {
|
||||
$validated = $request->validated();
|
||||
$transactionId = $validated['transaction_id'] ?? null;
|
||||
$orderId = $validated['order_id'] ?? null;
|
||||
$checkoutId = $validated['checkout_id'] ?? null;
|
||||
|
||||
$metadata = $session->provider_metadata ?? [];
|
||||
$metadataUpdated = false;
|
||||
|
||||
if ($transactionId) {
|
||||
$session->paddle_transaction_id = $transactionId;
|
||||
$metadata['paddle_transaction_id'] = $transactionId;
|
||||
if ($orderId) {
|
||||
$session->lemonsqueezy_order_id = $orderId;
|
||||
$metadata['lemonsqueezy_order_id'] = $orderId;
|
||||
$metadataUpdated = true;
|
||||
}
|
||||
|
||||
if ($checkoutId) {
|
||||
$metadata['paddle_checkout_id'] = $checkoutId;
|
||||
$metadata['lemonsqueezy_checkout_id'] = $checkoutId;
|
||||
$metadataUpdated = true;
|
||||
}
|
||||
|
||||
if ($metadataUpdated) {
|
||||
$metadata['paddle_client_event_at'] = now()->toIso8601String();
|
||||
$metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String();
|
||||
$session->provider_metadata = $metadata;
|
||||
$session->save();
|
||||
}
|
||||
|
||||
if (app()->environment('local')
|
||||
&& $session->provider === CheckoutSession::PROVIDER_PADDLE
|
||||
&& $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY
|
||||
&& ! in_array($session->status, [
|
||||
CheckoutSession::STATUS_COMPLETED,
|
||||
CheckoutSession::STATUS_FAILED,
|
||||
CheckoutSession::STATUS_CANCELLED,
|
||||
], true)
|
||||
&& ($transactionId || $checkoutId)
|
||||
&& ($orderId || $checkoutId)
|
||||
) {
|
||||
$sessions->markProcessing($session, array_filter([
|
||||
'paddle_status' => 'completed',
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_local_confirmed_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_status' => 'paid',
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(),
|
||||
]));
|
||||
|
||||
$assignment->finalise($session, [
|
||||
'source' => 'paddle_local',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $transactionId ?? $checkoutId,
|
||||
'source' => 'lemonsqueezy_local',
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
'provider_reference' => $orderId ?? $checkoutId,
|
||||
]);
|
||||
|
||||
$sessions->markCompleted($session);
|
||||
} else {
|
||||
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
||||
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
||||
}
|
||||
|
||||
$session->refresh();
|
||||
@@ -411,13 +419,13 @@ class CheckoutController extends Controller
|
||||
return $price <= 0;
|
||||
}
|
||||
|
||||
private function attemptPaddleRecovery(
|
||||
private function attemptLemonSqueezyRecovery(
|
||||
CheckoutSession $session,
|
||||
CheckoutSessionService $sessions,
|
||||
CheckoutAssignmentService $assignment,
|
||||
PaddleTransactionService $transactions
|
||||
LemonSqueezyOrderService $orders
|
||||
): void {
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -430,7 +438,7 @@ class CheckoutController extends Controller
|
||||
}
|
||||
|
||||
$metadata = $session->provider_metadata ?? [];
|
||||
$lastPollAt = $metadata['paddle_poll_at'] ?? null;
|
||||
$lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null;
|
||||
$now = now();
|
||||
|
||||
if ($lastPollAt) {
|
||||
@@ -444,39 +452,31 @@ class CheckoutController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null;
|
||||
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null;
|
||||
$checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null;
|
||||
$orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null;
|
||||
|
||||
if (! $checkoutId && ! $transactionId) {
|
||||
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [
|
||||
if (! $checkoutId && ! $orderId) {
|
||||
Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$metadata['paddle_poll_at'] = $now->toIso8601String();
|
||||
$metadata['lemonsqueezy_poll_at'] = $now->toIso8601String();
|
||||
$session->forceFill([
|
||||
'provider_metadata' => $metadata,
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null;
|
||||
$order = $orderId ? $orders->retrieve($orderId) : null;
|
||||
|
||||
if (! $transaction && $checkoutId) {
|
||||
$transaction = $transactions->findByCheckoutId($checkoutId);
|
||||
if (! $order && $checkoutId) {
|
||||
$order = $orders->findByCheckoutId($checkoutId);
|
||||
}
|
||||
|
||||
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', [
|
||||
} catch (LemonSqueezyException $exception) {
|
||||
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
'status' => $exception->status(),
|
||||
'message' => $exception->getMessage(),
|
||||
'context' => $exception->context(),
|
||||
@@ -484,77 +484,77 @@ class CheckoutController extends Controller
|
||||
|
||||
return;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('[Checkout] Paddle recovery failed', [
|
||||
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $transaction) {
|
||||
Log::info('[Checkout] Paddle recovery: transaction not found', [
|
||||
if (! $order) {
|
||||
Log::info('[Checkout] Lemon Squeezy recovery: order not found', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$status = strtolower((string) ($transaction['status'] ?? ''));
|
||||
$transactionId = $transactionId ?: ($transaction['id'] ?? null);
|
||||
$status = strtolower((string) data_get($order, 'attributes.status', ''));
|
||||
$resolvedOrderId = $orderId ?: data_get($order, 'id');
|
||||
|
||||
if ($transactionId && $session->paddle_transaction_id !== $transactionId) {
|
||||
if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) {
|
||||
$session->forceFill([
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'lemonsqueezy_order_id' => $resolvedOrderId,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($status === 'completed') {
|
||||
if (in_array($status, ['paid', 'completed'], true)) {
|
||||
$sessions->markProcessing($session, [
|
||||
'paddle_status' => $status,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_recovered_at' => $now->toIso8601String(),
|
||||
'lemonsqueezy_status' => $status,
|
||||
'lemonsqueezy_order_id' => $resolvedOrderId,
|
||||
'lemonsqueezy_recovered_at' => $now->toIso8601String(),
|
||||
]);
|
||||
|
||||
$assignment->finalise($session, [
|
||||
'source' => 'paddle_poll',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $transactionId,
|
||||
'payload' => $transaction,
|
||||
'source' => 'lemonsqueezy_poll',
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
'provider_reference' => $resolvedOrderId,
|
||||
'payload' => $order,
|
||||
]);
|
||||
|
||||
$sessions->markCompleted($session, $now);
|
||||
|
||||
Log::info('[Checkout] Paddle session recovered via API', [
|
||||
Log::info('[Checkout] Lemon Squeezy session recovered via API', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) {
|
||||
$sessions->markFailed($session, 'paddle_'.$status);
|
||||
if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) {
|
||||
$sessions->markFailed($session, 'lemonsqueezy_'.$status);
|
||||
|
||||
Log::info('[Checkout] Paddle transaction failed', [
|
||||
Log::info('[Checkout] Lemon Squeezy order failed', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('[Checkout] Paddle transaction pending', [
|
||||
Log::info('[Checkout] Lemon Squeezy order pending', [
|
||||
'session_id' => $session->id,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $resolvedOrderId,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
217
app/Http/Controllers/CheckoutFacebookController.php
Normal file
217
app/Http/Controllers/CheckoutFacebookController.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\LocaleConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class CheckoutFacebookController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'checkout_facebook_payload';
|
||||
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'locale' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'package_id' => (int) $validated['package_id'],
|
||||
'locale' => $validated['locale'] ?? app()->getLocale(),
|
||||
];
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, $payload);
|
||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
||||
|
||||
return Socialite::driver('facebook')
|
||||
->redirectUrl(route('checkout.facebook.callback'))
|
||||
->scopes(['email'])
|
||||
->fields(['name', 'email', 'first_name', 'last_name'])
|
||||
->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$payload = $request->session()->get(self::SESSION_KEY, []);
|
||||
$packageId = $payload['package_id'] ?? null;
|
||||
$locale = $payload['locale'] ?? null;
|
||||
|
||||
try {
|
||||
$facebookUser = Socialite::driver('facebook')->user();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Facebook checkout login failed', ['message' => $e->getMessage()]);
|
||||
$this->flashError($request, __('checkout.facebook_error_fallback'));
|
||||
|
||||
return $this->redirectBackToWizard($packageId, $locale);
|
||||
}
|
||||
|
||||
$email = $facebookUser->getEmail();
|
||||
if (! $email) {
|
||||
$this->flashError($request, __('checkout.facebook_missing_email'));
|
||||
|
||||
return $this->redirectBackToWizard($packageId, $locale);
|
||||
}
|
||||
|
||||
$raw = $facebookUser->getRaw();
|
||||
$givenName = $raw['first_name'] ?? null;
|
||||
$familyName = $raw['last_name'] ?? null;
|
||||
$request->session()->put('checkout_facebook_profile', array_filter([
|
||||
'email' => $email,
|
||||
'name' => $facebookUser->getName(),
|
||||
'given_name' => $givenName,
|
||||
'family_name' => $familyName,
|
||||
'avatar' => $facebookUser->getAvatar(),
|
||||
'locale' => $raw['locale'] ?? null,
|
||||
]));
|
||||
|
||||
$existing = User::where('email', $email)->first();
|
||||
|
||||
if (! $existing) {
|
||||
$request->session()->put('checkout_facebook_profile', array_filter([
|
||||
'email' => $email,
|
||||
'name' => $facebookUser->getName(),
|
||||
'given_name' => $givenName,
|
||||
'family_name' => $familyName,
|
||||
'avatar' => $facebookUser->getAvatar(),
|
||||
'locale' => $raw['locale'] ?? null,
|
||||
]));
|
||||
|
||||
$request->session()->put('checkout_facebook_status', 'prefill');
|
||||
|
||||
return $this->redirectBackToWizard($packageId, $locale);
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($existing, $facebookUser, $email) {
|
||||
$existing->forceFill([
|
||||
'name' => $facebookUser->getName() ?: $existing->name,
|
||||
'pending_purchase' => true,
|
||||
'email_verified_at' => $existing->email_verified_at ?? now(),
|
||||
])->save();
|
||||
|
||||
if (! $existing->tenant) {
|
||||
$this->createTenantForUser($existing, $facebookUser->getName(), $email);
|
||||
}
|
||||
|
||||
return $existing->fresh();
|
||||
});
|
||||
|
||||
if (! $user->tenant) {
|
||||
$this->createTenantForUser($user, $facebookUser->getName(), $email);
|
||||
}
|
||||
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
$request->session()->forget('checkout_facebook_profile');
|
||||
$request->session()->put('checkout_facebook_status', 'signin');
|
||||
|
||||
if ($packageId) {
|
||||
$this->ensurePackageAttached($user, (int) $packageId);
|
||||
}
|
||||
|
||||
return $this->redirectBackToWizard($packageId, $locale);
|
||||
}
|
||||
|
||||
private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant
|
||||
{
|
||||
$tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant';
|
||||
$slugBase = Str::slug($tenantName) ?: 'tenant';
|
||||
$slug = $slugBase;
|
||||
$counter = 1;
|
||||
|
||||
while (Tenant::where('slug', $slug)->exists()) {
|
||||
$slug = $slugBase.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $tenantName,
|
||||
'slug' => $slug,
|
||||
'email' => $email,
|
||||
'contact_email' => $email,
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_status' => 'free',
|
||||
'subscription_expires_at' => null,
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
'photo_likes_enabled' => false,
|
||||
'event_checklist' => false,
|
||||
'custom_domain' => false,
|
||||
'advanced_analytics' => false,
|
||||
],
|
||||
'custom_domain' => null,
|
||||
'contact_email' => $email,
|
||||
'event_default_type' => 'general',
|
||||
]),
|
||||
]);
|
||||
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function ensurePackageAttached(User $user, int $packageId): void
|
||||
{
|
||||
$tenant = $user->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$package = Package::find($packageId);
|
||||
if (! $package) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tenant->packages()->where('package_id', $packageId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant->packages()->attach($packageId, [
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'active' => $package->price <= 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectBackToWizard(?int $packageId, ?string $locale = null): RedirectResponse
|
||||
{
|
||||
if ($packageId) {
|
||||
return redirect()->to(CheckoutRoutes::wizardUrl($packageId, $locale));
|
||||
}
|
||||
|
||||
$firstPackageId = Package::query()->orderBy('price')->value('id');
|
||||
if ($firstPackageId) {
|
||||
return redirect()->to(CheckoutRoutes::wizardUrl($firstPackageId, $locale));
|
||||
}
|
||||
|
||||
return redirect()->route('packages', [
|
||||
'locale' => LocaleConfig::canonicalize($locale ?? app()->getLocale()),
|
||||
]);
|
||||
}
|
||||
|
||||
private function flashError(Request $request, string $message): void
|
||||
{
|
||||
$request->session()->flash('checkout_facebook_error', $message);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class CheckoutGoogleController extends Controller
|
||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
||||
|
||||
return Socialite::driver('google')
|
||||
->redirectUrl(route('checkout.google.callback'))
|
||||
->scopes(['email', 'profile'])
|
||||
->with(['prompt' => 'select_account'])
|
||||
->redirect();
|
||||
|
||||
@@ -2,27 +2,27 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
|
||||
use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PaddleCheckoutController extends Controller
|
||||
class LemonSqueezyCheckoutController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $checkout,
|
||||
private readonly LemonSqueezyCheckoutService $checkout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function create(PaddleCheckoutRequest $request): JsonResponse
|
||||
public function create(LemonSqueezyCheckoutRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
@@ -35,8 +35,8 @@ class PaddleCheckoutController extends Controller
|
||||
|
||||
$package = Package::findOrFail((int) $data['package_id']);
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||
@@ -46,7 +46,7 @@ class PaddleCheckoutController extends Controller
|
||||
]
|
||||
));
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -59,44 +59,10 @@ class PaddleCheckoutController 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 ($request->boolean('inline') && $discountId === null) {
|
||||
$metadata = array_merge($session->provider_metadata ?? [], [
|
||||
'mode' => 'inline',
|
||||
]);
|
||||
|
||||
$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, [
|
||||
@@ -108,15 +74,17 @@ class PaddleCheckoutController extends Controller
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
],
|
||||
'discount_id' => $discountId,
|
||||
'discount_code' => $couponCode ?: null,
|
||||
'customer_email' => $user?->email,
|
||||
'customer_name' => trim(($user?->first_name ?? '').' '.($user?->last_name ?? '')) ?: ($user?->name ?? null),
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
||||
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
@@ -2,35 +2,32 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleReturnController extends Controller
|
||||
class LemonSqueezyReturnController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PaddleTransactionService $transactions) {}
|
||||
public function __construct(private readonly LemonSqueezyOrderService $orders) {}
|
||||
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
$transactionId = $this->resolveTransactionId($request);
|
||||
$orderId = $this->resolveOrderId($request);
|
||||
$fallback = $this->resolveFallbackUrl();
|
||||
|
||||
if (! $transactionId) {
|
||||
if (! $orderId) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
try {
|
||||
$transaction = $this->transactions->retrieve($transactionId);
|
||||
} catch (PaddleException $exception) {
|
||||
Log::warning('Paddle return failed to load transaction', [
|
||||
'transaction_id' => $transactionId,
|
||||
$order = $this->orders->retrieve($orderId);
|
||||
} catch (LemonSqueezyException $exception) {
|
||||
Log::warning('Lemon Squeezy return failed to load order', [
|
||||
'order_id' => $orderId,
|
||||
'error' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
]);
|
||||
@@ -38,10 +35,10 @@ class PaddleReturnController extends Controller
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
$customData = $this->extractCustomData($transaction);
|
||||
$status = Str::lower((string) ($transaction['status'] ?? ''));
|
||||
$customData = $this->extractCustomData($order);
|
||||
$status = Str::lower((string) Arr::get($order, 'attributes.status', ''));
|
||||
$successUrl = $customData['success_url'] ?? null;
|
||||
$cancelUrl = $customData['cancel_url'] ?? $customData['return_url'] ?? null;
|
||||
$cancelUrl = $customData['return_url'] ?? null;
|
||||
|
||||
$target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl;
|
||||
$target = $this->resolveSafeRedirect($target, $fallback);
|
||||
@@ -49,11 +46,10 @@ class PaddleReturnController extends Controller
|
||||
return redirect()->to($target);
|
||||
}
|
||||
|
||||
protected function resolveTransactionId(Request $request): ?string
|
||||
protected function resolveOrderId(Request $request): ?string
|
||||
{
|
||||
$candidate = $request->query('_ptxn')
|
||||
?? $request->query('ptxn')
|
||||
?? $request->query('transaction_id');
|
||||
$candidate = $request->query('order_id')
|
||||
?? $request->query('order');
|
||||
|
||||
if (! is_string($candidate) || $candidate === '') {
|
||||
return null;
|
||||
@@ -68,33 +64,19 @@ class PaddleReturnController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $transaction
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractCustomData(array $transaction): array
|
||||
protected function extractCustomData(array $order): array
|
||||
{
|
||||
$customData = Arr::get($transaction, 'custom_data', []);
|
||||
$customData = Arr::get($order, 'attributes.custom_data', []);
|
||||
|
||||
if (! is_array($customData)) {
|
||||
$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;
|
||||
return is_array($customData) ? $customData : [];
|
||||
}
|
||||
|
||||
protected function isSuccessStatus(string $status): bool
|
||||
{
|
||||
return in_array($status, ['completed', 'paid'], true);
|
||||
return in_array($status, ['paid', 'completed'], true);
|
||||
}
|
||||
|
||||
protected function resolveSafeRedirect(?string $target, string $fallback): string
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaddleWebhookController extends Controller
|
||||
class LemonSqueezyWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutWebhookService $webhooks,
|
||||
@@ -22,7 +22,7 @@ class PaddleWebhookController extends Controller
|
||||
{
|
||||
try {
|
||||
if (! $this->verify($request)) {
|
||||
Log::warning('Paddle webhook signature verification failed');
|
||||
Log::warning('Lemon Squeezy webhook signature verification failed');
|
||||
|
||||
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
@@ -33,29 +33,27 @@ class PaddleWebhookController extends Controller
|
||||
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
|
||||
$eventType = $payload['meta']['event_name'] ?? $request->headers->get('X-Event-Name');
|
||||
$eventId = $payload['meta']['event_id'] ?? $payload['data']['id'] ?? null;
|
||||
$webhookEvent = $this->recorder->recordReceived(
|
||||
'paddle',
|
||||
'lemonsqueezy',
|
||||
$eventId ? (string) $eventId : null,
|
||||
$eventType ? (string) $eventType : null,
|
||||
);
|
||||
$handled = false;
|
||||
|
||||
$this->logDev('Paddle webhook received', [
|
||||
$this->logDev('Lemon Squeezy webhook received', [
|
||||
'event_type' => $eventType,
|
||||
'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', '') !== '',
|
||||
'order_id' => data_get($payload, 'data.id'),
|
||||
'has_signature' => (string) $request->headers->get('X-Signature', '') !== '',
|
||||
]);
|
||||
|
||||
if ($eventType) {
|
||||
$handled = $this->webhooks->handlePaddleEvent($payload);
|
||||
$handled = $this->webhooks->handleLemonSqueezyEvent($payload);
|
||||
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
||||
}
|
||||
|
||||
Log::info('Paddle webhook processed', [
|
||||
Log::info('Lemon Squeezy webhook processed', [
|
||||
'event_type' => $eventType,
|
||||
'handled' => $handled,
|
||||
]);
|
||||
@@ -71,13 +69,13 @@ class PaddleWebhookController extends Controller
|
||||
} catch (\Throwable $exception) {
|
||||
$eventId = $this->captureWebhookException($exception);
|
||||
|
||||
Log::error('Paddle webhook processing failed', [
|
||||
Log::error('Lemon Squeezy webhook processing failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
'event_type' => (string) $request->json('event_type'),
|
||||
'event_type' => (string) data_get($request->json()->all(), 'meta.event_name'),
|
||||
'sentry_event_id' => $eventId,
|
||||
]);
|
||||
|
||||
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
|
||||
$this->logDev('Lemon Squeezy webhook error payload', $this->reducePayload($request->json()->all()));
|
||||
|
||||
if (isset($webhookEvent)) {
|
||||
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
|
||||
@@ -89,85 +87,33 @@ class PaddleWebhookController extends Controller
|
||||
|
||||
protected function verify(Request $request): bool
|
||||
{
|
||||
$secret = config('paddle.webhook_secret');
|
||||
$secret = config('lemonsqueezy.webhook_secret');
|
||||
|
||||
if (! $secret) {
|
||||
// Allow processing in sandbox or when secret not configured
|
||||
return true;
|
||||
}
|
||||
|
||||
$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', '');
|
||||
$signature = (string) $request->headers->get('X-Signature', '');
|
||||
|
||||
if ($signature === '') {
|
||||
$this->logDev('Paddle webhook missing signature header', [
|
||||
'header' => 'Paddle-Webhook-Signature',
|
||||
$this->logDev('Lemon Squeezy webhook missing signature header', [
|
||||
'header' => 'X-Signature',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$expected = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$valid = hash_equals($expected, $signature);
|
||||
if (! $valid) {
|
||||
$this->logDev('Paddle webhook signature mismatch (legacy)', []);
|
||||
$this->logDev('Lemon Squeezy webhook signature mismatch', []);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -177,7 +123,7 @@ class PaddleWebhookController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('[PaddleWebhook] '.$message, $context);
|
||||
Log::info('[LemonSqueezyWebhook] '.$message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,12 +132,11 @@ class PaddleWebhookController extends Controller
|
||||
protected function reducePayload(array $payload): array
|
||||
{
|
||||
return array_filter([
|
||||
'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')),
|
||||
'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')),
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
||||
use App\Support\CheckoutRequestContext;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
@@ -41,7 +41,7 @@ class MarketingController extends Controller
|
||||
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $checkoutSessions,
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout,
|
||||
private readonly CouponService $coupons,
|
||||
private readonly GiftVoucherCheckoutService $giftVouchers,
|
||||
) {}
|
||||
@@ -64,7 +64,6 @@ class MarketingController extends Controller
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'message' => 'required|string|max:1000',
|
||||
'nickname' => 'present|size:0',
|
||||
]);
|
||||
|
||||
$locale = app()->getLocale();
|
||||
@@ -195,14 +194,14 @@ 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]);
|
||||
if (! $package->lemonsqueezy_variant_id) {
|
||||
Log::warning('Package missing Lemon Squeezy variant id', ['package_id' => $package->id]);
|
||||
|
||||
return redirect()->route('packages', [
|
||||
'locale' => app()->getLocale(),
|
||||
'highlight' => $package->slug,
|
||||
])
|
||||
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||
->with('error', __('marketing.packages.lemonsqueezy_not_configured'));
|
||||
}
|
||||
|
||||
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
||||
@@ -212,7 +211,7 @@ class MarketingController extends Controller
|
||||
]
|
||||
));
|
||||
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
||||
|
||||
$now = now();
|
||||
|
||||
@@ -224,20 +223,17 @@ 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'));
|
||||
}
|
||||
}
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||
$checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [
|
||||
'success_url' => route('marketing.success', [
|
||||
'locale' => app()->getLocale(),
|
||||
'packageId' => $package->id,
|
||||
@@ -253,15 +249,15 @@ class MarketingController extends Controller
|
||||
'accepted_terms' => (bool) $session->accepted_terms_at,
|
||||
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
|
||||
],
|
||||
'discount_id' => $appliedDiscountId,
|
||||
'discount_code' => $couponCode,
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
||||
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
@@ -269,7 +265,7 @@ class MarketingController extends Controller
|
||||
|
||||
if (! $redirectUrl) {
|
||||
throw ValidationException::withMessages([
|
||||
'paddle' => __('marketing.packages.paddle_checkout_failed'),
|
||||
'lemonsqueezy' => __('marketing.packages.lemonsqueezy_checkout_failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
129
app/Http/Controllers/TenantAdminFacebookController.php
Normal file
129
app/Http/Controllers/TenantAdminFacebookController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Throwable;
|
||||
|
||||
class TenantAdminFacebookController extends Controller
|
||||
{
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$returnTo = $request->query('return_to');
|
||||
if (is_string($returnTo) && $returnTo !== '') {
|
||||
$request->session()->put('tenant_oauth_return_to', $returnTo);
|
||||
}
|
||||
|
||||
return Socialite::driver('facebook')
|
||||
->redirectUrl(route('tenant.admin.facebook.callback'))
|
||||
->scopes(['email'])
|
||||
->fields(['name', 'email', 'first_name', 'last_name'])
|
||||
->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$facebookUser = Socialite::driver('facebook')->user();
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Tenant admin Facebook sign-in failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->sendBackWithError($request, 'facebook_failed', 'Unable to complete Facebook sign-in.');
|
||||
}
|
||||
|
||||
$email = $facebookUser->getEmail();
|
||||
if (! $email) {
|
||||
return $this->sendBackWithError($request, 'facebook_failed', 'Facebook account did not provide an email address.');
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
return $this->sendBackWithError($request, 'facebook_no_match', 'No tenant admin account is linked to this Facebook address.');
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'name' => $facebookUser->getName() ?: $user->name,
|
||||
'email_verified_at' => $user->email_verified_at ?? now(),
|
||||
])->save();
|
||||
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
$returnTo = $request->session()->pull('tenant_oauth_return_to');
|
||||
if (is_string($returnTo)) {
|
||||
$decoded = $this->decodeReturnTo($returnTo, $request);
|
||||
if ($decoded) {
|
||||
return redirect()->to($decoded);
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = $request->session()->pull('tenant_admin.return_to');
|
||||
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
return redirect()->to('/event-admin/dashboard');
|
||||
}
|
||||
|
||||
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
||||
{
|
||||
$query = [
|
||||
'error' => $code,
|
||||
'error_description' => $message,
|
||||
];
|
||||
|
||||
if ($request->session()->has('tenant_oauth_return_to')) {
|
||||
$query['return_to'] = $request->session()->get('tenant_oauth_return_to');
|
||||
}
|
||||
|
||||
return redirect()->route('tenant.admin.login', $query);
|
||||
}
|
||||
|
||||
private function decodeReturnTo(string $encoded, Request $request): ?string
|
||||
{
|
||||
$padded = str_pad($encoded, strlen($encoded) + ((4 - (strlen($encoded) % 4)) % 4), '=');
|
||||
$normalized = strtr($padded, '-_', '+/');
|
||||
$decoded = base64_decode($normalized);
|
||||
|
||||
if (! is_string($decoded) || $decoded === '') {
|
||||
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)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class TenantAdminGoogleController extends Controller
|
||||
}
|
||||
|
||||
return Socialite::driver('google')
|
||||
->redirectUrl(route('tenant.admin.google.callback'))
|
||||
->scopes(['openid', 'profile', 'email'])
|
||||
->with(['prompt' => 'select_account'])
|
||||
->redirect();
|
||||
@@ -57,6 +58,7 @@ class TenantAdminGoogleController extends Controller
|
||||
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
$returnTo = $request->session()->pull('tenant_oauth_return_to');
|
||||
if (is_string($returnTo)) {
|
||||
@@ -66,7 +68,12 @@ class TenantAdminGoogleController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->intended('/event-admin/dashboard');
|
||||
$fallback = $request->session()->pull('tenant_admin.return_to');
|
||||
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
return redirect()->to('/event-admin/dashboard');
|
||||
}
|
||||
|
||||
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
||||
@@ -93,13 +100,30 @@ 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 && ! Str::endsWith($targetHost, $appHost)) {
|
||||
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class TestCheckoutController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function simulatePaddle(
|
||||
public function simulateLemonSqueezy(
|
||||
Request $request,
|
||||
CheckoutWebhookService $webhooks,
|
||||
CheckoutSession $session
|
||||
@@ -70,13 +70,13 @@ class TestCheckoutController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'event_type' => ['nullable', 'string'],
|
||||
'transaction_id' => ['nullable', 'string'],
|
||||
'order_id' => ['nullable', 'string'],
|
||||
'status' => ['nullable', 'string'],
|
||||
'checkout_id' => ['nullable', 'string'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$eventType = $validated['event_type'] ?? 'transaction.completed';
|
||||
$eventType = $validated['event_type'] ?? 'order_created';
|
||||
$metadata = array_merge([
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'package_id' => $session->package_id,
|
||||
@@ -84,16 +84,21 @@ class TestCheckoutController extends Controller
|
||||
], $validated['metadata'] ?? []);
|
||||
|
||||
$payload = [
|
||||
'event_type' => $eventType,
|
||||
'data' => array_filter([
|
||||
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
|
||||
'status' => $validated['status'] ?? 'completed',
|
||||
'meta' => [
|
||||
'event_name' => $eventType,
|
||||
'custom_data' => $metadata,
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
],
|
||||
'data' => array_filter([
|
||||
'id' => $validated['order_id'] ?? ('order_'.Str::uuid()),
|
||||
'attributes' => array_filter([
|
||||
'status' => $validated['status'] ?? 'paid',
|
||||
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['lemonsqueezy_checkout_id'] ?? 'chk_'.Str::uuid(),
|
||||
'custom_data' => $metadata,
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
$handled = $webhooks->handlePaddleEvent($payload);
|
||||
$handled = $webhooks->handleLemonSqueezyEvent($payload);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Models\EventPackage;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\Customer\WithdrawalConfirmed;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
||||
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,
|
||||
PaddleTransactionService $transactions,
|
||||
LemonSqueezyOrderService $orders,
|
||||
string $locale
|
||||
): RedirectResponse {
|
||||
$user = $request->user();
|
||||
@@ -60,10 +60,10 @@ class WithdrawalController extends Controller
|
||||
->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale));
|
||||
}
|
||||
|
||||
$transactionId = $this->resolveTransactionId($purchase);
|
||||
$orderId = $this->resolveOrderId($purchase);
|
||||
|
||||
if (! $transactionId) {
|
||||
Log::warning('Withdrawal missing Paddle transaction reference.', [
|
||||
if (! $orderId) {
|
||||
Log::warning('Withdrawal missing Lemon Squeezy order reference.', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'provider' => $purchase->provider,
|
||||
]);
|
||||
@@ -74,11 +74,11 @@ class WithdrawalController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$transactions->refund($transactionId, ['reason' => 'withdrawal']);
|
||||
$orders->refund($orderId, ['reason' => 'withdrawal']);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Withdrawal refund failed', [
|
||||
'purchase_id' => $purchase->id,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
@@ -94,13 +94,13 @@ class WithdrawalController extends Controller
|
||||
$withdrawalMeta = array_merge($withdrawalMeta, [
|
||||
'confirmed_at' => $confirmedAt->toIso8601String(),
|
||||
'confirmed_by' => $user?->id,
|
||||
'transaction_id' => $transactionId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
|
||||
$metadata['withdrawal'] = $withdrawalMeta;
|
||||
|
||||
$purchase->forceFill([
|
||||
'provider_id' => $transactionId,
|
||||
'provider_id' => $orderId,
|
||||
'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', 'paddle')
|
||||
->where('provider', 'lemonsqueezy')
|
||||
->where('refunded', false)
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
@@ -151,7 +151,7 @@ class WithdrawalController extends Controller
|
||||
$reasons[] = 'type';
|
||||
}
|
||||
|
||||
if ($purchase->provider !== 'paddle') {
|
||||
if ($purchase->provider !== 'lemonsqueezy') {
|
||||
$reasons[] = 'provider';
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class WithdrawalController extends Controller
|
||||
$reasons[] = 'refunded';
|
||||
}
|
||||
|
||||
if (! $this->resolveTransactionId($purchase)) {
|
||||
if (! $this->resolveOrderId($purchase)) {
|
||||
$reasons[] = 'missing_reference';
|
||||
}
|
||||
|
||||
@@ -224,13 +224,13 @@ class WithdrawalController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveTransactionId(PackagePurchase $purchase): ?string
|
||||
private function resolveOrderId(PackagePurchase $purchase): ?string
|
||||
{
|
||||
if ($purchase->provider === 'paddle' && $purchase->provider_id) {
|
||||
if ($purchase->provider === 'lemonsqueezy' && $purchase->provider_id) {
|
||||
return (string) $purchase->provider_id;
|
||||
}
|
||||
|
||||
return data_get($purchase->metadata, 'paddle_transaction_id');
|
||||
return data_get($purchase->metadata, 'lemonsqueezy_order_id');
|
||||
}
|
||||
|
||||
private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void
|
||||
|
||||
@@ -37,7 +37,7 @@ class ContentSecurityPolicy
|
||||
$scriptSources = [
|
||||
"'self'",
|
||||
"'nonce-{$scriptNonce}'",
|
||||
'https://cdn.paddle.com',
|
||||
'https://app.lemonsqueezy.com',
|
||||
'https://global.localizecdn.com',
|
||||
];
|
||||
|
||||
@@ -49,21 +49,16 @@ class ContentSecurityPolicy
|
||||
|
||||
$connectSources = [
|
||||
"'self'",
|
||||
'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://api.lemonsqueezy.com',
|
||||
'https://app.lemonsqueezy.com',
|
||||
'https://fotospiel.lemonsqueezy.com',
|
||||
'https://global.localizecdn.com',
|
||||
];
|
||||
|
||||
$frameSources = [
|
||||
"'self'",
|
||||
'https://checkout.paddle.com',
|
||||
'https://sandbox-checkout.paddle.com',
|
||||
'https://checkout-service.paddle.com',
|
||||
'https://sandbox-checkout-service.paddle.com',
|
||||
'https://app.lemonsqueezy.com',
|
||||
'https://fotospiel.lemonsqueezy.com',
|
||||
];
|
||||
|
||||
$imgSources = [
|
||||
@@ -118,11 +113,18 @@ class ContentSecurityPolicy
|
||||
$styleSources[] = 'data:';
|
||||
$connectSources[] = 'https:';
|
||||
$fontSources[] = 'https:';
|
||||
$styleElemSources = array_values(array_filter(
|
||||
$styleSources,
|
||||
static fn (string $source): bool => ! str_starts_with($source, "'nonce-")
|
||||
));
|
||||
$styleElemSources = array_unique(array_merge($styleElemSources, ["'unsafe-inline'"]));
|
||||
|
||||
$directives = [
|
||||
'default-src' => ["'self'"],
|
||||
'script-src' => array_unique($scriptSources),
|
||||
'style-src' => array_unique($styleSources),
|
||||
'style-src-elem' => $styleElemSources,
|
||||
'style-src-attr' => ["'unsafe-inline'"],
|
||||
'img-src' => array_unique($imgSources),
|
||||
'font-src' => array_unique($fontSources),
|
||||
'connect-src' => array_unique($connectSources),
|
||||
|
||||
66
app/Http/Middleware/EnsureSupportToken.php
Normal file
66
app/Http/Middleware/EnsureSupportToken.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSupportToken
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): JsonResponse|Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return $this->unauthorizedResponse('Unauthenticated request.');
|
||||
}
|
||||
|
||||
$accessToken = $user->currentAccessToken();
|
||||
|
||||
if (! $accessToken instanceof PersonalAccessToken) {
|
||||
return $this->unauthorizedResponse('Missing personal access token context.');
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
return $this->forbiddenResponse('Only super administrators may access support APIs.');
|
||||
}
|
||||
|
||||
if (! $accessToken->can('support-admin') && ! $accessToken->can('super-admin')) {
|
||||
return $this->forbiddenResponse('Access token does not include the support-admin ability.');
|
||||
}
|
||||
|
||||
$request->attributes->set('support_token_id', $accessToken->id);
|
||||
|
||||
Auth::shouldUse('sanctum');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function unauthorizedResponse(string $message): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'unauthenticated',
|
||||
'Unauthenticated',
|
||||
$message,
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
private function forbiddenResponse(string $message): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'support_forbidden',
|
||||
'Forbidden',
|
||||
$message,
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Support\LocaleConfig;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Spatie\Honeypot\Honeypot;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
@@ -67,6 +68,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'error' => fn () => $request->session()->get('error'),
|
||||
'verification' => fn () => $request->session()->get('verification'),
|
||||
],
|
||||
'honeypot' => fn () => new Honeypot(config('honeypot')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ class VerifyCsrfToken extends Middleware
|
||||
protected $except = [
|
||||
'api/v1/photos/*/like',
|
||||
'api/v1/events/*/upload',
|
||||
'paddle/webhook*',
|
||||
'lemonsqueezy/webhook*',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -35,16 +35,16 @@ class CheckoutSessionConfirmRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => ['nullable', 'string', 'required_without:checkout_id'],
|
||||
'checkout_id' => ['nullable', 'string', 'required_without:transaction_id'],
|
||||
'order_id' => ['nullable', 'string', 'required_without:checkout_id'],
|
||||
'checkout_id' => ['nullable', 'string', 'required_without:order_id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'transaction_id.required_without' => 'Transaction ID oder Checkout ID fehlt.',
|
||||
'checkout_id.required_without' => 'Checkout ID oder Transaction ID fehlt.',
|
||||
'order_id.required_without' => 'Order ID oder Checkout ID fehlt.',
|
||||
'checkout_id.required_without' => 'Checkout ID oder Order ID fehlt.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Paddle;
|
||||
namespace App\Http\Requests\LemonSqueezy;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PaddleCheckoutRequest extends FormRequest
|
||||
class LemonSqueezyCheckoutRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,15 +25,11 @@ class PaddleCheckoutRequest 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 [
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportBlogPostResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$postId = $model?->getKey();
|
||||
|
||||
$rules = [
|
||||
'blog_category_id' => ['sometimes', 'integer', 'exists:blog_categories,id'],
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('blog_posts', 'slug')->ignore($postId),
|
||||
],
|
||||
'banner' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||
'is_published' => ['sometimes', 'boolean'],
|
||||
'title' => ['sometimes', 'array'],
|
||||
'title.de' => ['required_with:title', 'string', 'max:255'],
|
||||
'title.en' => ['nullable', 'string', 'max:255'],
|
||||
'content' => ['sometimes', 'array'],
|
||||
'content.de' => ['required_with:content', 'string'],
|
||||
'content.en' => ['nullable', 'string'],
|
||||
'excerpt' => ['sometimes', 'array'],
|
||||
'excerpt.de' => ['nullable', 'string'],
|
||||
'excerpt.en' => ['nullable', 'string'],
|
||||
'meta_title' => ['sometimes', 'array'],
|
||||
'meta_title.de' => ['nullable', 'string', 'max:255'],
|
||||
'meta_title.en' => ['nullable', 'string', 'max:255'],
|
||||
'meta_description' => ['sometimes', 'array'],
|
||||
'meta_description.de' => ['nullable', 'string'],
|
||||
'meta_description.en' => ['nullable', 'string'],
|
||||
'translations' => ['sometimes', 'array'],
|
||||
];
|
||||
|
||||
if ($action === 'create') {
|
||||
$rules['blog_category_id'] = ['required', 'integer', 'exists:blog_categories,id'];
|
||||
$rules['slug'] = ['required', 'string', 'max:255', Rule::unique('blog_posts', 'slug')];
|
||||
$rules['title'] = ['required', 'array'];
|
||||
$rules['title.de'] = ['required', 'string', 'max:255'];
|
||||
$rules['content'] = ['required', 'array'];
|
||||
$rules['content.de'] = ['required', 'string'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'blog_category_id',
|
||||
'slug',
|
||||
'banner',
|
||||
'published_at',
|
||||
'is_published',
|
||||
'title',
|
||||
'content',
|
||||
'excerpt',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'translations',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportDataExportResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$scopeValues = array_map(static fn (DataExportScope $scope): string => $scope->value, DataExportScope::cases());
|
||||
|
||||
return [
|
||||
'scope' => ['required', 'string', Rule::in($scopeValues)],
|
||||
'tenant_id' => ['required', 'integer', 'exists:tenants,id'],
|
||||
'event_id' => ['nullable', 'integer', 'exists:events,id', 'required_if:scope,event'],
|
||||
'include_media' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'scope',
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'include_media',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupportEmotionResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$rules = [
|
||||
'name' => ['sometimes', 'array'],
|
||||
'name.de' => ['required_with:name', 'string', 'max:255'],
|
||||
'name.en' => ['required_with:name', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'array'],
|
||||
'description.de' => ['nullable', 'string'],
|
||||
'description.en' => ['nullable', 'string'],
|
||||
'icon' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'color' => ['sometimes', 'nullable', 'string', 'max:7'],
|
||||
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'tenant_id' => ['sometimes', 'nullable', 'integer', 'exists:tenants,id'],
|
||||
];
|
||||
|
||||
if ($action === 'create') {
|
||||
$rules['name'] = ['required', 'array'];
|
||||
$rules['name.de'] = ['required', 'string', 'max:255'];
|
||||
$rules['name.en'] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'name',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'tenant_id',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportEventResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$eventId = $model?->getKey();
|
||||
|
||||
$rules = [
|
||||
'name' => ['sometimes', 'array'],
|
||||
'name.de' => ['required_with:name', 'string', 'max:255'],
|
||||
'name.en' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'array'],
|
||||
'description.de' => ['nullable', 'string'],
|
||||
'description.en' => ['nullable', 'string'],
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('events', 'slug')->ignore($eventId),
|
||||
],
|
||||
'date' => ['sometimes', 'date'],
|
||||
'location' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'max_participants' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'event_type_id' => ['sometimes', 'nullable', 'integer', 'exists:event_types,id'],
|
||||
'default_locale' => ['sometimes', 'string', 'max:5'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'status' => ['sometimes', 'string', Rule::in(['draft', 'published', 'archived'])],
|
||||
'settings' => ['sometimes', 'array'],
|
||||
'join_link_enabled' => ['sometimes', 'boolean'],
|
||||
'photo_upload_enabled' => ['sometimes', 'boolean'],
|
||||
'task_checklist_enabled' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
if ($action === 'create') {
|
||||
$rules['name'] = ['required', 'array'];
|
||||
$rules['name.de'] = ['required', 'string', 'max:255'];
|
||||
$rules['slug'] = [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('events', 'slug'),
|
||||
];
|
||||
$rules['date'] = ['required', 'date'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'name',
|
||||
'description',
|
||||
'slug',
|
||||
'date',
|
||||
'location',
|
||||
'max_participants',
|
||||
'event_type_id',
|
||||
'default_locale',
|
||||
'is_active',
|
||||
'status',
|
||||
'settings',
|
||||
'join_link_enabled',
|
||||
'photo_upload_enabled',
|
||||
'task_checklist_enabled',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportPhotoResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
return [
|
||||
'status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected', 'hidden'])],
|
||||
'moderation_notes' => ['nullable', 'required_if:status,rejected', 'string', 'max:1000'],
|
||||
'is_featured' => ['sometimes', 'boolean'],
|
||||
'emotion_id' => ['sometimes', 'nullable', 'integer', 'exists:emotions,id'],
|
||||
'task_id' => ['sometimes', 'nullable', 'integer', 'exists:tasks,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'status',
|
||||
'moderation_notes',
|
||||
'is_featured',
|
||||
'emotion_id',
|
||||
'task_id',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupportPhotoboothSettingResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
return [
|
||||
'ftp_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'rate_limit_per_minute' => ['sometimes', 'integer', 'min:1', 'max:200'],
|
||||
'expiry_grace_days' => ['sometimes', 'integer', 'min:0', 'max:14'],
|
||||
'require_ftps' => ['sometimes', 'boolean'],
|
||||
'allowed_ip_ranges' => ['sometimes', 'array'],
|
||||
'control_service_base_url' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
'control_service_token_identifier' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'ftp_port',
|
||||
'rate_limit_per_minute',
|
||||
'expiry_grace_days',
|
||||
'require_ftps',
|
||||
'allowed_ip_ranges',
|
||||
'control_service_base_url',
|
||||
'control_service_token_identifier',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
abstract class SupportResourceFormRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return static::rulesFor('update', null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportTaskResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$rules = [
|
||||
'emotion_id' => ['sometimes', 'integer', 'exists:emotions,id'],
|
||||
'event_type_id' => ['sometimes', 'nullable', 'integer', 'exists:event_types,id'],
|
||||
'title' => ['sometimes', 'array'],
|
||||
'title.de' => ['required_with:title', 'string', 'max:255'],
|
||||
'title.en' => ['required_with:title', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'array'],
|
||||
'description.de' => ['nullable', 'string'],
|
||||
'description.en' => ['nullable', 'string'],
|
||||
'example_text' => ['sometimes', 'array'],
|
||||
'example_text.de' => ['nullable', 'string'],
|
||||
'example_text.en' => ['nullable', 'string'],
|
||||
'difficulty' => ['sometimes', 'string', Rule::in(['easy', 'medium', 'hard'])],
|
||||
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
if ($action === 'create') {
|
||||
$rules['emotion_id'] = ['required', 'integer', 'exists:emotions,id'];
|
||||
$rules['title'] = ['required', 'array'];
|
||||
$rules['title.de'] = ['required', 'string', 'max:255'];
|
||||
$rules['title.en'] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'emotion_id',
|
||||
'event_type_id',
|
||||
'title',
|
||||
'description',
|
||||
'example_text',
|
||||
'difficulty',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportTenantFeedbackResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
return [
|
||||
'status' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
Rule::in(['pending', 'resolved', 'hidden', 'deleted']),
|
||||
],
|
||||
'moderation_notes' => ['sometimes', 'nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'status',
|
||||
'moderation_notes',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportTenantResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$tenantId = $model?->getKey();
|
||||
|
||||
return [
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'slug')->ignore($tenantId),
|
||||
],
|
||||
'contact_email' => ['sometimes', 'email', 'max:255'],
|
||||
'lemonsqueezy_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'is_suspended' => ['sometimes', 'boolean'],
|
||||
'features' => ['sometimes', 'array'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'slug',
|
||||
'contact_email',
|
||||
'lemonsqueezy_customer_id',
|
||||
'is_active',
|
||||
'is_suspended',
|
||||
'features',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportUserResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$userId = $model?->getKey();
|
||||
|
||||
return [
|
||||
'first_name' => ['sometimes', 'string', 'max:255'],
|
||||
'last_name' => ['sometimes', 'string', 'max:255'],
|
||||
'username' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('users', 'username')->ignore($userId),
|
||||
],
|
||||
'email' => [
|
||||
'sometimes',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users', 'email')->ignore($userId),
|
||||
],
|
||||
'address' => ['sometimes', 'string', 'max:1000'],
|
||||
'phone' => ['sometimes', 'string', 'max:50'],
|
||||
'preferred_locale' => ['sometimes', 'string', 'max:10'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'email',
|
||||
'address',
|
||||
'phone',
|
||||
'preferred_locale',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Support/SupportGuestPolicyRequest.php
Normal file
36
app/Http/Requests/Support/SupportGuestPolicyRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportGuestPolicyRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'guest_downloads_enabled' => ['sometimes', 'boolean'],
|
||||
'guest_sharing_enabled' => ['sometimes', 'boolean'],
|
||||
'guest_upload_visibility' => ['sometimes', 'string'],
|
||||
'per_device_upload_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'join_token_failure_limit' => ['sometimes', 'integer', 'min:1'],
|
||||
'join_token_failure_decay_minutes' => ['sometimes', 'integer', 'min:1'],
|
||||
'join_token_access_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'join_token_access_decay_minutes' => ['sometimes', 'integer', 'min:1'],
|
||||
'join_token_download_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'join_token_download_decay_minutes' => ['sometimes', 'integer', 'min:1'],
|
||||
'join_token_ttl_hours' => ['sometimes', 'integer', 'min:0'],
|
||||
'share_link_ttl_hours' => ['sometimes', 'integer', 'min:1'],
|
||||
'guest_notification_ttl_hours' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Support/SupportResourceRequest.php
Normal file
24
app/Http/Requests/Support/SupportResourceRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportResourceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'data' => ['required', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Http/Requests/Support/SupportTokenRequest.php
Normal file
52
app/Http/Requests/Support/SupportTokenRequest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTokenRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'login' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
'abilities' => ['sometimes', 'array'],
|
||||
'abilities.*' => ['string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{email?: string, username?: string, password: string, abilities?: array<int, string>}
|
||||
*/
|
||||
public function credentials(): array
|
||||
{
|
||||
$login = $this->string('login')->trim()->value();
|
||||
|
||||
$credentials = [
|
||||
'password' => $this->string('password')->value(),
|
||||
];
|
||||
|
||||
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
|
||||
$credentials['email'] = $login;
|
||||
} else {
|
||||
$credentials['username'] = $login;
|
||||
}
|
||||
|
||||
$abilities = $this->input('abilities');
|
||||
if (is_array($abilities) && $abilities !== []) {
|
||||
$credentials['abilities'] = array_values(array_filter($abilities, 'is_string'));
|
||||
}
|
||||
|
||||
return $credentials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportWatermarkSettingsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'asset' => ['sometimes', 'string'],
|
||||
'position' => ['sometimes', 'string'],
|
||||
'opacity' => ['sometimes', 'numeric', 'min:0', 'max:1'],
|
||||
'scale' => ['sometimes', 'numeric', 'min:0.05', 'max:1'],
|
||||
'padding' => ['sometimes', 'integer', 'min:0'],
|
||||
'offset_x' => ['sometimes', 'integer', 'min:-500', 'max:500'],
|
||||
'offset_y' => ['sometimes', 'integer', 'min:-500', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantAddPackageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_id' => ['required', 'integer'],
|
||||
'expires_at' => ['nullable', 'date'],
|
||||
'reason' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantScheduleDeletionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'pending_deletion_at' => ['required', 'date', 'after:now'],
|
||||
'send_warning' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantSetGracePeriodRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'grace_period_ends_at' => ['required', 'date', 'after_or_equal:now'],
|
||||
'note' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantUpdateLimitsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'max_photos_per_event' => ['required', 'integer', 'min:0'],
|
||||
'max_storage_mb' => ['required', 'integer', 'min:0'],
|
||||
'note' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantUpdateSubscriptionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'subscription_expires_at' => ['nullable', 'date'],
|
||||
'note' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ class EventResource extends JsonResource
|
||||
'key' => $addon->addon_key,
|
||||
'label' => $addon->metadata['label'] ?? null,
|
||||
'status' => $addon->status,
|
||||
'price_id' => $addon->price_id,
|
||||
'variant_id' => $addon->variant_id,
|
||||
'transaction_id' => $addon->transaction_id,
|
||||
'extra_photos' => (int) $addon->extra_photos,
|
||||
'extra_guests' => (int) $addon->extra_guests,
|
||||
|
||||
@@ -71,12 +71,44 @@ class ArchiveEventMediaAssets implements ShouldQueue
|
||||
|
||||
Storage::disk($archiveDisk)->put($archivePath, $stream);
|
||||
|
||||
$checksumMeta = null;
|
||||
$archiveChecksum = null;
|
||||
if ($this->checksumValidationEnabled()) {
|
||||
$archiveChecksum = $this->computeChecksum($archiveDisk, $archivePath);
|
||||
if (! $archiveChecksum) {
|
||||
throw new \RuntimeException('Archive checksum unavailable');
|
||||
}
|
||||
|
||||
$expectedChecksum = $asset->checksum;
|
||||
if ($expectedChecksum) {
|
||||
if (! hash_equals($expectedChecksum, $archiveChecksum)) {
|
||||
$this->handleChecksumMismatch($asset, $expectedChecksum, $archiveChecksum, $sourceDisk, $archiveDisk);
|
||||
$this->deleteArchiveCopy($archiveDisk, $archivePath);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$checksumMeta = [
|
||||
'checksum_status' => 'verified',
|
||||
'checksum_verified_at' => now()->toIso8601String(),
|
||||
];
|
||||
} else {
|
||||
$asset->checksum = $archiveChecksum;
|
||||
$checksumMeta = [
|
||||
'checksum_status' => 'seeded',
|
||||
'checksum_verified_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$asset->fill([
|
||||
'disk' => $archiveDisk,
|
||||
'media_storage_target_id' => $archiveTargetId,
|
||||
'status' => 'archived',
|
||||
'archived_at' => now(),
|
||||
'error_message' => null,
|
||||
'checksum' => $asset->checksum,
|
||||
'meta' => $this->mergeMeta($asset->meta, $checksumMeta),
|
||||
])->save();
|
||||
|
||||
if ($this->deleteSource) {
|
||||
@@ -102,4 +134,92 @@ class ArchiveEventMediaAssets implements ShouldQueue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checksumValidationEnabled(): bool
|
||||
{
|
||||
return (bool) config('storage-monitor.checksum_validation.enabled', true);
|
||||
}
|
||||
|
||||
private function computeChecksum(string $disk, string $path): ?string
|
||||
{
|
||||
try {
|
||||
$stream = Storage::disk($disk)->readStream($path);
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('storage-jobs')->warning('Failed to open stream for checksum', [
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $stream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$context = hash_init('sha256');
|
||||
$ok = hash_update_stream($context, $stream);
|
||||
if ($ok === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hash_final($context);
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function handleChecksumMismatch(
|
||||
EventMediaAsset $asset,
|
||||
string $expectedChecksum,
|
||||
string $actualChecksum,
|
||||
string $sourceDisk,
|
||||
string $archiveDisk,
|
||||
): void {
|
||||
Log::channel('storage-jobs')->alert('Checksum mismatch detected during archive', [
|
||||
'asset_id' => $asset->id,
|
||||
'event_id' => $asset->event_id,
|
||||
'source_disk' => $sourceDisk,
|
||||
'archive_disk' => $archiveDisk,
|
||||
'expected_checksum' => $expectedChecksum,
|
||||
'actual_checksum' => $actualChecksum,
|
||||
]);
|
||||
|
||||
$asset->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => 'checksum_mismatch',
|
||||
'meta' => $this->mergeMeta($asset->meta, [
|
||||
'checksum_status' => 'mismatch',
|
||||
'checksum_verified_at' => now()->toIso8601String(),
|
||||
'checksum_expected' => $expectedChecksum,
|
||||
'checksum_actual' => $actualChecksum,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function deleteArchiveCopy(string $archiveDisk, string $path): void
|
||||
{
|
||||
try {
|
||||
Storage::disk($archiveDisk)->delete($path);
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('storage-jobs')->warning('Failed to clean up archive copy after checksum mismatch', [
|
||||
'disk' => $archiveDisk,
|
||||
'path' => $path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function mergeMeta(?array $meta, ?array $updates): ?array
|
||||
{
|
||||
if (! $updates) {
|
||||
return $meta;
|
||||
}
|
||||
|
||||
return array_merge($meta ?? [], $updates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\PaddleCatalogService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
|
||||
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 PullPackageFromPaddle implements ShouldQueue
|
||||
class PullPackageFromLemonSqueezy implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
@@ -22,7 +22,7 @@ class PullPackageFromPaddle implements ShouldQueue
|
||||
|
||||
public function __construct(private readonly int $packageId) {}
|
||||
|
||||
public function handle(PaddleCatalogService $catalog): void
|
||||
public function handle(LemonSqueezyCatalogService $catalog): void
|
||||
{
|
||||
$package = Package::query()->find($this->packageId);
|
||||
|
||||
@@ -30,8 +30,8 @@ class PullPackageFromPaddle implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $package->paddle_product_id && ! $package->paddle_price_id) {
|
||||
Log::channel('paddle-sync')->warning('Paddle pull skipped for package without linkage', [
|
||||
if (! $package->lemonsqueezy_product_id && ! $package->lemonsqueezy_variant_id) {
|
||||
Log::channel('lemonsqueezy-sync')->warning('Lemon Squeezy pull skipped for package without linkage', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
@@ -39,41 +39,41 @@ class PullPackageFromPaddle implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
$product = $package->paddle_product_id ? $catalog->fetchProduct($package->paddle_product_id) : null;
|
||||
$price = $package->paddle_price_id ? $catalog->fetchPrice($package->paddle_price_id) : null;
|
||||
$product = $package->lemonsqueezy_product_id ? $catalog->fetchProduct($package->lemonsqueezy_product_id) : null;
|
||||
$price = $package->lemonsqueezy_variant_id ? $catalog->fetchPrice($package->lemonsqueezy_variant_id) : null;
|
||||
|
||||
$snapshot = $package->paddle_snapshot ?? [];
|
||||
$snapshot = $package->lemonsqueezy_snapshot ?? [];
|
||||
$snapshot['remote'] = array_filter([
|
||||
'product' => $product,
|
||||
'price' => $price,
|
||||
], static fn ($value) => $value !== null);
|
||||
|
||||
$package->forceFill([
|
||||
'paddle_sync_status' => 'pulled',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => $snapshot,
|
||||
'lemonsqueezy_sync_status' => 'pulled',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => $snapshot,
|
||||
])->save();
|
||||
|
||||
Log::channel('paddle-sync')->info('Paddle package pull completed', [
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package pull completed', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('paddle-sync')->error('Paddle package pull failed', [
|
||||
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package pull failed', [
|
||||
'package_id' => $package->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
$snapshot = $package->paddle_snapshot ?? [];
|
||||
$snapshot = $package->lemonsqueezy_snapshot ?? [];
|
||||
$snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [
|
||||
'message' => $exception->getMessage(),
|
||||
'class' => $exception::class,
|
||||
]);
|
||||
|
||||
$package->forceFill([
|
||||
'paddle_sync_status' => 'pull-failed',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => $snapshot,
|
||||
'lemonsqueezy_sync_status' => 'pull-failed',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => $snapshot,
|
||||
])->save();
|
||||
|
||||
throw $exception;
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Coupon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleDiscountService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyDiscountService;
|
||||
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 SyncCouponToPaddle implements ShouldQueue
|
||||
class SyncCouponToLemonSqueezy implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
@@ -24,16 +24,16 @@ class SyncCouponToPaddle implements ShouldQueue
|
||||
public bool $archive = false,
|
||||
) {}
|
||||
|
||||
public function handle(PaddleDiscountService $discounts): void
|
||||
public function handle(LemonSqueezyDiscountService $discounts): void
|
||||
{
|
||||
try {
|
||||
if ($this->archive) {
|
||||
$discounts->archiveDiscount($this->coupon);
|
||||
|
||||
$this->coupon->forceFill([
|
||||
'paddle_discount_id' => null,
|
||||
'paddle_snapshot' => null,
|
||||
'paddle_last_synced_at' => now(),
|
||||
'lemonsqueezy_discount_id' => null,
|
||||
'lemonsqueezy_snapshot' => null,
|
||||
'lemonsqueezy_last_synced_at' => now(),
|
||||
])->save();
|
||||
|
||||
return;
|
||||
@@ -42,12 +42,12 @@ class SyncCouponToPaddle implements ShouldQueue
|
||||
$data = $discounts->updateDiscount($this->coupon);
|
||||
|
||||
$this->coupon->forceFill([
|
||||
'paddle_discount_id' => $data['id'] ?? $this->coupon->paddle_discount_id,
|
||||
'paddle_snapshot' => $data,
|
||||
'paddle_last_synced_at' => now(),
|
||||
'lemonsqueezy_discount_id' => $data['id'] ?? $this->coupon->lemonsqueezy_discount_id,
|
||||
'lemonsqueezy_snapshot' => $data,
|
||||
'lemonsqueezy_last_synced_at' => now(),
|
||||
])->save();
|
||||
} catch (PaddleException $exception) {
|
||||
Log::channel('paddle-sync')->error('Failed syncing coupon to Paddle', [
|
||||
} catch (LemonSqueezyException $exception) {
|
||||
Log::channel('lemonsqueezy-sync')->error('Failed syncing coupon to Lemon Squeezy', [
|
||||
'coupon_id' => $this->coupon->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
@@ -55,7 +55,7 @@ class SyncCouponToPaddle implements ShouldQueue
|
||||
]);
|
||||
|
||||
$this->coupon->forceFill([
|
||||
'paddle_snapshot' => [
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'error' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
'context' => $exception->context(),
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleAddonCatalogService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyAddonCatalogService;
|
||||
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 SyncPackageAddonToPaddle implements ShouldQueue
|
||||
class SyncPackageAddonToLemonSqueezy implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
@@ -26,7 +26,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
||||
*/
|
||||
public function __construct(private readonly int $addonId, private readonly array $options = []) {}
|
||||
|
||||
public function handle(PaddleAddonCatalogService $catalog): void
|
||||
public function handle(LemonSqueezyAddonCatalogService $catalog): void
|
||||
{
|
||||
$addon = PackageAddon::query()->find($this->addonId);
|
||||
|
||||
@@ -39,7 +39,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
||||
$priceOverrides = Arr::get($this->options, 'price', []);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides);
|
||||
$this->storeDryRunSnapshot($addon, $productOverrides, $priceOverrides);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -47,41 +47,41 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
||||
// Mark syncing (metadata)
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'paddle_sync_status' => 'syncing',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_sync_status' => 'syncing',
|
||||
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||
]),
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
||||
|
||||
$productResponse = $addon->metadata['paddle_product_id'] ?? null
|
||||
? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product'])
|
||||
$productResponse = $addon->metadata['lemonsqueezy_product_id'] ?? null
|
||||
? $catalog->updateProduct($addon->metadata['lemonsqueezy_product_id'], $addon, $payloadOverrides['product'])
|
||||
: $catalog->createProduct($addon, $payloadOverrides['product']);
|
||||
|
||||
$productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null);
|
||||
$productId = (string) ($productResponse['id'] ?? $addon->metadata['lemonsqueezy_product_id'] ?? null);
|
||||
|
||||
if (! $productId) {
|
||||
throw new PaddleException('Paddle product ID missing after addon sync.');
|
||||
throw new LemonSqueezyException('Lemon Squeezy product ID missing after addon sync.');
|
||||
}
|
||||
|
||||
$priceResponse = $addon->price_id
|
||||
? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
|
||||
$priceResponse = $addon->variant_id
|
||||
? $catalog->updatePrice($addon->variant_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
|
||||
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
|
||||
|
||||
$priceId = (string) ($priceResponse['id'] ?? $addon->price_id);
|
||||
$priceId = (string) ($priceResponse['id'] ?? $addon->variant_id);
|
||||
|
||||
if (! $priceId) {
|
||||
throw new PaddleException('Paddle price ID missing after addon sync.');
|
||||
throw new LemonSqueezyException('Lemon Squeezy variant ID missing after addon sync.');
|
||||
}
|
||||
|
||||
$addon->forceFill([
|
||||
'price_id' => $priceId,
|
||||
'variant_id' => $priceId,
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'paddle_sync_status' => 'synced',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_product_id' => $productId,
|
||||
'paddle_snapshot' => [
|
||||
'lemonsqueezy_sync_status' => 'synced',
|
||||
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_product_id' => $productId,
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'product' => $productResponse,
|
||||
'price' => $priceResponse,
|
||||
'payload' => $payloadOverrides,
|
||||
@@ -89,7 +89,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
||||
]),
|
||||
])->save();
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('paddle-sync')->error('Paddle addon sync failed', [
|
||||
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy addon sync failed', [
|
||||
'addon_id' => $addon->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
@@ -97,9 +97,9 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
||||
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'paddle_sync_status' => 'failed',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_error' => [
|
||||
'lemonsqueezy_sync_status' => 'failed',
|
||||
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_error' => [
|
||||
'message' => $exception->getMessage(),
|
||||
'class' => $exception::class,
|
||||
],
|
||||
@@ -145,22 +145,22 @@ class SyncPackageAddonToPaddle implements ShouldQueue
|
||||
* @param array<string, mixed> $productOverrides
|
||||
* @param array<string, mixed> $priceOverrides
|
||||
*/
|
||||
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void
|
||||
protected function storeDryRunSnapshot(PackageAddon $addon, array $productOverrides, array $priceOverrides): void
|
||||
{
|
||||
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
|
||||
|
||||
$addon->forceFill([
|
||||
'metadata' => array_merge($addon->metadata ?? [], [
|
||||
'paddle_sync_status' => 'dry-run',
|
||||
'paddle_synced_at' => now()->toIso8601String(),
|
||||
'paddle_snapshot' => [
|
||||
'lemonsqueezy_sync_status' => 'dry-run',
|
||||
'lemonsqueezy_synced_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'dry_run' => true,
|
||||
'payload' => $payloadOverrides,
|
||||
],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
Log::channel('paddle-sync')->info('Paddle addon dry-run snapshot generated', [
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy addon dry-run snapshot generated', [
|
||||
'addon_id' => $addon->id,
|
||||
]);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleCatalogService;
|
||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
||||
use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
|
||||
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 SyncPackageToPaddle implements ShouldQueue
|
||||
class SyncPackageToLemonSqueezy implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
@@ -26,7 +26,7 @@ class SyncPackageToPaddle implements ShouldQueue
|
||||
*/
|
||||
public function __construct(private readonly int $packageId, private readonly array $options = []) {}
|
||||
|
||||
public function handle(PaddleCatalogService $catalog): void
|
||||
public function handle(LemonSqueezyCatalogService $catalog): void
|
||||
{
|
||||
$package = Package::query()->find($this->packageId);
|
||||
|
||||
@@ -45,37 +45,37 @@ class SyncPackageToPaddle implements ShouldQueue
|
||||
}
|
||||
|
||||
$package->forceFill([
|
||||
'paddle_sync_status' => 'syncing',
|
||||
'lemonsqueezy_sync_status' => 'syncing',
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$productResponse = $package->paddle_product_id
|
||||
? $catalog->updateProduct($package->paddle_product_id, $package, $productOverrides)
|
||||
$productResponse = $package->lemonsqueezy_product_id
|
||||
? $catalog->updateProduct($package->lemonsqueezy_product_id, $package, $productOverrides)
|
||||
: $catalog->createProduct($package, $productOverrides);
|
||||
|
||||
$productId = (string) ($productResponse['id'] ?? $package->paddle_product_id);
|
||||
$productId = (string) ($productResponse['id'] ?? $package->lemonsqueezy_product_id);
|
||||
|
||||
if (! $productId) {
|
||||
throw new PaddleException('Paddle product ID missing after sync.');
|
||||
throw new LemonSqueezyException('Lemon Squeezy product ID missing after sync.');
|
||||
}
|
||||
|
||||
$package->paddle_product_id = $productId;
|
||||
$package->lemonsqueezy_product_id = $productId;
|
||||
|
||||
$priceResponse = $package->paddle_price_id
|
||||
? $catalog->updatePrice($package->paddle_price_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
|
||||
$priceResponse = $package->lemonsqueezy_variant_id
|
||||
? $catalog->updatePrice($package->lemonsqueezy_variant_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
|
||||
: $catalog->createPrice($package, $productId, $priceOverrides);
|
||||
|
||||
$priceId = (string) ($priceResponse['id'] ?? $package->paddle_price_id);
|
||||
$priceId = (string) ($priceResponse['id'] ?? $package->lemonsqueezy_variant_id);
|
||||
|
||||
if (! $priceId) {
|
||||
throw new PaddleException('Paddle price ID missing after sync.');
|
||||
throw new LemonSqueezyException('Lemon Squeezy variant ID missing after sync.');
|
||||
}
|
||||
|
||||
$package->forceFill([
|
||||
'paddle_price_id' => $priceId,
|
||||
'paddle_sync_status' => 'synced',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => [
|
||||
'lemonsqueezy_variant_id' => $priceId,
|
||||
'lemonsqueezy_sync_status' => 'synced',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'product' => $productResponse,
|
||||
'price' => $priceResponse,
|
||||
'payload' => [
|
||||
@@ -85,16 +85,16 @@ class SyncPackageToPaddle implements ShouldQueue
|
||||
],
|
||||
])->save();
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('paddle-sync')->error('Paddle package sync failed', [
|
||||
Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package sync failed', [
|
||||
'package_id' => $package->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
$package->forceFill([
|
||||
'paddle_sync_status' => 'failed',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => array_merge($package->paddle_snapshot ?? [], [
|
||||
'lemonsqueezy_sync_status' => 'failed',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => array_merge($package->lemonsqueezy_snapshot ?? [], [
|
||||
'error' => [
|
||||
'message' => $exception->getMessage(),
|
||||
'class' => $exception::class,
|
||||
@@ -110,19 +110,19 @@ class SyncPackageToPaddle implements ShouldQueue
|
||||
* @param array<string, mixed> $productOverrides
|
||||
* @param array<string, mixed> $priceOverrides
|
||||
*/
|
||||
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
|
||||
protected function storeDryRunSnapshot(LemonSqueezyCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
|
||||
{
|
||||
$productPayload = $catalog->buildProductPayload($package, $productOverrides);
|
||||
$pricePayload = $catalog->buildPricePayload(
|
||||
$package,
|
||||
$package->paddle_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
|
||||
$package->lemonsqueezy_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
|
||||
$priceOverrides
|
||||
);
|
||||
|
||||
$package->forceFill([
|
||||
'paddle_sync_status' => 'dry-run',
|
||||
'paddle_synced_at' => now(),
|
||||
'paddle_snapshot' => [
|
||||
'lemonsqueezy_sync_status' => 'dry-run',
|
||||
'lemonsqueezy_synced_at' => now(),
|
||||
'lemonsqueezy_snapshot' => [
|
||||
'dry_run' => true,
|
||||
'payload' => [
|
||||
'product' => $productPayload,
|
||||
@@ -131,7 +131,7 @@ class SyncPackageToPaddle implements ShouldQueue
|
||||
],
|
||||
])->save();
|
||||
|
||||
Log::channel('paddle-sync')->info('Paddle package dry-run snapshot generated', [
|
||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package dry-run snapshot generated', [
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user