Bank Payments via Nello Pay for WooCommerce is a WooCommerce payment gateway for one-time account-to-account payments through the hosted Nello Pay checkout of Neonomics AS (Checkout API). One store serves one market: Norway (NOK), Sweden (SEK), Denmark (DKK) or Finland (EUR). The customer-facing payment method is Pay by Bank.
This repository holds the complete source of Nello Pay for WooCommerce — PHP, TypeScript and
SCSS — and the tooling that builds and tests the release ZIP. It is the developer documentation;
the merchant documentation is readme.txt.
- At a glance
- What the provider dictates
- Payment flow
- Checkout request
- Payment state machine
- Webhook
- Idempotency and concurrency
- Time rules instead of polling
- Data model
- Public API
- Security model
- Privacy
- Repository layout
- Development
- Quality gates
- Continuous integration and release
- Translations
- Coding standards
- Compatibility
- Scope
- License
| Slug, text domain, logger source, Action Scheduler group, WP-CLI command | buckmerce-nellopay |
| Main file | buckmerce-nellopay.php |
PHP namespace (PSR-4, src/) |
Buckmerce\NelloPay (tests: Buckmerce\NelloPay\Tests) |
| Gateway ID | buckmerce_nellopay |
| Settings option | woocommerce_buckmerce_nellopay_settings |
| Options, hooks, filters, scheduled actions | buckmerce_nellopay_* |
| Tables | {prefix}buckmerce_nellopay_payment_locks, {prefix}buckmerce_nellopay_events |
| Order meta | _bmnp_* |
| Transients, nonces, query arguments, CSS classes, data attributes | bmnp_*, .bmnp-*, data-bmnp-* |
| REST namespace | buckmerce-nellopay/v1 |
| Constants and environment variables | BUCKMERCE_NELLOPAY_* |
| Runtime dependencies | none: no Composer package, no bundled library, no vendor/ in the ZIP |
| Baseline | PHP 8.1, WordPress 6.6, WooCommerce 8.7, declare(strict_types=1) everywhere |
| Listing name on WordPress.org | Buckmerce – Bank Payments via Nello Pay for WooCommerce |
Two properties of the Nello Pay Checkout API shape the whole design:
- There is no endpoint to read a checkout or a payment. The authenticated webhook is the only source of payment truth. Every update is therefore stored durably before it is acknowledged, and silence is handled by local time rules, not by polling.
- There is no endpoint to refund a payment. The gateway declares
supports = ['products']only. A refund is a bank transfer recorded with WooCommerce's manual refund.
Further facts the code relies on: the create request has no idempotency key; webhook updates carry no event ID, signature, timestamp or nonce (a shared key over TLS is the whole authentication); updates of one payment are delivered in order, at least once, and redelivered for up to seven days; a hosted checkout address is bound to the first client that opens it.
Only two endpoints are ever called, and only by NelloPay\Client\NelloPayClient, which holds
them in an allowlist:
| Method | Path | Host | Used for |
|---|---|---|---|
POST |
/api/v1/checkout-requests |
checkout.sandbox.neonomics.io / checkout.neonomics.io |
create the checkout of one payment attempt |
GET |
/api/v1/checkout-banks/{country} |
same | connection test, wp buckmerce-nellopay banks |
The API key is sent in the api-key and x-api-key headers. The host is derived from the
Environment setting alone; there is no host or URL setting. The Neonomics UAPI
(/ics/v3/…, sessions, consents, access tokens) is not used.
WooCommerce order
↓
process_payment(): order mutex + durable reservation
↓
POST /api/v1/checkout-requests (server side, API key)
↓ 201 { id, redirectUrl }
redirect to the hosted Nello Pay checkout (bank selection, bank login, approval)
↓ ↘
customer returns to WooCommerce Nello Pay calls the webhook with every status change
(navigation and a hint, never evidence) (shared key)
↓
authenticate → store → acknowledge → apply
↓
payment state machine
↓
WooCommerce order (paid only on PAYMENT_COMPLETED)
Invariants every change has to preserve:
- Browser state — a return address, a query argument, a script — is never payment state.
- Amount and currency come from the WooCommerce order on the server; money is a decimal string
end to end (
Support\Decimal,Support\Money,Support\ExactJson), never a binary float — neither for comparison nor on the wire. - One intended payment never creates two checkouts a customer can pay at the same time.
- A webhook request without the correct key is never parsed, stored or applied.
- Duplicate, late, out-of-order and contradictory updates never regress or overwrite a state.
- Only
PAYMENT_COMPLETED(or the merchant's explicit, recorded confirmation) pays an order. - Money that cannot be confirmed or assigned reaches the merchant as an alert; it is never silently ignored and never silently applied.
- A database or locking failure fails closed before the remote request.
- A Sandbox attempt never reaches Production and the reverse.
NelloPay\Checkout\CheckoutRequest::build() assembles the body from the immutable
Payment\PaymentSnapshot of the attempt, the order and the settings:
| Field | Source |
|---|---|
referenceId |
{order ID}-{first 16 hex characters of the attempt ID} |
amount, currency |
the order total as an exact JSON number literal; the order currency |
country |
the configured market: NO, SE, DK or FI |
language |
from determine_locale(): NB, SV, DA, FI, otherwise EN |
remittanceInfo |
the Payment message template with {order_number}; cut to the market limit (140, Sweden 10, Denmark 20), falling back to the order number alone |
clientName |
Store name in Nello Pay or the site title, at most 70 characters |
successUrl, failUrl, cancelUrl |
order pages carrying bmnp_return and bmnp_attempt |
autoRedirect |
true |
Nothing personal is sent: no name, no email address, no postal address. Optional fields come only
through the allowlisted filter buckmerce_nellopay_checkout_options.
The answer is accepted only when redirectUrl is an https address on neonomics.io or a
subdomain, on the default port and without credentials; otherwise it is rejected and no redirect
happens. A timeout, a connection error, a 5xx answer or an unreadable success body is an
unknown outcome: it is never retried into and its address is never handed out — a retry
creates a new checkout and the earlier one stays in the attempt history.
Payment\PaymentStateMachine decides, Payment\OrderPaymentProjector writes. Nothing else
changes a payment state.
| Nello Pay status | Payment state | WooCommerce order |
|---|---|---|
| — | checkout_creating, checkout_created |
Pending payment |
| create request refused / unknown outcome | checkout_rejected / checkout_uncertain |
Pending payment, payable again |
STARTED |
checkout_started |
Pending payment |
PAYMENT_CREATED |
payment_created |
Pending payment |
PAYMENT_INITIATED (approved) |
payment_initiated |
On hold |
PAYMENT_COMPLETED (funds received) |
completed |
payment_complete() → Processing / Completed; transaction ID = checkout ID |
PAYMENT_NONTRACKABLE |
nontrackable |
On hold, alert; the merchant confirms or rejects the funds |
FAILED, PAYMENT_FAILED |
failed |
Failed, payable again |
CANCELLED, PAYMENT_CANCELLED |
cancelled |
payable again |
TIMED_OUT |
expired |
payable again |
| no update for 2 hours | abandoned (local) |
payable again; later updates still apply |
| mismatch, money for a closed order, contradiction | manual_review |
automatic processing stops, alert and email |
PaymentStateMachine::decide(from, to) returns one of four decisions:
| Decision | Meaning | Effect |
|---|---|---|
apply |
an allowed transition | state, timestamps, order note, order status, hooks |
noop |
the same state again (redelivery) | nothing new; a half-finished projection is completed |
stale |
older or weaker evidence than what is recorded | ignored, inbox row marked processed |
conflict |
contradicts the recorded outcome | never applied; logged, alert, order note and email |
Later states are reachable directly (checkout_created → completed), because an update may be
lost. completed, failed, cancelled and expired are final. manual_review is left
automatically only towards an outcome without money. A new attempt for the same order does not
transition the old one: the old attempt is archived on the order (_bmnp_retired_attempts) and
late updates for it are matched there — adopted when they still fit, reported as a possible
duplicate payment otherwise. The transition table and the ordering matrix exist a second time as
tests written independently of the implementation (tests/Unit/PaymentStateMachineTest.php,
tests/Unit/StateOrderingMatrixTest.php).
POST /wp-json/buckmerce-nellopay/v1/webhook — REST\WebhookController.
Authentication runs in the permission callback, before anything is parsed:
- body over 64 KiB →
413; - no acceptable key configured (16–128 printable characters without spaces) →
503: the route is closed, not open; - key read from
api-key, orx-api-keywhen that is absent; missing or wrong →401. The comparison ishash_equals()over the SHA-256 of both values.
Every refusal has the same body (buckmerce_invalid_webhook), is logged with the reason and a
SHA-256 of the request body — never the body, never the presented key — and is recorded at most
once a minute for the status panel.
Handling of an authenticated request:
parse → record → schedule background job → apply (2 s budget) → 200 {"received":true}
| Step | Detail |
|---|---|
| Parse | NelloPay\Webhook\WebhookParser: only the fields of NelloPay\DTO\WebhookEvent are kept; debtor and creditor are dropped; numbers are read as text. An authenticated but unusable body is acknowledged with 200, because Nello Pay would otherwise hold back every later update of that payment |
| Record | Persistence\WebhookEventStore::record(): INSERT IGNORE keyed by the SHA-256 of the reduced update. A database error answers 503, so the update is delivered again |
| Schedule | a unique Action Scheduler action, enqueued before applying, so a crash still leaves a job |
| Apply | Background\EventProcessingService applies the stored updates of that checkout, oldest first, under the order mutex; a busy order defers the update, it is never dropped |
Inbox row statuses: received, processing (owner token, 120-second lease), processed,
ignored (unknown_checkout, unknown_status, reference_mismatch, batch_checkout), retry,
abandoned (after 20 attempts). An update is applied only when the order knows its checkout ID
as the current or an earlier attempt, the update repeats the reference of that attempt, and a
reported amount and currency equal the snapshot (a bank does not always report the amount).
| Mechanism | Scope | Property |
|---|---|---|
Persistence\DatabaseMutex (GET_LOCK) |
one order, all PHP workers of the database | bound to the connection: released when the worker dies; acquired without waiting; ownership re-asserted before the remote request |
Reservation row in …_payment_locks |
checkout creation of one order | durable; owner token and 120-second lease; records how far creation got (preparing, creating, created, uncertain, failed, retired) |
Payment\PaymentSnapshot on the order |
one attempt | immutable; written and read back before any remote request |
| Payment index (the same row) | checkout ID → order | written before the order meta, so an update is matched even when the order save failed |
Inbox row in …_events (unique event_key) |
one update | a duplicate cannot be inserted; a claim is an atomic conditional UPDATE |
Payment\PaymentStateMachine |
one attempt | the same or an older update changes nothing |
mutex(order) ← second worker: "already being prepared", nothing sent
reuse? same order, amount, currency, environment and key, address younger than 10 minutes,
customer has not returned → hand out the stored address, no request
reservation.acquire() ← database error → fail closed
order: snapshot + state checkout_creating, saved and read back
mutex.assert_owned() · reservation.begin_creation() · mutex.assert_owned()
─────────────────────── the only remote side effect ───────────────────────
POST /api/v1/checkout-requests
reservation.mark_created(checkout ID, address) ← reservation lost → the address is discarded
order: checkout ID, address, state checkout_created
Tolerated without a second payable checkout or a second effect: double clicks, a repeated
process_payment(), reloads and the back button, a customer who returns several times or never, a
timeout or 5xx after the request was sent, a worker crash at any point, webhook redelivery and
replay, two updates arriving together, Action Scheduler retries, two concurrent PHP workers, and
a payment through a checkout that was already replaced. tests/Integration/concurrency.php
proves the concurrent cases with real parallel PHP processes.
Payment\MonitoringPolicy holds every duration; nothing polls Nello Pay.
| Rule | Value |
|---|---|
| A stored checkout address is handed out again | for 10 minutes |
| Nello Pay keeps a checkout address usable / ends an idle hosted session | 15 minutes / 15 minutes after the last activity (5 more minutes are allowed for a late update) |
An unapproved checkout without any update becomes abandoned |
after 2 hours |
| An approved payment without confirmation raises an alert | after 5 days |
| Reconciliation (inbox drain and due local checks) | every 15 minutes, Action Scheduler |
Maintenance of existing payments never depends on the gateway's enabled switch: it runs while open payments exist.
Schema version 1 (buckmerce_nellopay_schema_version, Persistence\Installer); dbDelta() on
activation, every column and index verified afterwards. A missing table makes payment creation
fail closed.
{prefix}buckmerce_nellopay_payment_locks — one row per order that ever started a bank
payment; reservation and payment index. Columns: order_id (primary key), environment,
status, owner_token, lease_expires_at, attempts, attempt_id, snapshot_hash,
checkout_id, redirect_url, payment_state, account_fp, reconcile_after, error_code,
created_at, updated_at.
{prefix}buckmerce_nellopay_events — webhook inbox, one row per distinct authenticated
update. Columns: id, event_key (unique), checkout_id, reference_id, provider_status,
order_id, event_data (the reduced update as JSON), status, owner_token,
lease_expires_at, attempts, error_code, provider_modified_at, created_at, updated_at,
processed_at.
Order meta (WooCommerce CRUD only, HPOS and legacy storage alike): _bmnp_payment_state,
_bmnp_payment_snapshot, _bmnp_environment, _bmnp_account_fingerprint, _bmnp_checkout_id,
_bmnp_redirect_url, _bmnp_checkout_status, _bmnp_transaction_status, timestamps
(_bmnp_checkout_created_at, _bmnp_last_activity_at, _bmnp_initiated_at,
_bmnp_completed_at, _bmnp_last_event_at), _bmnp_end_to_end_id, _bmnp_abort_reason,
_bmnp_failure_code, _bmnp_correlation_id, _bmnp_payee_verification, _bmnp_return_result,
and the order-level records _bmnp_retired_attempts, _bmnp_manual_review_reason,
_bmnp_manual_review_resolution, _bmnp_merchant_decision, _bmnp_overdue_alerted.
Options: woocommerce_buckmerce_nellopay_settings and buckmerce_nellopay_*
(schema_version, payment_alerts, last_connection_test, last_checkout_error,
last_webhook, last_webhook_rejection, last_reconciliation, last_reconciliation_error).
Transients: bmnp_* — rate limiting and one-time admin messages.
Never stored anywhere: the raw webhook body or API response, the payer's and payee's names and account numbers, a key outside the settings option.
Deactivation removes the scheduled actions only. Uninstall keeps everything unless the merchant enabled Uninstall cleanup; then the two tables and the listed options and transients are removed by explicit name. Payment records on orders are never deleted.
Routes, hooks, WP-CLI commands and the identifiers above are stable within a major version. Classes, methods, table layouts and order meta are internal.
| Route | Method | Caller | Authorisation | Answers |
|---|---|---|---|---|
/payment-status |
POST |
the order-received page | order_id + order_key + payment_nonce; an order of a registered customer also needs that login; 90 answers per order in 10 minutes |
200 {status, final, message} · 403 buckmerce_forbidden · 429 buckmerce_rate_limited |
/webhook |
POST |
Nello Pay | shared key in api-key (or x-api-key) |
200 {"received":true} · 401 / 413 / 503 buckmerce_invalid_webhook |
status is one of none, pending, authorized, paid, review, failed, cancelled,
expired. The route is read-only and answers every refusal with the same 403.
| Action | Arguments | Fired |
|---|---|---|
buckmerce_nellopay_checkout_created |
WC_Order $order, string $checkout_id |
after a checkout request was created and stored |
buckmerce_nellopay_payment_state_changed |
WC_Order $order, string $from (new for the first state), string $to |
after every applied transition |
buckmerce_nellopay_payment_confirmed |
WC_Order $order, string $checkout_id |
after payment_complete() |
buckmerce_nellopay_payment_failed |
WC_Order $order, string $reason_code |
when an attempt becomes failed |
buckmerce_nellopay_payment_cancelled |
WC_Order $order |
when an attempt becomes cancelled |
buckmerce_nellopay_payment_nontrackable |
WC_Order $order |
when the bank stopped reporting on an approved payment |
buckmerce_nellopay_payment_manual_review |
WC_Order $order, string $reason |
when a payment is quarantined |
buckmerce_nellopay_webhook_processed |
string $checkout_id, string $status, string $outcome |
after an authenticated update was stored (processed, queued, duplicate, stored) |
State actions fire once per real change, after the state was saved; a replayed or stale update
fires none. A listener must not throw, and only …_payment_confirmed means "the order is paid".
add_action('buckmerce_nellopay_payment_confirmed', static function (WC_Order $order, string $checkout_id): void {
// Funds were received for $order; $checkout_id is also the order's transaction ID.
}, 10, 2);| Filter | Arguments | Return |
|---|---|---|
buckmerce_nellopay_checkout_options |
array $options (empty), WC_Order $order |
optional checkout fields: creditorAccount (creditorName + iban, or bban and optionally bic), creditorAddress (creditorStreetName, creditorBuildingNumber, creditorPostalCode, creditorCity, creditorCountry), customerLogoName, whiteLabel. Everything else and every malformed value is dropped; amount, currency, reference and return addresses cannot be changed |
buckmerce_nellopay_alert_recipient |
string $recipient, WC_Order $order |
comma-separated recipients of alert emails |
add_filter('buckmerce_nellopay_checkout_options', static function (array $options, WC_Order $order): array {
$options['creditorAccount'] = array('creditorName' => 'Example Shop AS', 'iban' => 'NO9386011117947');
return $options;
}, 10, 2);wp buckmerce-nellopay status # diagnostics report, no secrets; exit 0
wp buckmerce-nellopay test-connection # read-only request to Nello Pay; exit 0 connected, 1 otherwise
wp buckmerce-nellopay banks [--country=<NO|SE|DK|FI>] # banks of a market; exit 1 on an error or an unknown market
wp buckmerce-nellopay process-events # apply stored webhook updates now
wp buckmerce-nellopay reconcile # one reconciliation pass (inbox + due local checks)| Kind | Value |
|---|---|
| Action Scheduler hooks | buckmerce_nellopay_process_events, buckmerce_nellopay_reconcile, buckmerce_nellopay_reconcile_continue |
| Admin page | buckmerce-nellopay-health (WooCommerce → Nello Pay Health) |
| Script and style handles | buckmerce-nellopay-admin, buckmerce-nellopay-blocks, buckmerce-nellopay-status |
| Return address arguments | bmnp_return (success, fail, cancel), bmnp_attempt |
| Checkout block payment method | buckmerce_nellopay (settings data key buckmerce_nellopay_data) |
| Constants | BUCKMERCE_NELLOPAY_VERSION, BUCKMERCE_NELLOPAY_FILE, BUCKMERCE_NELLOPAY_DIR, BUCKMERCE_NELLOPAY_URL |
| Boundary | Trusted | Control |
|---|---|---|
| Customer browser → WordPress | no | server-side amounts and state only; order key, ownership and nonce checks; nothing the browser sends changes a payment |
| Nello Pay → webhook route | only with the shared key | constant-time key check before parsing; size limit; checkout ID, reference and amount checks; state machine |
| WordPress → Nello Pay | TLS-verified, fixed hosts | endpoint allowlist, no redirects followed, API key in headers only |
Return address (successUrl, failUrl, cancelUrl) |
no | navigation and a hint, never evidence |
| wp-admin → payment operations | by capability | manage_woocommerce and a nonce on every action |
| Logs and diagnostics → people | partially | recursive redaction (Logging\Redactor); fingerprints instead of secrets |
- The API key and the webhook key are write-only settings: never rendered into HTML, never sent
to the browser, never logged, and returned empty by the WooCommerce REST API
(
Settings\RestSecretsGuard); diagnostics show "configured" and a non-reversible fingerprint. - A forged update needs the key and a checkout ID the order knows and the matching reference (64 random bits per attempt); a reported amount and currency must equal the snapshot. An update without a reference is ignored like one with a wrong reference.
Settings\WebhookKeyGuardrefuses to remove the webhook key while Production payments are open.- Every SQL statement with a value is prepared; table names come from constants and
$wpdb->prefix(%i). Everything is escaped on output. Redirects performed by the code usewp_safe_redirect(); the redirect to Nello Pay uses the validated address. - The front end holds no credentials and no business authority: the status script sends the order identifiers and renders the statuses the server defines.
Sent to Nello Pay when a customer starts a payment: order amount and currency, market, language, a payment message with the order number, the store name, the internal reference and the three return addresses. Never sent: the customer's name, email address or postal address. Never stored or logged: the payer's and payee's names and account numbers that Nello Pay reports. Nothing is sent to anyone else, and no usage data is collected.
buckmerce-nellopay.php plugin header, BUCKMERCE_NELLOPAY_* constants, autoloader, HPOS and
Checkout Blocks compatibility declarations, activation hooks
uninstall.php conservative uninstall
src/
├── Bootstrap.php requirements check, then Plugin::register()
├── Plugin.php hook wiring only
├── Container.php service construction (the Nello Pay client is created lazily)
├── Settings/ typed settings, AccountIdentity (key fingerprint), WebhookKeyGuard
├── Gateway/ WC_Payment_Gateway adapter and availability rules (no provider call)
├── Checkout/ return handling, order-received status view, access checks, Blocks integration
├── NelloPay/
│ ├── Client/ the only HTTP boundary: endpoint allowlist, both key headers, typed errors
│ ├── Checkout/ checkout request builder, checkout service (create, bank list)
│ ├── Webhook/ shared-key authentication, body parser
│ ├── DTO/ CheckoutSession, WebhookEvent, field readers
│ ├── Exception/ API, network, malformed-response and webhook-rejection exceptions
│ └── CheckoutStatus, Market, NelloPayEnvironment, NelloPayClientFactory
├── Payment/ snapshot, states, state machine, attempt service and history, webhook
│ event processor, order projection, monitoring policy, watchdog, manual
│ actions, alerts, notifier, customer status, return URLs
├── Persistence/ installer, payment reservation and index, webhook inbox, database mutex
├── Background/ Action Scheduler jobs: event processing, reconciliation, scheduler
├── Admin/ configuration status, connection test, diagnostics, order panel,
│ orders-list column, Site Health, notices
├── REST/ /payment-status and /webhook controllers
├── CLI/ wp buckmerce-nellopay commands
├── Logging/ logger and redactor
├── Exception/ PluginException hierarchy
└── Support/ Decimal, Money, ExactJson, Requirements
resources/ts|scss|images front-end sources (Parcel)
assets/ compiled CSS, JavaScript and images: generated, git-ignored, shipped
languages/ .pot template, .po sources, compiled .mo and .l10n.php (tracked)
tests/ Unit (PHPUnit), Integration (WP-CLI on a disposable site), E2E (browser
and real Sandbox), js (node --test), fixtures (the Nello Pay double)
scripts/ build, package, verification and test runners; scripts/lib/package-manifest.sh
is the allowlist of the release ZIP
.github/workflows/ quality.yml (every gate), release.yml (tag → publish the tested ZIP)
Dependency direction: adapters (Gateway, Checkout, REST, Admin, CLI, Background) →
services (Payment) → domain (PaymentState, PaymentStateMachine, MonitoringPolicy,
PaymentSnapshot) → provider and storage (NelloPay, Persistence) → Settings, Logging,
Support. WC_Payment_Gateway contains no provider call and no domain rule; provider wire formats
stay inside NelloPay/ and reach the rest of the code as DTOs only.
Prerequisites: PHP 8.1+ with mysqli, Composer 2.10.3 (pinned in CI), Node.js 22, WP-CLI 2.x, a
MySQL or MariaDB server for the disposable test databases, zip, unzip, curl; shellcheck
for the script gate; ngrok for the real Sandbox gate. The pinned Playwright CLI is fetched by
npx.
git clone https://github.com/Buckmerce/NelloPay.git buckmerce-nellopay
cd buckmerce-nellopay
composer install # development tools only; nothing from vendor/ is shipped
npm ci
npm run build # Parcel: resources/ts + resources/scss → assets/
npm run watch # rebuild on changeLocal inputs go into a git-ignored .env (template: .env.example), loaded by
scripts/lib/test-env.sh; database credentials fall back to the [client] section of
~/.my.cnf.
| Variable | Purpose |
|---|---|
BUCKMERCE_NELLOPAY_TEST_DB_HOST, …_DB_USER, …_DB_PASSWORD |
database server of the disposable test sites |
BUCKMERCE_NELLOPAY_TEST_WP_VERSION, …_TEST_WC_VERSION |
WordPress and WooCommerce versions of the disposable sites |
BUCKMERCE_NELLOPAY_TEST_PLUGIN_ZIP |
test another ZIP than dist/buckmerce-nellopay-<version>.zip |
BUCKMERCE_NELLOPAY_EXTERNAL_CHECKS |
strict (default) or warn for the two checks that depend on the day of the run: security advisories and "Tested up to" |
BUCKMERCE_NELLOPAY_SANDBOX_API_KEY, …_SANDBOX_COUNTRY, …_SANDBOX_WEBHOOK_KEY, …_NGROK_DOMAIN |
inputs of the real Sandbox gate |
BUCKMERCE_NELLOPAY_SANDBOX_CREDITOR_NAME, …_SANDBOX_CREDITOR_IBAN |
a Sandbox receiving account, only when the merchant account has none in the market |
Only Sandbox credentials belong in .env. Scripts create and drop their own databases
(buckmerce_test_*, buckmerce_browser_*, buckmerce_check_*, buckmerce_sandbox_*) and refuse
to drop anything else.
Source gates — no WordPress site needed:
composer validate --strict
composer syntax # php -l on every project PHP file
composer lint # PHPCS: PSR-12 plus forbidden functions
composer stan # PHPStan level 6 with WordPress and WooCommerce stubs
composer test # PHPUnit, tests/Unit
npm test # build + typecheck + bundle syntax + node --test tests/js
bash scripts/check-identity.sh # no former working identifier of the project in any file
bash scripts/audit-source.sh # nothing secret or local is tracked or distributed
bash scripts/check-translations.sh # template == sources, translations complete and compiled
bash scripts/audit-dependencies.sh # composer audit --locked, npm audit --audit-level=high
shellcheck -x -P SCRIPTDIR -S warning scripts/*.sh scripts/lib/*.shRelease-ZIP gates — each installs only dist/buckmerce-nellopay-<version>.zip on a disposable
WordPress site with its own database; the source tree is never loaded as a plugin:
npm run plugin-zip # clean asset build + scripts/package.sh (allowlist-built ZIP)
bash scripts/verify-package.sh # allowlist, byte parity with the sources, PHP lint, SHA-256
bash scripts/test-package-smoke.sh # pristine install: a fresh HPOS store and a fresh legacy store
bash scripts/test-integration.sh # every suite with HPOS off and on, concurrency, translations, uninstall
bash scripts/test-plugin-check.sh # WordPress Plugin Check: no error, no warning, no allowlist
bash scripts/test-browser-e2e.sh # Classic and Blocks checkout, webhooks, admin, accessibility (axe-core)
npm run test:sandbox # the real Nello Pay Sandbox over a public HTTPS tunnel| Layer | Where | What it proves |
|---|---|---|
| Unit | tests/Unit/ |
decimal arithmetic and exact JSON, markets, client (allowlist, headers, every error shape), request building, webhook authentication and reduction, snapshot, state machine and ordering matrix, time rules, availability, attempt history, redaction, identity |
| JavaScript | tests/js/ |
the compiled bundles, executed unchanged in a node:vm context: status polling, key generation from the cryptographic random source, Blocks registration |
| Integration | tests/Integration/ |
WP-CLI on a disposable site against tests/fixtures/nellopay-mock.php, a strict double of the provider contract with 16 failure modes: smoke, CLI, payment flow, webhook and REST, lifecycle, maintenance, real parallel processes, ru_RU, uninstall |
| Browser | tests/E2E/browser-smoke.js |
Playwright CLI: settings, Classic Checkout and Checkout block for guests and customers, redirect, status page following webhook updates, refusals, retries, alerts, order panel, WCAG 2.2 AA rules on every owned surface |
| Real Sandbox | scripts/test-sandbox-e2e.sh |
the genuine service: API contract, checkout creation and reuse, a genuine webhook request, five customer payments in a real browser (completed, 20 → failed, 30 → cancelled, 40 → untrackable, customer cancels), forged and replayed requests through the public address |
Every runtime suite fails on any PHP warning, notice, deprecation or fatal the disposable site
logged. The Sandbox gate exits 78 when an input is missing; that is a skipped gate, never a
passed one. Sandbox order totals decide the outcome: 20 → PAYMENT_FAILED, 30 →
PAYMENT_CANCELLED, 40 → PAYMENT_NONTRACKABLE, anything else → PAYMENT_COMPLETED.
.github/workflows/quality.yml runs on pushes and pull requests to master, on manual dispatch
and as a reusable workflow:
| Job | Content |
|---|---|
composer-validation |
pinned Composer, composer validate --strict, no runtime package, identity gate |
php-syntax |
php -l on PHP 8.1 and 8.4 |
scripts-lint |
shellcheck |
phpcs, phpstan |
composer lint, composer stan |
phpunit |
PHP 8.1–8.4 (8.5 as a non-blocking early warning) |
dependency-audit |
scripts/audit-source.sh, scripts/audit-dependencies.sh |
frontend |
npm test |
package |
translation gate, npm run plugin-zip, scripts/verify-package.sh; uploads the ZIP and records its SHA-256 |
package-smoke |
pristine install on the minimum and the current stack |
source-package-parity |
the artifact matches its hash, and a rebuild of the commit is byte-identical |
integration-mysql, integration-mariadb |
scripts/test-integration.sh on MySQL 8.0 / 8.4 and MariaDB 10.11 / 11.4 |
plugin-check |
WordPress Plugin Check on the ZIP |
browser-e2e |
scripts/test-browser-e2e.sh |
sandbox |
the real Sandbox gate, when its secrets are configured |
quality-gate |
fails unless every job succeeded |
The ZIP is built once, hashed, and every later job downloads that file. It is reproducible:
entries are sorted, stamped with SOURCE_DATE_EPOCH (the commit time) and normalised to
0755/0644, so the same commit yields the same SHA-256.
.github/workflows/release.yml runs on a vX.Y.Z tag: it checks that the tag, the plugin header,
BUCKMERCE_NELLOPAY_VERSION, the readme.txt Stable tag and package.json agree, runs the
complete quality pipeline with the Sandbox gate required and the day-dependent checks blocking,
and publishes exactly the ZIP that pipeline tested, after re-checking its SHA-256.
The ZIP contains buckmerce-nellopay.php, readme.txt, LICENSE, uninstall.php, src/,
assets/ and languages/ — defined by the allowlist in scripts/lib/package-manifest.sh, never
by exclusions.
Every user-visible string is translatable with the text domain buckmerce-nellopay, and no
translation call runs before after_setup_theme. After a string changed:
bash scripts/build-translations.sh # WP-CLI: rebuild the .pot, merge it into every .po, compile .mo and .l10n.php
# translate the new msgstr lines in languages/*.po, then run it again
bash scripts/check-translations.sh # template == sources, every catalogue complete, placeholders equal, compiled files currentBundled: ru_RU. A WordPress.org language pack keeps priority over the bundled catalogue.
- PHP 8.1 baseline, strict types, typed parameters, returns and properties, typed domain
exceptions (
Exception\PluginExceptionhierarchy), no silent catches, no dynamic properties. - WordPress hooks and controllers stay thin; domain logic lives in services. No provider call
inside
WC_Payment_Gateway. - Orders are read and written with WooCommerce CRUD only;
get_post_meta()and its siblings are forbidden by PHPCS, as arevar_dump(),print_r()anderror_log(). - Money is a decimal string; JSON that carries an amount goes through
Support\ExactJson. - Input is sanitised by type, validated against domain constraints and escaped on output; capabilities and nonces are checked on every state-changing admin action.
- Logging goes through
Logging\LoggerandLogging\Redactor; a new log field is added only with its redaction considered. - TypeScript is strict; CSS selectors are namespaced
.bmnp-. - A
phpcs:ignoreneeds a written justification. WordPress Plugin Check on the release ZIP reports no error and no warning.
| Minimum | Tested in CI | |
|---|---|---|
| PHP | 8.1 | 8.1, 8.2, 8.3, 8.4 |
| WordPress | 6.6 | 6.6, 6.8, 7.1 |
| WooCommerce | 8.7 | 8.7, 9.9, 11.2 |
| Database | — | MySQL 8.0 and 8.4, MariaDB 10.11 and 11.4 |
| Order storage | — | HPOS and legacy, both in every integration run |
| Checkout | — | Classic Checkout, Checkout block, order-pay page |
Store requirements: the store currency is the currency of the selected market, and the site is publicly reachable over HTTPS, because Nello Pay delivers webhooks to public HTTPS addresses only.
Nello Pay for WooCommerce handles one-time bank payments in one Nordic market per store. Not built, on purpose: cards, subscriptions and saved payment methods, batch and scheduled payments, refunds through the gateway, several markets in one store, and any use of the Neonomics UAPI.
GPL-2.0-or-later. See LICENSE.