From 7afe61876b1b879868c86aa23f5405cbc7e8f66d Mon Sep 17 00:00:00 2001 From: Nick Aldewereld Date: Mon, 1 Jun 2026 22:02:17 +0200 Subject: [PATCH] Plan 03 batch 6: shop-module skeleton + Payment-provider abstractie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lege shop-module skelet die alleen een geparkeerde beheer-tab registreert. Payment-provider abstractie met PaymentStatus enum, RefundResult, en 3 stub adapters (Mollie/Stripe/Adyen) die NotImplementedException werpen. PaymentProviderFactory leest PAYMENT_PROVIDER + bijbehorende API-key uit .env via Easeo\Cms\Config\Environment. 26 tests via #[DataProvider]-coverage (clean, geen deprecation warnings). Shop NIET geactiveerd in apps/easeo-website — wacht op eurolight kickoff. --- composer.json | 12 ++- packages/cms-core/CHANGELOG.md | 7 ++ packages/shop-module/.gitignore | 3 + packages/shop-module/README.md | 33 +++++++ packages/shop-module/composer.json | 31 +++++++ packages/shop-module/phpunit.xml | 17 ++++ packages/shop-module/src/Module.php | 44 +++++++++ .../src/Payment/NotImplementedException.php | 15 +++ .../src/Payment/PaymentProvider.php | 47 ++++++++++ .../src/Payment/PaymentProviderFactory.php | 50 ++++++++++ .../shop-module/src/Payment/PaymentStatus.php | 14 +++ .../src/Payment/Providers/AdyenProvider.php | 36 ++++++++ .../src/Payment/Providers/MollieProvider.php | 36 ++++++++ .../src/Payment/Providers/StripeProvider.php | 36 ++++++++ .../shop-module/src/Payment/RefundResult.php | 16 ++++ .../shop-module/tests/Module/ModuleTest.php | 39 ++++++++ .../Payment/PaymentProviderFactoryTest.php | 91 +++++++++++++++++++ .../tests/Payment/StubProvidersTest.php | 58 ++++++++++++ packages/shop-module/tests/bootstrap.php | 3 + 19 files changed, 584 insertions(+), 4 deletions(-) create mode 100644 packages/shop-module/.gitignore create mode 100644 packages/shop-module/README.md create mode 100644 packages/shop-module/composer.json create mode 100644 packages/shop-module/phpunit.xml create mode 100644 packages/shop-module/src/Module.php create mode 100644 packages/shop-module/src/Payment/NotImplementedException.php create mode 100644 packages/shop-module/src/Payment/PaymentProvider.php create mode 100644 packages/shop-module/src/Payment/PaymentProviderFactory.php create mode 100644 packages/shop-module/src/Payment/PaymentStatus.php create mode 100644 packages/shop-module/src/Payment/Providers/AdyenProvider.php create mode 100644 packages/shop-module/src/Payment/Providers/MollieProvider.php create mode 100644 packages/shop-module/src/Payment/Providers/StripeProvider.php create mode 100644 packages/shop-module/src/Payment/RefundResult.php create mode 100644 packages/shop-module/tests/Module/ModuleTest.php create mode 100644 packages/shop-module/tests/Payment/PaymentProviderFactoryTest.php create mode 100644 packages/shop-module/tests/Payment/StubProvidersTest.php create mode 100644 packages/shop-module/tests/bootstrap.php diff --git a/composer.json b/composer.json index 0b7fe21..34301b3 100644 --- a/composer.json +++ b/composer.json @@ -15,16 +15,19 @@ "test": [ "@test:core", "@test:tools", - "@test:hello-module" + "@test:hello-module", + "@test:shop-module" ], "test:core": "phpunit --configuration packages/cms-core/phpunit.xml --testsuite cms-core", "test:tools": "phpunit --configuration tools/phpunit.xml", - "test:hello-module": "phpunit --configuration packages/hello-module/phpunit.xml" + "test:hello-module": "phpunit --configuration packages/hello-module/phpunit.xml", + "test:shop-module": "phpunit --configuration packages/shop-module/phpunit.xml" }, "autoload": { "psr-4": { "Easeo\\Cms\\": "packages/cms-core/src/", - "Easeo\\Hello\\": "packages/hello-module/src/" + "Easeo\\Hello\\": "packages/hello-module/src/", + "Easeo\\Shop\\": "packages/shop-module/src/" } }, "autoload-dev": { @@ -32,7 +35,8 @@ "Easeo\\Tools\\": "tools/src/", "Easeo\\Tools\\Tests\\": "tools/tests/", "Easeo\\Cms\\Tests\\": "packages/cms-core/tests/", - "Easeo\\Hello\\Tests\\": "packages/hello-module/tests/" + "Easeo\\Hello\\Tests\\": "packages/hello-module/tests/", + "Easeo\\Shop\\Tests\\": "packages/shop-module/tests/" } } } diff --git a/packages/cms-core/CHANGELOG.md b/packages/cms-core/CHANGELOG.md index b62ed17..ee9a4bc 100644 --- a/packages/cms-core/CHANGELOG.md +++ b/packages/cms-core/CHANGELOG.md @@ -7,6 +7,13 @@ Versionering: [SemVer](https://semver.org/lang/nl/). ## [Unreleased] +### Added — Plan 03 batch 6 (shop-module skeleton) +- **packages/shop-module/** — Shop-module skelet. Registreert alleen een geparkeerde beheer-tab. Payment-provider abstractie beschikbaar via `Easeo\Shop\Payment\PaymentProviderFactory::make()`. +- `Easeo\Shop\Payment\{PaymentProvider, PaymentStatus, RefundResult, NotImplementedException}` — public API. +- 3 stub adapters: `MollieProvider`, `StripeProvider`, `AdyenProvider` — alle methods werpen `NotImplementedException`. Echte implementaties komen bij eurolight-webshop kickoff. +- `PaymentProviderFactory` leest `PAYMENT_PROVIDER` env + bijbehorende API-key env. Throws RuntimeException als config ontbreekt. +- ~20 nieuwe tests via dataProvider-style stub-coverage. + ### Added — Plan 03 batches 3+4+5 (router-integration + nav-merge + hello-module) - `apps/easeo-website/public/module-router.php` — fallback entry that dispatches module-registered routes via `App::registry()->routes()->match()`. Boots App if not already booted; 404-safe on no match, 500-safe on handler exception. - `apps/easeo-website/public/beheer-entry.php` — beheer-UI entry-point that calls `App::boot()` before including `cms-core/beheer/index.php`, ensuring modules are registered before the admin UI renders. diff --git a/packages/shop-module/.gitignore b/packages/shop-module/.gitignore new file mode 100644 index 0000000..f2cc474 --- /dev/null +++ b/packages/shop-module/.gitignore @@ -0,0 +1,3 @@ +vendor/ +.phpunit.cache/ +composer.lock diff --git a/packages/shop-module/README.md b/packages/shop-module/README.md new file mode 100644 index 0000000..b9dfaac --- /dev/null +++ b/packages/shop-module/README.md @@ -0,0 +1,33 @@ +# easeo/shop-module + +EASEO CMS — Shop module skeleton. + +> **Skelet status.** Echte shop-logica (cart, checkout, orders, product-catalog) komt bij de eurolight-webshop kickoff. Op dit moment registreert deze module alleen een geparkeerde beheer-tab en biedt een Payment-provider abstractie met stub adapters. + +## Wat zit er nu wel in? + +- `Easeo\Shop\Module` — registreert een admin-only beheer-tab "Shop" met een "geparkeerd" placeholder +- `Easeo\Shop\Payment\PaymentProvider` interface +- `Easeo\Shop\Payment\PaymentStatus` enum (Pending/Paid/Failed/Expired/Refunded/Cancelled) +- `Easeo\Shop\Payment\RefundResult` value-object +- `Easeo\Shop\Payment\PaymentProviderFactory::make()` — leest `PAYMENT_PROVIDER` env var en instantieert juiste adapter +- 3 stub adapters: `MollieProvider`, `StripeProvider`, `AdyenProvider` — alle methods werpen `NotImplementedException` + +## Configuratie + +In `.env`: + +``` +PAYMENT_PROVIDER=mollie # of stripe / adyen +MOLLIE_API_KEY=test_xxx # alleen vereist als PAYMENT_PROVIDER=mollie +STRIPE_SECRET_KEY=sk_xxx # alleen vereist als PAYMENT_PROVIDER=stripe +ADYEN_API_KEY=AQE... # alleen vereist als PAYMENT_PROVIDER=adyen +``` + +## Wat komt later (eurolight-kickoff) + +- Echte adapter-implementaties (Mollie/Stripe/Adyen API-calls met signature verification) +- Cart engine + product-catalog +- Order-management + admin +- Webhook handlers per provider +- Email-notificaties + invoice-PDFs (via `easeo/cms-core` Mailer) diff --git a/packages/shop-module/composer.json b/packages/shop-module/composer.json new file mode 100644 index 0000000..59fdef2 --- /dev/null +++ b/packages/shop-module/composer.json @@ -0,0 +1,31 @@ +{ + "name": "easeo/shop-module", + "description": "EASEO CMS — Shop module skeleton with Payment-provider abstraction. Skelet only; eurolight-webshop logic comes later.", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.2", + "easeo/cms-core": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "autoload": { + "psr-4": { + "Easeo\\Shop\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Easeo\\Shop\\Tests\\": "tests/" + } + }, + "extra": { + "easeo": { + "module-class": "Easeo\\Shop\\Module" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/packages/shop-module/phpunit.xml b/packages/shop-module/phpunit.xml new file mode 100644 index 0000000..2b507b9 --- /dev/null +++ b/packages/shop-module/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/packages/shop-module/src/Module.php b/packages/shop-module/src/Module.php new file mode 100644 index 0000000..213d03c --- /dev/null +++ b/packages/shop-module/src/Module.php @@ -0,0 +1,44 @@ +beheerPage( + 'shop', + 'Shop', + // Shopping cart icon (Heroicons outline) + 'M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z', + function (): string { + return '
' + . '

Shop module — skelet

' + . '

Deze module is geparkeerd. Echte cart/checkout/orders logica komt bij de eurolight-webshop kickoff.

' + . '

Payment-provider abstractie is wel beschikbaar: Easeo\\Shop\\Payment\\PaymentProviderFactory. Configureer PAYMENT_PROVIDER in .env.

' + . '
'; + }, + $this->name(), + true // admin-only + ); + } +} diff --git a/packages/shop-module/src/Payment/NotImplementedException.php b/packages/shop-module/src/Payment/NotImplementedException.php new file mode 100644 index 0000000..12d6852 --- /dev/null +++ b/packages/shop-module/src/Payment/NotImplementedException.php @@ -0,0 +1,15 @@ + $metadata Provider-side metadata (order-id, customer-id, etc.) + * @return array{paymentId:string, checkoutUrl:string} + */ + public function createPayment(int $amountCents, string $currency, string $description, string $webhookUrl, string $returnUrl, array $metadata = []): array; + + /** + * Verify a webhook delivery. Providers send a POST with an id; this + * method confirms authenticity (signature check) and fetches the + * current status from the provider's API. + */ + public function verifyWebhook(array $headers, string $rawBody): PaymentStatus; + + /** + * Fetch the current status of an existing payment. + */ + public function fetchStatus(string $paymentId): PaymentStatus; + + /** + * Refund a (partially or fully) paid payment. + */ + public function refund(string $paymentId, int $amountCents, ?string $reason = null): RefundResult; +} diff --git a/packages/shop-module/src/Payment/PaymentProviderFactory.php b/packages/shop-module/src/Payment/PaymentProviderFactory.php new file mode 100644 index 0000000..9a6ff87 --- /dev/null +++ b/packages/shop-module/src/Payment/PaymentProviderFactory.php @@ -0,0 +1,50 @@ + new MollieProvider(self::requireKey('MOLLIE_API_KEY')), + 'stripe' => new StripeProvider(self::requireKey('STRIPE_SECRET_KEY')), + 'adyen' => new AdyenProvider(self::requireKey('ADYEN_API_KEY')), + default => throw new \InvalidArgumentException( + "Onbekende PAYMENT_PROVIDER '$name'. Geldig: " . implode(', ', self::PROVIDERS) + ), + }; + } + + public static function knownProviders(): array + { + return self::PROVIDERS; + } + + private static function requireKey(string $envVar): string + { + $value = Environment::get($envVar, ''); + if ($value === null || $value === '') { + throw new \RuntimeException( + "Payment provider vereist $envVar in .env (niet ingesteld of leeg)" + ); + } + return $value; + } +} diff --git a/packages/shop-module/src/Payment/PaymentStatus.php b/packages/shop-module/src/Payment/PaymentStatus.php new file mode 100644 index 0000000..8f5fdc3 --- /dev/null +++ b/packages/shop-module/src/Payment/PaymentStatus.php @@ -0,0 +1,14 @@ +assertSame('easeo/shop-module', $m->name()); + $this->assertNotEmpty($m->version()); + } + + public function test_register_adds_shop_beheer_tab_only(): void + { + $r = new Registry(); + (new Module())->register($r); + + $pages = $r->beheerPages()->all(); + $this->assertArrayHasKey('shop', $pages); + $this->assertTrue($pages['shop']['admin_only']); + $this->assertSame(0, $r->routes()->count(), 'shop-module registers no public routes (skelet)'); + } + + public function test_renderer_emits_skeleton_card(): void + { + $r = new Registry(); + (new Module())->register($r); + $page = $r->beheerPages()->get('shop'); + $html = ($page['renderer'])(); + $this->assertStringContainsString('geparkeerd', $html); + $this->assertStringContainsString('PaymentProviderFactory', $html); + } +} diff --git a/packages/shop-module/tests/Payment/PaymentProviderFactoryTest.php b/packages/shop-module/tests/Payment/PaymentProviderFactoryTest.php new file mode 100644 index 0000000..552b162 --- /dev/null +++ b/packages/shop-module/tests/Payment/PaymentProviderFactoryTest.php @@ -0,0 +1,91 @@ + */ + private array $savedEnv = []; + + protected function setUp(): void + { + foreach (['PAYMENT_PROVIDER', 'MOLLIE_API_KEY', 'STRIPE_SECRET_KEY', 'ADYEN_API_KEY'] as $k) { + $this->savedEnv[$k] = getenv($k); + putenv($k); + } + Environment::reset(); + } + + protected function tearDown(): void + { + foreach ($this->savedEnv as $k => $v) { + $v === false ? putenv($k) : putenv("$k=$v"); + } + Environment::reset(); + } + + public function test_make_throws_when_provider_not_set(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('PAYMENT_PROVIDER'); + PaymentProviderFactory::make(); + } + + public function test_make_throws_for_unknown_provider(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('paypal'); + PaymentProviderFactory::make('paypal'); + } + + public function test_make_throws_when_api_key_missing(): void + { + putenv('PAYMENT_PROVIDER=mollie'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('MOLLIE_API_KEY'); + PaymentProviderFactory::make(); + } + + public function test_make_mollie(): void + { + putenv('PAYMENT_PROVIDER=mollie'); + putenv('MOLLIE_API_KEY=test_xxx'); + $p = PaymentProviderFactory::make(); + $this->assertInstanceOf(MollieProvider::class, $p); + $this->assertSame('mollie', $p->name()); + } + + public function test_make_stripe(): void + { + putenv('PAYMENT_PROVIDER=stripe'); + putenv('STRIPE_SECRET_KEY=sk_test_x'); + $this->assertInstanceOf(StripeProvider::class, PaymentProviderFactory::make()); + } + + public function test_make_adyen(): void + { + putenv('PAYMENT_PROVIDER=adyen'); + putenv('ADYEN_API_KEY=AQE...'); + $this->assertInstanceOf(AdyenProvider::class, PaymentProviderFactory::make()); + } + + public function test_make_uppercase_provider_name_works(): void + { + putenv('PAYMENT_PROVIDER=MOLLIE'); + putenv('MOLLIE_API_KEY=test'); + $this->assertInstanceOf(MollieProvider::class, PaymentProviderFactory::make()); + } + + public function test_known_providers_list(): void + { + $this->assertSame(['mollie', 'stripe', 'adyen'], PaymentProviderFactory::knownProviders()); + } +} diff --git a/packages/shop-module/tests/Payment/StubProvidersTest.php b/packages/shop-module/tests/Payment/StubProvidersTest.php new file mode 100644 index 0000000..81a728e --- /dev/null +++ b/packages/shop-module/tests/Payment/StubProvidersTest.php @@ -0,0 +1,58 @@ + [MollieProvider::class, 'mollie'], + 'stripe' => [StripeProvider::class, 'stripe'], + 'adyen' => [AdyenProvider::class, 'adyen'], + ]; + } + + #[DataProvider('provider_classes')] + public function test_provider_name_matches(string $class, string $expectedName): void + { + $p = new $class('fake-api-key'); + $this->assertSame($expectedName, $p->name()); + } + + #[DataProvider('provider_classes')] + public function test_create_payment_throws_not_implemented(string $class): void + { + $this->expectException(NotImplementedException::class); + (new $class('fake'))->createPayment(1000, 'EUR', 'test', 'https://x/webhook', 'https://x/return'); + } + + #[DataProvider('provider_classes')] + public function test_verify_webhook_throws(string $class): void + { + $this->expectException(NotImplementedException::class); + (new $class('fake'))->verifyWebhook([], ''); + } + + #[DataProvider('provider_classes')] + public function test_fetch_status_throws(string $class): void + { + $this->expectException(NotImplementedException::class); + (new $class('fake'))->fetchStatus('pmt-x'); + } + + #[DataProvider('provider_classes')] + public function test_refund_throws(string $class): void + { + $this->expectException(NotImplementedException::class); + (new $class('fake'))->refund('pmt-x', 500); + } +} diff --git a/packages/shop-module/tests/bootstrap.php b/packages/shop-module/tests/bootstrap.php new file mode 100644 index 0000000..e17a5e7 --- /dev/null +++ b/packages/shop-module/tests/bootstrap.php @@ -0,0 +1,3 @@ +