Skip to content
BuckmercePublic

Repository files navigation

Bank Payments via Nello Pay for WooCommerce

Quality PHP WordPress WooCommerce License

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.

Contents

At a glance

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

What the provider dictates

Two properties of the Nello Pay Checkout API shape the whole design:

  1. 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.
  2. 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.

Payment flow

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.

Checkout request

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 state machine

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).

Webhook

POST /wp-json/buckmerce-nellopay/v1/webhook — REST\WebhookController.

Authentication runs in the permission callback, before anything is parsed:

  1. body over 64 KiB → 413;
  2. no acceptable key configured (16–128 printable characters without spaces) → 503: the route is closed, not open;
  3. key read from api-key, or x-api-key when that is absent; missing or wrong → 401. The comparison is hash_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).

Idempotency and concurrency

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.

Time rules instead of polling

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.

Data model

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.

Public API

Routes, hooks, WP-CLI commands and the identifiers above are stable within a major version. Classes, methods, table layouts and order meta are internal.

REST routes — buckmerce-nellopay/v1

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.

Actions

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);

Filters

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-CLI

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)

Other identifiers

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

Security model

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\WebhookKeyGuard refuses 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 use wp_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.

Privacy

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.

Repository layout

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.

Development

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 change

Local 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.

Quality gates

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/*.sh

Release-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.

Continuous integration and release

.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.

Translations

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 current

Bundled: ru_RU. A WordPress.org language pack keeps priority over the bundled catalogue.

Coding standards

  • PHP 8.1 baseline, strict types, typed parameters, returns and properties, typed domain exceptions (Exception\PluginException hierarchy), 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 are var_dump(), print_r() and error_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\Logger and Logging\Redactor; a new log field is added only with its redaction considered.
  • TypeScript is strict; CSS selectors are namespaced .bmnp-.
  • A phpcs:ignore needs a written justification. WordPress Plugin Check on the release ZIP reports no error and no warning.

Compatibility

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.

Scope

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.

License

GPL-2.0-or-later. See LICENSE.

Releases

Contributors

Languages