Appearance
Architecture & extension points
For developers
Namespace Kommandhub\PaystackSW\ → src/. Plugin class Kommandhub\PaystackSW\KmhPaystackSW (technical name KmhPaystackSW; KommandhubPaystackSW before 0.9.0-beta.3).
Modules
| Module | Responsibility |
|---|---|
Checkout/Payment/Handler | PaystackPaymentHandler — Shopware's payment handler (pay, finalize, refund); delegates to services. |
Checkout/Payment/Service | PaymentProcessor (initialize), FinalizeProcessor (verify + mark paid), RefundProcessor, RefundAggregator, TransactionVerificationProcessor, TransactionMetadataProcessor, PayloadBuilder, TransactionService, OrderTransactionService. |
Checkout/Cart | CartValidator — blocks checkout when the secret key for the active mode is missing. |
Webhook | Controller, WebhookSignatureValidator, WebhookEventFactory, WebhookProcessor, RefundInitializeService, subscribers. |
Administration/Controller | RefundController — the refund API used by the order tab. |
BankVerification | Storefront controller for bank list, account resolution and saving bank details. |
Client | PaystackClient with one typed class per Paystack endpoint under Client/Resource/. |
DataAbstractionLayer | Reader/writer gateways for order transactions and refunds. |
Installer | PaymentMethodInstaller, CustomFieldsInstaller. |
Util | PaystackConstants (custom-field keys), PaystackCurrencyHelper (minor units), OrderCurrencyResolver. |
Payment flow
text
PaystackPaymentHandler::pay()
└─ PaymentProcessor
├─ PayloadBuilder amount in minor units, currency, email, callback_url,
│ metadata (always incl. order_transaction_id),
│ optional split parameters
├─ POST /transaction/initialize
├─ persist custom field paystack_reference on the order transaction
└─ redirect to authorization_url
customer returns ?reference=… (no reference → use the stored paystack_reference)
PaystackPaymentHandler::finalize()
└─ FinalizeProcessor
├─ already "paid"? → no-op
├─ TransactionVerificationProcessor
│ GET /transaction/verify/{reference}
│ assert amount (minor units) AND currency match;
│ failed/reversed → fail; pending/processing/queued/ongoing/abandoned → leave open
├─ TransactionMetadataProcessor persist Paystack data as custom fields
├─ OrderTransactionStateHandler::paid()
└─ dispatch PaymentFinalizedEventA return without reference (cancel/back, some failures) verifies the reference stored at initialization instead; if Paystack reports that attempt as abandoned, the transaction is cancelled (customerCanceled). Only when no reference is stored at all is it treated as a cancellation without verification. A failed verification interrupts finalization (asyncFinalizeInterrupted) so Shopware shows the payment-failed page and the transaction becomes failed.
Webhook reconciliation. charge.success is looked up by paystack_reference (stored at initialization), falling back to metadata.order_transaction_id when a retry has overwritten the stored reference, and run through the same FinalizeProcessor, so the redirect and the webhook cannot diverge. Both are idempotent: a transaction already paid is skipped.
Refund flow
text
Admin → POST /api/_action/paystack/refund
├─ _acl paystack.refund, refundEnabled, transaction state paid / partially_paid / partially_refunded
├─ min (minimumRefundAmount) and max (captures − completed/in-progress refunds) in minor units
└─ POST /refund at Paystack
refund.pending → RefundInitializeService: create capture + refund in Shopware
(externalReference = "<refund id>-<transaction_reference>", deduplicated,
bounded by the transaction total)
refund.processed → find refund by externalReference (create it if pending never arrived),
check amount matches, then Shopware's PaymentRefundProcessor
→ PaystackPaymentHandler::refund() → RefundProcessor
→ refund completed; transaction refunded / partially refundedData written
Order transaction custom fields (keys in PaystackConstants):
| Key | Content |
|---|---|
paystack_reference | Paystack reference. Written at initialization; the webhook lookup key. |
paystack_transaction_id | Paystack transaction ID |
paystack_payment_type | Channel (card, bank_transfer, ussd…) |
paystack_transaction_fee | Fee, converted from minor units |
paystack_amount | Amount charged, converted from minor units |
paystack_currency | Currency reported by Paystack |
paystack_verified_at | Verification timestamp |
Customer custom fields — the bank-details set kmh_paystack_fieldset, see Customer bank details.
The plugin ships no migrations and no tables of its own. The payment method (technical name kmh_paystack_payment) and the custom-field set are created by installers on install() and re-applied on update(). On first creation the payment method is assigned to all sales channels; update() only refreshes its handler and active flag and never re-assigns channels.
Events you can subscribe to
| Event | When | Payload |
|---|---|---|
Kommandhub\PaystackSW\Checkout\Payment\Event\PaymentFinalizedEvent | After a payment was verified and the transaction set to paid (redirect or webhook) | getOrder(), getOrderTransaction(), getPaymentTransactionStruct(), getContext() |
Kommandhub\PaystackSW\Webhook\Event\ChargeSuccessEvent | A signed charge.success webhook arrived | getData() (Paystack data object), getContext() |
Kommandhub\PaystackSW\Webhook\Event\RefundPendingEvent | A signed refund.pending webhook arrived | same |
Kommandhub\PaystackSW\Webhook\Event\RefundProcessedEvent | A signed refund.processed webhook arrived | same |
php
use Kommandhub\PaystackSW\Checkout\Payment\Event\PaymentFinalizedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
final class NotifyWarehouseOnPayment
{
#[AsEventListener]
public function __invoke(PaymentFinalizedEvent $event): void
{
$orderNumber = $event->getOrder()->getOrderNumber();
// …
}
}The webhook events fire after signature verification but before the plugin's own subscribers have finished; treat getData() as provider data, not as confirmed state. Other Paystack webhook event types are acknowledged and logged, not dispatched.
Replaceable services
The finalizer depends on two interfaces, aliased in services.yml, which you can decorate:
Checkout\Payment\Service\TransactionVerificationProcessorInterface— verification rules.Checkout\Payment\Service\TransactionMetadataProcessorInterface— what is persisted after verification.
Keep the status + amount + currency checks if you decorate verification: the reference comes from an attacker-controllable query string.
Money
Paystack expects minor units (kobo, pesewas). All conversions go through Util\PaystackCurrencyHelper::toMinorUnit() / fromMinorUnit(), which know each currency's decimals (NGN 2, XOF/RWF 0, KWD 3). Never multiply by 100 directly.
Security notes
- Webhook signature: HMAC-SHA512 of the raw body with the secret key, compared with
hash_equals; missing header or missing key →403. The key is read from the global configuration scope. - Verification checks status, amount and currency before marking paid.
- Refund endpoint: route
_aclpaystack.refund, server-side min/max, currency resolved from the order (fails closed when unknown). - Webhook refund amounts are checked against the Shopware refund and the transaction total.
Development setup & testing
bash
git clone https://github.com/KommandHub-Org/KmhPaystackSW.git
cd KmhPaystackSW
make up # Shopware + this plugin in container `kmh-paystack-plugin`
make shell # then: bin/console plugin:install --activate KmhPaystackSW
make cs-fix && make analyse && make test # the gate to pass before every commit
make test FILTER=SomeTest
make test-coverage
make validate-plugin # Shopware Store compliance (shopware-cli, inside the container)
make zip # release ZIP into build/tests/Unitmirrorssrc/and needs no kernel; tests that boot Shopware carry#[Group('kernel')]and are excluded in CI.- CI (GitHub Actions,
.github/workflows/php.yml) runs composer validate, PHP lint, PHPStan level 9, php-cs-fixer and PHPUnit, and enforces 100 % line coverage. - Webhook and payment flows are tested manually against a Paystack test key; there is no automated end-to-end suite yet. For local webhook testing use a tunnel and add its hostname as a domain on a sales channel.
make downdeletes the stack's database volume; the stack publishes no host port by default (addports: ["80:80"]to reach the shop in a browser). Shared tooling: Development environment.