From 79df406c6fc5f041d27876cf0ca602f995e068b0 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 22 Sep 2026 00:43:56 +0100 Subject: [PATCH 1/6] feat: Let a host put its discounts on the console The discounts page had nothing behind it, so a product that already knew how to discount something could not show or create one here. A host registers a provider that answers what exists, makes one, and stops one, and declares the fields it needs so the console renders a form it can submit. Whatever stores the discount stays the host's business, which is what lets one product use coupons and another use credits. Registering nothing is still a supported state and answers with an empty list. --- CHANGELOG.md | 7 ++ README.md | 55 ++++++++++++ config/admin.php | 4 + routes/api.php | 3 + src/AdminServiceProvider.php | 6 ++ src/Contracts/OfferField.php | 35 ++++++++ src/Contracts/OfferProvider.php | 37 ++++++++ src/Http/Controllers/AdminController.php | 44 +++++++++ src/Http/Requests/CreateOfferRequest.php | 21 +++++ src/Support/Offer.php | 43 +++++++++ src/Support/OfferRegistry.php | 46 ++++++++++ tests/Feature/OfferProviderTest.php | 108 +++++++++++++++++++++++ tests/Support/CouponOfferProvider.php | 61 +++++++++++++ 13 files changed, 470 insertions(+) create mode 100644 src/Contracts/OfferField.php create mode 100644 src/Contracts/OfferProvider.php create mode 100644 src/Http/Requests/CreateOfferRequest.php create mode 100644 src/Support/Offer.php create mode 100644 src/Support/OfferRegistry.php create mode 100644 tests/Feature/OfferProviderTest.php create mode 100644 tests/Support/CouponOfferProvider.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d8fad..e3fb89d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [0.2.0] - 2026-09-22 + +### Added +- Offer providers, so a host's discounts can be listed and created from the console without the package knowing what stores them (`OfferProvider`, `OfferField`, `Offer`) +- `GET offers`, `POST offers/{provider}`, `DELETE offers/{provider}/{id}` +- `admin.offer_providers` configuration + ## [0.1.0] - 2026-08-29 - Shared administration user directory and configurable automatic email templates - Host-configurable models, resources, requests, routes, middleware and user provider diff --git a/README.md b/README.md index 8bdb6e4..2b0723a 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,58 @@ return [ ``` Each provider implements `Whilesmart\Engagement\Contracts\MetricProvider` and returns count, sum, ratio, series, or ranking metrics. Adding a provider automatically adds its measurements to the shared admin page. + +## Offers + +A discount is stored differently in every product, so the console asks a +provider rather than a table. Register one and the discounts page lists what it +holds and renders a form from the fields it declares. + +```php +return [ + 'offer_providers' => [ + App\Billing\CouponOfferProvider::class, + ], +]; +``` + +```php +use Whilesmart\Admin\Contracts\OfferField; +use Whilesmart\Admin\Contracts\OfferProvider; +use Whilesmart\Admin\Support\Offer; + +class CouponOfferProvider implements OfferProvider +{ + public function key(): string + { + return 'coupons'; + } + + public function label(): string + { + return 'Coupons'; + } + + public function fields(): array + { + return [ + new OfferField('code', 'Code', required: true), + new OfferField('percent_off', 'Percent off', 'number', required: true), + new OfferField('expires_at', 'Expires', 'date'), + ]; + } + + public function all(): array { /* Offer[] */ } + + public function create(array $attributes): Offer { /* ... */ } + + public function revoke(string $id): void { /* ... */ } +} +``` + +`Offer::$value` is already formatted, because only the provider knows whether +20 means a percentage, pennies or seats. Revoking stops an offer being redeemed +again and leaves redemptions already made standing. + +Registering no provider is a supported state: the endpoint answers with an +empty list and the page says so. diff --git a/config/admin.php b/config/admin.php index 747a1fd..69e46c1 100644 --- a/config/admin.php +++ b/config/admin.php @@ -21,6 +21,10 @@ 'requests' => ['update_mail_template' => UpdateMailTemplateRequest::class], 'resources' => ['user' => AdminUserResource::class, 'mail_template' => MailTemplateResource::class], 'controller' => AdminController::class, + // Classes implementing OfferProvider. Registering one puts its discounts + // on the console; registering none leaves that page saying so. + 'offer_providers' => [], + 'templates' => [], 'tokens' => ['first_name', 'last_name', 'name', 'email'], 'registration_template' => 'welcome', diff --git a/routes/api.php b/routes/api.php index 786179b..3de17a8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,9 @@ $controller = config('admin.controller'); Route::get('metrics', [$controller, 'metrics']); +Route::get('offers', [$controller, 'offers']); +Route::post('offers/{provider}', [$controller, 'createOffer'])->middleware(config('admin.write_middleware', [])); +Route::delete('offers/{provider}/{id}', [$controller, 'revokeOffer'])->middleware(config('admin.write_middleware', [])); Route::get('users', [$controller, 'users']); Route::get('users/{id}', [$controller, 'user']); Route::get('mail-templates', [$controller, 'templates']); diff --git a/src/AdminServiceProvider.php b/src/AdminServiceProvider.php index 36dd7a2..a9f27db 100644 --- a/src/AdminServiceProvider.php +++ b/src/AdminServiceProvider.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use Whilesmart\Admin\Contracts\AdminUserProvider; use Whilesmart\Admin\Listeners\SendRegistrationEmail; +use Whilesmart\Admin\Support\OfferRegistry; use Whilesmart\UserAuthentication\Events\UserRegisteredEvent; class AdminServiceProvider extends ServiceProvider @@ -23,6 +24,11 @@ public function register(): void return $app->make($provider); }); + + $this->app->singleton( + OfferRegistry::class, + fn () => new OfferRegistry((array) config('admin.offer_providers', [])), + ); } public function boot(): void diff --git a/src/Contracts/OfferField.php b/src/Contracts/OfferField.php new file mode 100644 index 0000000..db3e302 --- /dev/null +++ b/src/Contracts/OfferField.php @@ -0,0 +1,35 @@ + $options For 'select', value => label. + */ + public function __construct( + public readonly string $name, + public readonly string $label, + public readonly string $type = 'text', + public readonly bool $required = false, + public readonly ?string $help = null, + public readonly array $options = [], + ) {} + + /** @return array */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'label' => $this->label, + 'type' => $this->type, + 'required' => $this->required, + 'help' => $this->help, + 'options' => $this->options, + ]; + } +} diff --git a/src/Contracts/OfferProvider.php b/src/Contracts/OfferProvider.php new file mode 100644 index 0000000..fa84dee --- /dev/null +++ b/src/Contracts/OfferProvider.php @@ -0,0 +1,37 @@ + + */ + public function fields(): array; + + /** + * @return Offer[] + */ + public function all(): array; + + /** + * @param array $attributes Keyed by the field names above. + */ + public function create(array $attributes): Offer; + + /** Stop an offer being redeemed again. Redemptions already made stand. */ + public function revoke(string $id): void; +} diff --git a/src/Http/Controllers/AdminController.php b/src/Http/Controllers/AdminController.php index 96a6133..ee8f7dc 100644 --- a/src/Http/Controllers/AdminController.php +++ b/src/Http/Controllers/AdminController.php @@ -6,9 +6,12 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Whilesmart\Admin\Contracts\AdminUserProvider; +use Whilesmart\Admin\Contracts\OfferProvider; +use Whilesmart\Admin\Http\Requests\CreateOfferRequest; use Whilesmart\Admin\Http\Resources\AdminUserResource; use Whilesmart\Admin\Mail\TemplateMail; use Whilesmart\Admin\Models\MailTemplate; +use Whilesmart\Admin\Support\OfferRegistry; use Whilesmart\Admin\Support\TemplateRegistry; use Whilesmart\Engagement\EngagementManager; use Whilesmart\Engagement\Support\ClientRegistry; @@ -17,6 +20,47 @@ class AdminController extends Controller { + /** + * Every registered provider, its fields, and its offers. + * + * A host with none registered gets an empty list, not an error. + */ + public function offers(OfferRegistry $offers): JsonResponse + { + return response()->json([ + 'success' => true, + 'data' => array_values(array_map(fn ($provider) => [ + 'key' => $provider->key(), + 'label' => $provider->label(), + 'fields' => array_map(fn ($field) => $field->toArray(), $provider->fields()), + 'offers' => array_map(fn ($offer) => $offer->toArray(), $provider->all()), + ], $offers->all())), + ]); + } + + public function createOffer(CreateOfferRequest $request, OfferRegistry $offers, string $provider): JsonResponse + { + $offer = $this->offerProvider($offers, $provider)->create($request->validated()['attributes']); + + return response()->json(['success' => true, 'data' => $offer->toArray()], 201); + } + + public function revokeOffer(OfferRegistry $offers, string $provider, string $id): JsonResponse + { + $this->offerProvider($offers, $provider)->revoke($id); + + return response()->json(['success' => true]); + } + + private function offerProvider(OfferRegistry $offers, string $key): OfferProvider + { + $provider = $offers->get($key); + + abort_if($provider === null, 404, 'No offer provider is registered under that key.'); + + return $provider; + } + public function metrics(Request $request, EngagementManager $engagement, ClientRegistry $clients): JsonResponse { $granularity = in_array($request->query('granularity'), ['day', 'week', 'month'], true) diff --git a/src/Http/Requests/CreateOfferRequest.php b/src/Http/Requests/CreateOfferRequest.php new file mode 100644 index 0000000..b120782 --- /dev/null +++ b/src/Http/Requests/CreateOfferRequest.php @@ -0,0 +1,21 @@ + ['required', 'array'], + ]; + } +} diff --git a/src/Support/Offer.php b/src/Support/Offer.php new file mode 100644 index 0000000..2260ea0 --- /dev/null +++ b/src/Support/Offer.php @@ -0,0 +1,43 @@ + $meta + */ + public function __construct( + public readonly string $id, + public readonly string $code, + public readonly string $value, + public readonly ?Carbon $expiresAt = null, + public readonly ?int $redemptions = null, + public readonly ?int $maxRedemptions = null, + public readonly bool $active = true, + public readonly array $meta = [], + ) {} + + /** @return array */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'code' => $this->code, + 'value' => $this->value, + 'expires_at' => $this->expiresAt?->toIso8601String(), + 'redemptions' => $this->redemptions, + 'max_redemptions' => $this->maxRedemptions, + 'active' => $this->active, + 'meta' => $this->meta, + ]; + } +} diff --git a/src/Support/OfferRegistry.php b/src/Support/OfferRegistry.php new file mode 100644 index 0000000..afb0733 --- /dev/null +++ b/src/Support/OfferRegistry.php @@ -0,0 +1,46 @@ +> */ + private array $providers = []; + + /** + * @param array> $providers + */ + public function __construct(array $providers = []) + { + foreach ($providers as $class) { + if (is_string($class) && is_subclass_of($class, OfferProvider::class)) { + $this->providers[app($class)->key()] = $class; + } + } + } + + /** @return array */ + public function all(): array + { + return array_map(fn (string $class) => app($class), $this->providers); + } + + public function has(string $key): bool + { + return isset($this->providers[$key]); + } + + public function get(string $key): ?OfferProvider + { + return isset($this->providers[$key]) ? app($this->providers[$key]) : null; + } +} diff --git a/tests/Feature/OfferProviderTest.php b/tests/Feature/OfferProviderTest.php new file mode 100644 index 0000000..24ba195 --- /dev/null +++ b/tests/Feature/OfferProviderTest.php @@ -0,0 +1,108 @@ + [CouponOfferProvider::class]]); + } + + private function actor(): User + { + return User::create(['first_name' => 'Ada', 'email' => 'ada@example.test']); + } + + public function test_a_registered_provider_says_what_it_can_be_asked_for(): void + { + $this->actingAs($this->actor()) + ->getJson('api/admin/offers') + ->assertOk() + ->assertJsonPath('data.0.key', 'coupons') + ->assertJsonPath('data.0.label', 'Coupons') + ->assertJsonPath('data.0.fields.0.name', 'code') + ->assertJsonPath('data.0.fields.0.required', true) + ->assertJsonPath('data.0.fields.1.type', 'number') + ->assertJsonPath('data.0.offers', []); + } + + public function test_a_host_with_no_provider_gets_an_empty_list_rather_than_an_error(): void + { + config(['admin.offer_providers' => []]); + + $this->actingAs($this->actor()) + ->getJson('api/admin/offers') + ->assertOk() + ->assertJsonPath('data', []); + } + + public function test_an_offer_can_be_created_and_is_then_listed(): void + { + $this->actingAs($this->actor()) + ->postJson('api/admin/offers/coupons', ['attributes' => [ + 'code' => 'PILOT20', + 'percent_off' => 20, + ]]) + ->assertCreated() + ->assertJsonPath('data.code', 'PILOT20') + ->assertJsonPath('data.value', '20%'); + + $this->actingAs($this->actor()) + ->getJson('api/admin/offers') + ->assertOk() + ->assertJsonPath('data.0.offers.0.code', 'PILOT20'); + } + + public function test_creating_one_needs_attributes(): void + { + $this->actingAs($this->actor()) + ->postJson('api/admin/offers/coupons', []) + ->assertStatus(422); + } + + public function test_a_revoked_offer_stops_being_listed(): void + { + $created = $this->actingAs($this->actor()) + ->postJson('api/admin/offers/coupons', ['attributes' => ['code' => 'GONE', 'percent_off' => 5]]) + ->json('data.id'); + + $this->actingAs($this->actor()) + ->deleteJson("api/admin/offers/coupons/{$created}") + ->assertOk(); + + $this->actingAs($this->actor()) + ->getJson('api/admin/offers') + ->assertOk() + ->assertJsonPath('data.0.offers', []); + } + + /** + * @dataProvider unknownProvider + */ + public function test_an_unregistered_provider_is_not_found(string $method, string $path): void + { + $this->actingAs($this->actor()) + ->json($method, $path, ['attributes' => ['code' => 'X']]) + ->assertNotFound(); + } + + public static function unknownProvider(): array + { + return [ + 'creating' => ['POST', 'api/admin/offers/credits'], + 'revoking' => ['DELETE', 'api/admin/offers/credits/1'], + ]; + } +} diff --git a/tests/Support/CouponOfferProvider.php b/tests/Support/CouponOfferProvider.php new file mode 100644 index 0000000..2b729da --- /dev/null +++ b/tests/Support/CouponOfferProvider.php @@ -0,0 +1,61 @@ + */ + public static array $offers = []; + + public function key(): string + { + return 'coupons'; + } + + public function label(): string + { + return 'Coupons'; + } + + public function fields(): array + { + return [ + new OfferField('code', 'Code', required: true), + new OfferField('percent_off', 'Percent off', 'number', required: true), + new OfferField('expires_at', 'Expires', 'date'), + ]; + } + + public function all(): array + { + return array_values(static::$offers); + } + + public function create(array $attributes): Offer + { + $offer = new Offer( + id: (string) (count(static::$offers) + 1), + code: $attributes['code'], + value: $attributes['percent_off'].'%', + expiresAt: isset($attributes['expires_at']) ? Carbon::parse($attributes['expires_at']) : null, + ); + + static::$offers[$offer->id] = $offer; + + return $offer; + } + + public function revoke(string $id): void + { + unset(static::$offers[$id]); + } +} From 6daf4554028ed90581afc5738ab830678bb309a9 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 22 Sep 2026 16:56:31 +0100 Subject: [PATCH 2/6] fix: Hold a host to the contract the offer page offers An expiry built with the date library an application already uses was refused, so a provider could not return one at all. Either form is accepted now. A provider class that was misspelled or did not implement the contract disappeared without a word, and two providers claiming the same key quietly replaced one another. Both are refused with a message naming what is wrong. Validation for a new offer can be replaced the way the other requests here can, so a host can ask for its own fields without taking over the controller. --- config/admin.php | 6 ++- src/Http/Controllers/AdminController.php | 6 +-- src/Support/Offer.php | 2 +- src/Support/OfferRegistry.php | 17 +++++++- tests/Feature/OfferProviderTest.php | 45 ++++++++++++++++++++++ tests/Support/SecondCouponProvider.php | 6 +++ tests/Support/StrictCreateOfferRequest.php | 22 +++++++++++ 7 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 tests/Support/SecondCouponProvider.php create mode 100644 tests/Support/StrictCreateOfferRequest.php diff --git a/config/admin.php b/config/admin.php index 69e46c1..99b9045 100644 --- a/config/admin.php +++ b/config/admin.php @@ -1,6 +1,7 @@ ['first_name', 'last_name', 'email'], 'user_provider' => EloquentAdminUserProvider::class, 'models' => ['mail_template' => MailTemplate::class], - 'requests' => ['update_mail_template' => UpdateMailTemplateRequest::class], + 'requests' => [ + 'update_mail_template' => UpdateMailTemplateRequest::class, + 'create_offer' => CreateOfferRequest::class, + ], 'resources' => ['user' => AdminUserResource::class, 'mail_template' => MailTemplateResource::class], 'controller' => AdminController::class, // Classes implementing OfferProvider. Registering one puts its discounts diff --git a/src/Http/Controllers/AdminController.php b/src/Http/Controllers/AdminController.php index ee8f7dc..3c56844 100644 --- a/src/Http/Controllers/AdminController.php +++ b/src/Http/Controllers/AdminController.php @@ -7,7 +7,6 @@ use Illuminate\Routing\Controller; use Whilesmart\Admin\Contracts\AdminUserProvider; use Whilesmart\Admin\Contracts\OfferProvider; -use Whilesmart\Admin\Http\Requests\CreateOfferRequest; use Whilesmart\Admin\Http\Resources\AdminUserResource; use Whilesmart\Admin\Mail\TemplateMail; use Whilesmart\Admin\Models\MailTemplate; @@ -38,9 +37,10 @@ public function offers(OfferRegistry $offers): JsonResponse ]); } - public function createOffer(CreateOfferRequest $request, OfferRegistry $offers, string $provider): JsonResponse + public function createOffer(Request $request, OfferRegistry $offers, string $provider): JsonResponse { - $offer = $this->offerProvider($offers, $provider)->create($request->validated()['attributes']); + $validated = app(config('admin.requests.create_offer'))->validated(); + $offer = $this->offerProvider($offers, $provider)->create($validated['attributes']); return response()->json(['success' => true, 'data' => $offer->toArray()], 201); } diff --git a/src/Support/Offer.php b/src/Support/Offer.php index 2260ea0..c7a487c 100644 --- a/src/Support/Offer.php +++ b/src/Support/Offer.php @@ -2,7 +2,7 @@ namespace Whilesmart\Admin\Support; -use Illuminate\Support\Carbon; +use Carbon\Carbon; /** * One discount, as the console shows it. A provider keeps whatever else its diff --git a/src/Support/OfferRegistry.php b/src/Support/OfferRegistry.php index afb0733..f7bfa20 100644 --- a/src/Support/OfferRegistry.php +++ b/src/Support/OfferRegistry.php @@ -2,6 +2,7 @@ namespace Whilesmart\Admin\Support; +use InvalidArgumentException; use Whilesmart\Admin\Contracts\OfferProvider; /** @@ -22,9 +23,21 @@ class OfferRegistry public function __construct(array $providers = []) { foreach ($providers as $class) { - if (is_string($class) && is_subclass_of($class, OfferProvider::class)) { - $this->providers[app($class)->key()] = $class; + if (! is_string($class) || ! is_subclass_of($class, OfferProvider::class)) { + throw new InvalidArgumentException( + 'admin.offer_providers must name classes implementing '.OfferProvider::class.'.' + ); } + + $key = app($class)->key(); + + if (isset($this->providers[$key])) { + throw new InvalidArgumentException( + "Two offer providers are registered under the key {$key}." + ); + } + + $this->providers[$key] = $class; } } diff --git a/tests/Feature/OfferProviderTest.php b/tests/Feature/OfferProviderTest.php index 24ba195..b99fad1 100644 --- a/tests/Feature/OfferProviderTest.php +++ b/tests/Feature/OfferProviderTest.php @@ -2,9 +2,15 @@ namespace Tests\Feature; +use Carbon\Carbon as BaseCarbon; +use InvalidArgumentException; use Tests\Support\CouponOfferProvider; +use Tests\Support\SecondCouponProvider; +use Tests\Support\StrictCreateOfferRequest; use Tests\Support\User; use Tests\TestCase; +use Whilesmart\Admin\Support\Offer; +use Whilesmart\Admin\Support\OfferRegistry; /** * What the console can do with a host's discounts without knowing what backs @@ -88,6 +94,45 @@ public function test_a_revoked_offer_stops_being_listed(): void ->assertJsonPath('data.0.offers', []); } + public function test_an_expiry_from_the_base_carbon_is_accepted(): void + { + $offer = new Offer( + id: '1', + code: 'PILOT20', + value: '20%', + expiresAt: BaseCarbon::parse('2026-01-01T00:00:00+00:00'), + ); + + $this->assertSame('2026-01-01T00:00:00+00:00', $offer->toArray()['expires_at']); + } + + public function test_a_provider_that_does_not_implement_the_contract_is_refused(): void + { + $this->expectException(InvalidArgumentException::class); + + new OfferRegistry([User::class]); + } + + public function test_two_providers_under_one_key_are_refused(): void + { + $this->expectException(InvalidArgumentException::class); + + new OfferRegistry([CouponOfferProvider::class, SecondCouponProvider::class]); + } + + public function test_a_host_can_replace_the_request_that_validates_an_offer(): void + { + config(['admin.requests.create_offer' => StrictCreateOfferRequest::class]); + + $this->actingAs($this->actor()) + ->postJson('api/admin/offers/coupons', ['attributes' => [ + 'code' => 'PILOT20', + 'percent_off' => 20, + ]]) + ->assertStatus(422) + ->assertJsonValidationErrors('attributes.reason'); + } + /** * @dataProvider unknownProvider */ diff --git a/tests/Support/SecondCouponProvider.php b/tests/Support/SecondCouponProvider.php new file mode 100644 index 0000000..98b68dd --- /dev/null +++ b/tests/Support/SecondCouponProvider.php @@ -0,0 +1,6 @@ + ['required', 'array'], + 'attributes.reason' => ['required', 'string'], + ]; + } +} From d679ebafe528362a5d236d76f5dace7be4cff86d Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 22 Sep 2026 18:18:21 +0100 Subject: [PATCH 3/6] fix: Guard the discounts endpoints and widen what an expiry can be Any authenticated caller could create or revoke a discount, and read every registered provider's contents, where the equivalent mail template write is refused for the same caller. The three endpoints now answer to the configured owner the way the rest of the console does, checked before the request object so a host replacing that object cannot drop the check. An expiry had to be one particular date class. Immutable dates and plain ones were refused at the door, which ruled out what many applications already hold. Any date is taken now. A host that published this package's configuration before offers existed has a request map that replaces the one shipped here, so creating a discount resolved nothing and failed. It falls back to the request this package ships. --- src/Http/Controllers/AdminController.php | 33 ++++++++++-- src/Support/Offer.php | 7 +-- tests/Feature/OfferProviderTest.php | 67 +++++++++++++++++++++--- 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/Http/Controllers/AdminController.php b/src/Http/Controllers/AdminController.php index 3c56844..62fc9d5 100644 --- a/src/Http/Controllers/AdminController.php +++ b/src/Http/Controllers/AdminController.php @@ -7,6 +7,7 @@ use Illuminate\Routing\Controller; use Whilesmart\Admin\Contracts\AdminUserProvider; use Whilesmart\Admin\Contracts\OfferProvider; +use Whilesmart\Admin\Http\Requests\CreateOfferRequest; use Whilesmart\Admin\Http\Resources\AdminUserResource; use Whilesmart\Admin\Mail\TemplateMail; use Whilesmart\Admin\Models\MailTemplate; @@ -24,8 +25,10 @@ class AdminController extends Controller * * A host with none registered gets an empty list, not an error. */ - public function offers(OfferRegistry $offers): JsonResponse + public function offers(Request $request, OfferRegistry $offers): JsonResponse { + $this->authorizeOffers($request); + return response()->json([ 'success' => true, 'data' => array_values(array_map(fn ($provider) => [ @@ -39,19 +42,43 @@ public function offers(OfferRegistry $offers): JsonResponse public function createOffer(Request $request, OfferRegistry $offers, string $provider): JsonResponse { - $validated = app(config('admin.requests.create_offer'))->validated(); + $this->authorizeOffers($request); + + // Defaulted rather than read straight out: a host that published this + // config before the key existed has an array that wins over the one + // shipped here, and would resolve nothing. + $requestClass = config('admin.requests.create_offer', CreateOfferRequest::class); + $validated = app($requestClass)->validated(); $offer = $this->offerProvider($offers, $provider)->create($validated['attributes']); return response()->json(['success' => true, 'data' => $offer->toArray()], 201); } - public function revokeOffer(OfferRegistry $offers, string $provider, string $id): JsonResponse + public function revokeOffer(Request $request, OfferRegistry $offers, string $provider, string $id): JsonResponse { + $this->authorizeOffers($request); + $this->offerProvider($offers, $provider)->revoke($id); return response()->json(['success' => true]); } + /** + * Offers answer to the configured owner, the way a mail template answers to + * its own. Checked here rather than in the request, so a host swapping the + * request class cannot drop it. + */ + private function authorizeOffers(Request $request): void + { + $owner = config('admin.owner'); + + abort_unless(app(OwnerAuthorizer::class)->authorize( + $request->user(), + $owner['type'], + $owner['id'], + ), 403); + } + private function offerProvider(OfferRegistry $offers, string $key): OfferProvider { $provider = $offers->get($key); diff --git a/src/Support/Offer.php b/src/Support/Offer.php index c7a487c..74d9c1b 100644 --- a/src/Support/Offer.php +++ b/src/Support/Offer.php @@ -2,7 +2,7 @@ namespace Whilesmart\Admin\Support; -use Carbon\Carbon; +use DateTimeInterface; /** * One discount, as the console shows it. A provider keeps whatever else its @@ -13,13 +13,14 @@ final class Offer /** * @param string $value Already formatted, because only the provider knows * whether 20 means a percentage, pennies, or seats. + * @param DateTimeInterface|null $expiresAt Whatever date the provider holds. * @param array $meta */ public function __construct( public readonly string $id, public readonly string $code, public readonly string $value, - public readonly ?Carbon $expiresAt = null, + public readonly ?DateTimeInterface $expiresAt = null, public readonly ?int $redemptions = null, public readonly ?int $maxRedemptions = null, public readonly bool $active = true, @@ -33,7 +34,7 @@ public function toArray(): array 'id' => $this->id, 'code' => $this->code, 'value' => $this->value, - 'expires_at' => $this->expiresAt?->toIso8601String(), + 'expires_at' => $this->expiresAt?->format(DateTimeInterface::ATOM), 'redemptions' => $this->redemptions, 'max_redemptions' => $this->maxRedemptions, 'active' => $this->active, diff --git a/tests/Feature/OfferProviderTest.php b/tests/Feature/OfferProviderTest.php index b99fad1..0533d17 100644 --- a/tests/Feature/OfferProviderTest.php +++ b/tests/Feature/OfferProviderTest.php @@ -3,14 +3,20 @@ namespace Tests\Feature; use Carbon\Carbon as BaseCarbon; +use Carbon\CarbonImmutable; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Carbon as LaravelCarbon; use InvalidArgumentException; use Tests\Support\CouponOfferProvider; use Tests\Support\SecondCouponProvider; use Tests\Support\StrictCreateOfferRequest; use Tests\Support\User; use Tests\TestCase; +use Whilesmart\Admin\Http\Requests\UpdateMailTemplateRequest; use Whilesmart\Admin\Support\Offer; use Whilesmart\Admin\Support\OfferRegistry; +use Whilesmart\OwnerAccess\Contracts\OwnerAuthorizer; /** * What the console can do with a host's discounts without knowing what backs @@ -94,18 +100,65 @@ public function test_a_revoked_offer_stops_being_listed(): void ->assertJsonPath('data.0.offers', []); } - public function test_an_expiry_from_the_base_carbon_is_accepted(): void + /** + * @dataProvider expiryDates + */ + public function test_any_date_a_host_holds_can_be_an_expiry(callable $make): void { - $offer = new Offer( - id: '1', - code: 'PILOT20', - value: '20%', - expiresAt: BaseCarbon::parse('2026-01-01T00:00:00+00:00'), - ); + $offer = new Offer(id: '1', code: 'PILOT20', value: '20%', expiresAt: $make()); $this->assertSame('2026-01-01T00:00:00+00:00', $offer->toArray()['expires_at']); } + public static function expiryDates(): array + { + $moment = '2026-01-01T00:00:00+00:00'; + + return [ + 'carbon' => [fn () => BaseCarbon::parse($moment)], + 'carbon immutable' => [fn () => CarbonImmutable::parse($moment)], + 'laravel carbon' => [fn () => LaravelCarbon::parse($moment)], + 'date time' => [fn () => new \DateTime($moment)], + 'date time immutable' => [fn () => new \DateTimeImmutable($moment)], + ]; + } + + public function test_offers_answer_to_the_configured_owner(): void + { + $this->app->instance(OwnerAuthorizer::class, new class implements OwnerAuthorizer + { + public function authorize(?Authenticatable $user, string $ownerType, mixed $ownerId): bool + { + return false; + } + + public function scope(Builder $query, ?Authenticatable $user, string $ownerTypeColumn = 'owner_type', string $ownerIdColumn = 'owner_id'): Builder + { + return $query->whereRaw('0 = 1'); + } + }); + + $this->actingAs($this->actor())->getJson('api/admin/offers')->assertForbidden(); + $this->actingAs($this->actor()) + ->postJson('api/admin/offers/coupons', ['attributes' => ['code' => 'X']]) + ->assertForbidden(); + $this->actingAs($this->actor()) + ->deleteJson('api/admin/offers/coupons/1') + ->assertForbidden(); + } + + public function test_a_host_upgrading_without_the_new_config_key_can_still_create(): void + { + config(['admin.requests' => ['update_mail_template' => UpdateMailTemplateRequest::class]]); + + $this->actingAs($this->actor()) + ->postJson('api/admin/offers/coupons', ['attributes' => [ + 'code' => 'PILOT20', + 'percent_off' => 20, + ]]) + ->assertCreated(); + } + public function test_a_provider_that_does_not_implement_the_contract_is_refused(): void { $this->expectException(InvalidArgumentException::class); From f67b391f927a9626996c90a688b78d6c056cd638 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 22 Sep 2026 18:23:54 +0100 Subject: [PATCH 4/6] test: Declare the data providers the way the suite declares its tests --- tests/Feature/OfferProviderTest.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/Feature/OfferProviderTest.php b/tests/Feature/OfferProviderTest.php index 0533d17..25f9e73 100644 --- a/tests/Feature/OfferProviderTest.php +++ b/tests/Feature/OfferProviderTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon as LaravelCarbon; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\Support\CouponOfferProvider; use Tests\Support\SecondCouponProvider; use Tests\Support\StrictCreateOfferRequest; @@ -100,9 +101,7 @@ public function test_a_revoked_offer_stops_being_listed(): void ->assertJsonPath('data.0.offers', []); } - /** - * @dataProvider expiryDates - */ + #[DataProvider('expiryDates')] public function test_any_date_a_host_holds_can_be_an_expiry(callable $make): void { $offer = new Offer(id: '1', code: 'PILOT20', value: '20%', expiresAt: $make()); @@ -186,9 +185,7 @@ public function test_a_host_can_replace_the_request_that_validates_an_offer(): v ->assertJsonValidationErrors('attributes.reason'); } - /** - * @dataProvider unknownProvider - */ + #[DataProvider('unknownProvider')] public function test_an_unregistered_provider_is_not_found(string $method, string $path): void { $this->actingAs($this->actor()) From 9ba3db76177b61e413444d4fe3409db87330c3c8 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 22 Sep 2026 18:35:43 +0100 Subject: [PATCH 5/6] chore: Release 0.2.0 --- CHANGELOG.md | 5 +++-- composer.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3fb89d..57b399e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ ### Added - Offer providers, so a host's discounts can be listed and created from the console without the package knowing what stores them (`OfferProvider`, `OfferField`, `Offer`) -- `GET offers`, `POST offers/{provider}`, `DELETE offers/{provider}/{id}` -- `admin.offer_providers` configuration +- `GET offers`, `POST offers/{provider}`, `DELETE offers/{provider}/{id}`, all answering to the configured owner the way the mail template endpoints do +- `admin.offer_providers` configuration, refused with a message when it names a class that does not implement the contract or two providers claiming one key +- `admin.requests.create_offer`, so a host can ask for its own fields when a discount is created ## [0.1.0] - 2026-08-29 - Shared administration user directory and configurable automatic email templates diff --git a/composer.json b/composer.json index 9f75957..6cee31c 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "whilesmart/eloquent-admin", "description": "Shared measurements, user directory and automatic email templates for Laravel applications.", "type": "library", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "authors": [{ "name": "Whilesmart Team" }], "require": { From bbde29c37a0a0e495745c4c8dd64d802b6375d1b Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 22 Sep 2026 18:40:22 +0100 Subject: [PATCH 6/6] docs: Say the offer configuration's two refusals the same way --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b399e..fbf96d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ ### Added - Offer providers, so a host's discounts can be listed and created from the console without the package knowing what stores them (`OfferProvider`, `OfferField`, `Offer`) - `GET offers`, `POST offers/{provider}`, `DELETE offers/{provider}/{id}`, all answering to the configured owner the way the mail template endpoints do -- `admin.offer_providers` configuration, refused with a message when it names a class that does not implement the contract or two providers claiming one key -- `admin.requests.create_offer`, so a host can ask for its own fields when a discount is created +- `admin.offer_providers` configuration, refused when a class does not implement the contract or when two providers claim one key +- `admin.requests.create_offer`, so a host can validate a new discount its own way ## [0.1.0] - 2026-08-29 - Shared administration user directory and configurable automatic email templates