diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d8fad..fbf96d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [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}`, all answering to the configured owner the way the mail template endpoints do +- `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 - 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/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": { diff --git a/config/admin.php b/config/admin.php index 747a1fd..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 + // 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..62fc9d5 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,74 @@ 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(Request $request, OfferRegistry $offers): JsonResponse + { + $this->authorizeOffers($request); + + 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(Request $request, OfferRegistry $offers, string $provider): JsonResponse + { + $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(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); + + 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..74d9c1b --- /dev/null +++ b/src/Support/Offer.php @@ -0,0 +1,44 @@ + $meta + */ + public function __construct( + public readonly string $id, + public readonly string $code, + public readonly string $value, + public readonly ?DateTimeInterface $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?->format(DateTimeInterface::ATOM), + '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..f7bfa20 --- /dev/null +++ b/src/Support/OfferRegistry.php @@ -0,0 +1,59 @@ +> */ + 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)) { + 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; + } + } + + /** @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..25f9e73 --- /dev/null +++ b/tests/Feature/OfferProviderTest.php @@ -0,0 +1,203 @@ + [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('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()); + + $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); + + 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')] + 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]); + } +} 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'], + ]; + } +}