Addon-Kauf im Event admin korrigiert.
This commit is contained in:
@@ -59,7 +59,7 @@ class EventAddonWebhookService
|
|||||||
return true; // idempotent
|
return true; // idempotent
|
||||||
}
|
}
|
||||||
|
|
||||||
$increments = $this->catalog->resolveIncrements($addonKey);
|
$increments = $this->resolveAddonIncrements($addon, $addonKey);
|
||||||
|
|
||||||
DB::transaction(function () use ($addon, $transactionId, $checkoutId, $data, $increments) {
|
DB::transaction(function () use ($addon, $transactionId, $checkoutId, $data, $increments) {
|
||||||
$addon->forceFill([
|
$addon->forceFill([
|
||||||
@@ -128,4 +128,25 @@ class EventAddonWebhookService
|
|||||||
|
|
||||||
return $metadata;
|
return $metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function resolveAddonIncrements(EventPackageAddon $addon, string $addonKey): array
|
||||||
|
{
|
||||||
|
$stored = Arr::get($addon->metadata ?? [], 'increments', []);
|
||||||
|
|
||||||
|
if (is_array($stored) && $stored !== []) {
|
||||||
|
$filtered = collect($stored)
|
||||||
|
->map(fn ($value) => is_numeric($value) ? (int) $value : 0)
|
||||||
|
->filter(fn ($value) => $value > 0)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($filtered !== []) {
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->catalog->resolveIncrements($addonKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,8 @@
|
|||||||
"guestsBlocked": "Gäste-Limit erreicht.",
|
"guestsBlocked": "Gäste-Limit erreicht.",
|
||||||
"guestsWarning": "{{remaining}} von {{limit}} Gästen verbleiben.",
|
"guestsWarning": "{{remaining}} von {{limit}} Gästen verbleiben.",
|
||||||
"galleryExpired": "Galerie abgelaufen. Verlängere die Laufzeit.",
|
"galleryExpired": "Galerie abgelaufen. Verlängere die Laufzeit.",
|
||||||
|
"galleryWarningHour": "Galerie läuft in {{hours}} Stunde ab.",
|
||||||
|
"galleryWarningHours": "Galerie läuft in {{hours}} Stunden ab.",
|
||||||
"galleryWarningDay": "Galerie läuft in {{days}} Tag ab.",
|
"galleryWarningDay": "Galerie läuft in {{days}} Tag ab.",
|
||||||
"galleryWarningDays": "Galerie läuft in {{days}} Tagen ab.",
|
"galleryWarningDays": "Galerie läuft in {{days}} Tagen ab.",
|
||||||
"buyMorePhotos": "Mehr Fotos freischalten",
|
"buyMorePhotos": "Mehr Fotos freischalten",
|
||||||
|
|||||||
@@ -227,6 +227,8 @@
|
|||||||
"guestsBlocked": "Guest limit reached.",
|
"guestsBlocked": "Guest limit reached.",
|
||||||
"guestsWarning": "{{remaining}} of {{limit}} guests remaining.",
|
"guestsWarning": "{{remaining}} of {{limit}} guests remaining.",
|
||||||
"galleryExpired": "Gallery expired. Extend to keep it online.",
|
"galleryExpired": "Gallery expired. Extend to keep it online.",
|
||||||
|
"galleryWarningHour": "Gallery expires in {{hours}} hour.",
|
||||||
|
"galleryWarningHours": "Gallery expires in {{hours}} hours.",
|
||||||
"galleryWarningDay": "Gallery expires in {{days}} day.",
|
"galleryWarningDay": "Gallery expires in {{days}} day.",
|
||||||
"galleryWarningDays": "Gallery expires in {{days}} days.",
|
"galleryWarningDays": "Gallery expires in {{days}} days.",
|
||||||
"buyMorePhotos": "Buy more photos",
|
"buyMorePhotos": "Buy more photos",
|
||||||
|
|||||||
50
resources/js/admin/lib/__tests__/limitWarnings.test.ts
Normal file
50
resources/js/admin/lib/__tests__/limitWarnings.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildLimitWarnings } from '../limitWarnings';
|
||||||
|
|
||||||
|
describe('buildLimitWarnings', () => {
|
||||||
|
it('renders gallery warning in hours when less than two days remain', () => {
|
||||||
|
const warnings = buildLimitWarnings(
|
||||||
|
{
|
||||||
|
photos: null,
|
||||||
|
guests: null,
|
||||||
|
gallery: {
|
||||||
|
state: 'warning',
|
||||||
|
expires_at: null,
|
||||||
|
days_remaining: 1.2,
|
||||||
|
warning_thresholds: [],
|
||||||
|
warning_triggered: null,
|
||||||
|
warning_sent_at: null,
|
||||||
|
expired_notified_at: null,
|
||||||
|
},
|
||||||
|
can_upload_photos: true,
|
||||||
|
can_add_guests: true,
|
||||||
|
},
|
||||||
|
(key, options) => (key === 'galleryWarningHours' ? `hours:${options?.hours}` : key),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(warnings[0]?.message).toBe('hours:29');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders gallery warning in days when two or more days remain', () => {
|
||||||
|
const warnings = buildLimitWarnings(
|
||||||
|
{
|
||||||
|
photos: null,
|
||||||
|
guests: null,
|
||||||
|
gallery: {
|
||||||
|
state: 'warning',
|
||||||
|
expires_at: null,
|
||||||
|
days_remaining: 2.1,
|
||||||
|
warning_thresholds: [],
|
||||||
|
warning_triggered: null,
|
||||||
|
warning_sent_at: null,
|
||||||
|
expired_notified_at: null,
|
||||||
|
},
|
||||||
|
can_upload_photos: true,
|
||||||
|
can_add_guests: true,
|
||||||
|
},
|
||||||
|
(key, options) => (key === 'galleryWarningDays' ? `days:${options?.days}` : key),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(warnings[0]?.message).toBe('days:3');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,12 +104,21 @@ export function buildLimitWarnings(limits: EventLimitSummary, t: TranslateFn): L
|
|||||||
} else if (limits.gallery.state === 'warning') {
|
} else if (limits.gallery.state === 'warning') {
|
||||||
const days = limits.gallery.days_remaining ?? 0;
|
const days = limits.gallery.days_remaining ?? 0;
|
||||||
const safeDays = Math.max(0, days);
|
const safeDays = Math.max(0, days);
|
||||||
const key = safeDays === 1 ? 'galleryWarningDay' : 'galleryWarningDays';
|
const useHours = safeDays > 0 && safeDays < 2;
|
||||||
|
const roundedDays = Math.max(1, Math.ceil(safeDays));
|
||||||
|
const roundedHours = Math.max(1, Math.ceil(safeDays * 24));
|
||||||
|
const key = useHours
|
||||||
|
? roundedHours === 1
|
||||||
|
? 'galleryWarningHour'
|
||||||
|
: 'galleryWarningHours'
|
||||||
|
: roundedDays === 1
|
||||||
|
? 'galleryWarningDay'
|
||||||
|
: 'galleryWarningDays';
|
||||||
warnings.push({
|
warnings.push({
|
||||||
id: 'gallery-warning',
|
id: 'gallery-warning',
|
||||||
scope: 'gallery',
|
scope: 'gallery',
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
message: t(key, { days: safeDays }),
|
message: t(key, useHours ? { hours: roundedHours } : { days: roundedDays }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1381,6 +1381,8 @@ function translateLimits(t: (key: string, defaultValue?: string, options?: Recor
|
|||||||
guestsBlocked: 'Guest limit reached.',
|
guestsBlocked: 'Guest limit reached.',
|
||||||
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
|
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
|
||||||
galleryExpired: 'Gallery expired. Extend to keep it online.',
|
galleryExpired: 'Gallery expired. Extend to keep it online.',
|
||||||
|
galleryWarningHour: 'Gallery expires in {{hours}} hour.',
|
||||||
|
galleryWarningHours: 'Gallery expires in {{hours}} hours.',
|
||||||
galleryWarningDay: 'Gallery expires in {{days}} day.',
|
galleryWarningDay: 'Gallery expires in {{days}} day.',
|
||||||
galleryWarningDays: 'Gallery expires in {{days}} days.',
|
galleryWarningDays: 'Gallery expires in {{days}} days.',
|
||||||
buyMorePhotos: 'Buy more photos',
|
buyMorePhotos: 'Buy more photos',
|
||||||
@@ -1390,7 +1392,7 @@ function translateLimits(t: (key: string, defaultValue?: string, options?: Recor
|
|||||||
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
|
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LimitWarnings({
|
export function LimitWarnings({
|
||||||
limits,
|
limits,
|
||||||
addons,
|
addons,
|
||||||
onCheckout,
|
onCheckout,
|
||||||
@@ -1420,32 +1422,40 @@ function LimitWarnings({
|
|||||||
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
<Text fontSize="$sm" color={textColor} fontWeight="700">
|
||||||
{warning.message}
|
{warning.message}
|
||||||
</Text>
|
</Text>
|
||||||
{(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests') && addons.length ? (
|
{(warning.scope === 'photos' || warning.scope === 'gallery' || warning.scope === 'guests')
|
||||||
<MobileAddonsPicker
|
&& resolveAddonOptions(addons, warning.scope).length ? (
|
||||||
scope={warning.scope}
|
<MobileAddonsPicker
|
||||||
addons={addons}
|
scope={warning.scope}
|
||||||
busy={busyScope === warning.scope}
|
addons={addons}
|
||||||
onCheckout={onCheckout}
|
busy={busyScope === warning.scope}
|
||||||
translate={translate}
|
onCheckout={onCheckout}
|
||||||
/>
|
translate={translate}
|
||||||
) : null}
|
/>
|
||||||
<CTAButton
|
) : (
|
||||||
label={
|
<CTAButton
|
||||||
warning.scope === 'photos'
|
label={
|
||||||
? translate('buyMorePhotos')
|
warning.scope === 'photos'
|
||||||
: warning.scope === 'gallery'
|
? translate('buyMorePhotos')
|
||||||
? translate('extendGallery')
|
: warning.scope === 'gallery'
|
||||||
: translate('buyMoreGuests')
|
? translate('extendGallery')
|
||||||
}
|
: translate('buyMoreGuests')
|
||||||
onPress={() => onCheckout(warning.scope)}
|
}
|
||||||
loading={busyScope === warning.scope}
|
onPress={() => onCheckout(warning.scope)}
|
||||||
/>
|
loading={busyScope === warning.scope}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
))}
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAddonOptions(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery' | 'guests'): EventAddonCatalogItem[] {
|
||||||
|
const whitelist = scopeDefaults[scope];
|
||||||
|
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
|
||||||
|
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
|
||||||
|
}
|
||||||
|
|
||||||
function MobileAddonsPicker({
|
function MobileAddonsPicker({
|
||||||
scope,
|
scope,
|
||||||
addons,
|
addons,
|
||||||
@@ -1459,11 +1469,7 @@ function MobileAddonsPicker({
|
|||||||
onCheckout: (addonKey: string) => void;
|
onCheckout: (addonKey: string) => void;
|
||||||
translate: LimitTranslator;
|
translate: LimitTranslator;
|
||||||
}) {
|
}) {
|
||||||
const options = React.useMemo(() => {
|
const options = React.useMemo(() => resolveAddonOptions(addons, scope), [addons, scope]);
|
||||||
const whitelist = scopeDefaults[scope];
|
|
||||||
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
|
|
||||||
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
|
|
||||||
}, [addons, scope]);
|
|
||||||
|
|
||||||
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
|
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
|
||||||
|
|
||||||
|
|||||||
107
resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx
Normal file
107
resources/js/admin/mobile/__tests__/LimitWarnings.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { LimitWarnings } from '../EventPhotosPage';
|
||||||
|
|
||||||
|
vi.mock('@tamagui/stacks', () => ({
|
||||||
|
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/text', () => ({
|
||||||
|
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/core', () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
color: { val: '#111827' },
|
||||||
|
gray: { val: '#4b5563' },
|
||||||
|
borderColor: { val: '#e5e7eb' },
|
||||||
|
blue3: { val: '#e8f1ff' },
|
||||||
|
blue6: { val: '#bfdbfe' },
|
||||||
|
red10: { val: '#b91c1c' },
|
||||||
|
surface: { val: '#ffffff' },
|
||||||
|
gray12: { val: '#0f172a' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||||
|
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Primitives', () => ({
|
||||||
|
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
PillBadge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||||
|
CTAButton: ({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}) => (
|
||||||
|
<button type="button" onClick={onPress} disabled={disabled || loading}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
SkeletonCard: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/FormControls', () => ({
|
||||||
|
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||||
|
MobileSelect: ({
|
||||||
|
children,
|
||||||
|
compact,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
compact?: boolean;
|
||||||
|
}) => <select {...props}>{children}</select>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('LimitWarnings', () => {
|
||||||
|
it('renders a single checkout button when add-on selection is available', () => {
|
||||||
|
const limits = {
|
||||||
|
photos: {
|
||||||
|
limit: 100,
|
||||||
|
used: 100,
|
||||||
|
remaining: 0,
|
||||||
|
percentage: 100,
|
||||||
|
state: 'limit_reached',
|
||||||
|
threshold_reached: null,
|
||||||
|
next_threshold: null,
|
||||||
|
thresholds: [],
|
||||||
|
},
|
||||||
|
guests: null,
|
||||||
|
gallery: null,
|
||||||
|
can_upload_photos: false,
|
||||||
|
can_add_guests: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addons = [
|
||||||
|
{
|
||||||
|
key: 'extra_photos_500',
|
||||||
|
label: 'Extra photos',
|
||||||
|
price_id: 'pri_addon_photos',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getAllByRole } = render(
|
||||||
|
<LimitWarnings
|
||||||
|
limits={limits}
|
||||||
|
addons={addons}
|
||||||
|
onCheckout={vi.fn()}
|
||||||
|
busyScope={null}
|
||||||
|
translate={(key) => key}
|
||||||
|
textColor="#111827"
|
||||||
|
borderColor="#e5e7eb"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getAllByRole('button')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,4 +80,73 @@ class EventAddonWebhookTest extends TenantTestCase
|
|||||||
|
|
||||||
Notification::assertSentTimes(AddonPurchaseReceipt::class, 1);
|
Notification::assertSentTimes(AddonPurchaseReceipt::class, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_webhook_uses_stored_increments_over_catalog(): void
|
||||||
|
{
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
Config::set('package-addons.extra_guests', [
|
||||||
|
'label' => 'Guests 50',
|
||||||
|
'increments' => ['extra_guests' => 50],
|
||||||
|
'price_id' => 'pri_guests',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$package = Package::factory()->endcustomer()->create([
|
||||||
|
'max_guests' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = Event::factory()->for($this->tenant)->create([
|
||||||
|
'status' => 'published',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eventPackage = EventPackage::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'package_id' => $package->id,
|
||||||
|
'purchased_price' => $package->price,
|
||||||
|
'purchased_at' => now(),
|
||||||
|
'used_photos' => 0,
|
||||||
|
'used_guests' => 0,
|
||||||
|
'gallery_expires_at' => now()->addDays(7),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$addon = EventPackageAddon::create([
|
||||||
|
'event_package_id' => $eventPackage->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'tenant_id' => $this->tenant->id,
|
||||||
|
'addon_key' => 'extra_guests',
|
||||||
|
'quantity' => 2,
|
||||||
|
'extra_guests' => 400,
|
||||||
|
'status' => 'pending',
|
||||||
|
'metadata' => [
|
||||||
|
'addon_intent' => 'intent-456',
|
||||||
|
'increments' => ['extra_guests' => 200],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'event_type' => 'transaction.completed',
|
||||||
|
'data' => [
|
||||||
|
'id' => 'txn_addon_2',
|
||||||
|
'custom_data' => [
|
||||||
|
'addon_intent' => 'intent-456',
|
||||||
|
'addon_key' => 'extra_guests',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$handler = app(EventAddonWebhookService::class);
|
||||||
|
|
||||||
|
$handled = $handler->handle($payload);
|
||||||
|
|
||||||
|
$this->assertTrue($handled);
|
||||||
|
|
||||||
|
$addon->refresh();
|
||||||
|
$eventPackage->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('completed', $addon->status);
|
||||||
|
$this->assertSame('txn_addon_2', $addon->transaction_id);
|
||||||
|
$this->assertSame(400, $eventPackage->extra_guests);
|
||||||
|
|
||||||
|
Notification::assertSentTimes(AddonPurchaseReceipt::class, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user