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 @@
+