Skip to content

Port Locked Payment Methods to the v2 framework - #24

Merged
ahegyes merged 50 commits into
trunkfrom
feat/lpm-port
Nov 1, 2026
Merged

ahegyes merged 50 commits into
trunkfrom
feat/lpm-port

Conversation

@ahegyes

@ahegyes ahegyes commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Summary

Ports Locked Payment Methods for WooCommerce 1.3.5 to the v2 framework as 2.0.0, with every way of unlocking a payment method it had, distributed through wordpress.org, adds it to the monorepo's fan-outs and shared test environment, and moves Linked Orders' and Internal Comments' safeguards before the floor gate, as the template loads them.

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Build / CI / tooling
  • Documentation
  • Dependency update

Checklist

  • Changelog fragment added (composer changelog:add in the changed plugin's directory, or at the root for the monorepo itself)
  • Tests added/updated
  • composer quality-check passes locally in every changed plugin
  • Plugin header and readme.txt updated if runtime requirements changed

What changes

  • Root: Locked Payment Methods joins the root's fan-outs and the shared tests environment beside Linked Orders and Internal Comments; shared rows check that every plugin boots from its own prefixed framework and that a guest never sees a locked payment method on the classic or the block checkout.
  • Linked Orders and Internal Comments: each entry loads includes/safeguards.php before the bootstrap package and its floor gate, so a restriction either adds holds below its floors too.
  • The lock: a safeguard filters the payment gateways WooCommerce offers on the classic checkout, the block checkout, the Store API and the order payment page, on any runtime that parses the plugin, below its floors and when it fails to boot included. Only an explicit false from dws_lpmwc/payment_method/should_lock, once the plugin has booted, unlocks a payment method, and dws_lpmwc/payment_method/locked_ids, which the lock check and the grants forms read too, only adds ids.
  • Unlocking: full-access roles; per-site grants on a user's profile, with a Users column and filter; grants on an unpaid order, on either order storage; Groups groups; WooCommerce Memberships plans. An order's grants count only on its own payment page with its key.
  • Settings: a section on WooCommerce's Advanced tab under dws_lpmwc_ options; any registered payment method can be locked.
  • Migrations: v1's settings are copied and v1's grants moved into the new shapes on the first request after the update; v1's four capabilities stay.
  • Compatibility: every v1 hook fires deprecated where v1 fired it, and v1's constants and helpers stay as deprecated shims.

Breaking changes

  • Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.2.
  • The settings move from the Payments tab to WooCommerce → Settings → Advanced.
  • A customer's unlocked payment methods become per site; the move deletes v1's grants, so a downgrade to 1.3.5 finds none for customers, orders, groups or plans.
  • Only an explicit false unlocks a payment method; a list filter only adds ids; an empty full-access roles list unlocks no one.
  • v1's settings hooks, framework notice actions and init_status filters, its container and lifecycle functions and Freemius are removed.
  • Uninstalling with consent also deletes every unlocked payment method, v1's data and the capabilities.

Not verified locally

  • Plugin Check, the readme linter and the release rehearsal run only in CI.
  • No network runs in the test environments, and WooCommerce Memberships runs against a double.
  • The wc-locked-payment-methods mirror needs a wp-org-release environment with SVN_USERNAME and SVN_PASSWORD before the first wordpress.org release.

@ahegyes ahegyes changed the title Feat/lpm port Port Locked Payment Methods to the v2 framework Oct 31, 2026
Refresh the root's, Linked Orders' and Internal Comments' Composer
locks, which move only roave/security-advisories, and their npm locks,
which take Babel 7.29.11, ignore 7.0.14, http-proxy-middleware 4.2.1
and their patch-level neighbours, every change upward. No code change
was needed.

Assisted-by: Claude Code:claude-opus-5-5
Linked Orders and Internal Comments load includes/safeguards.php after
the bootstrap package's floor gate, as the template did, so on a
runtime below a floor their restrictions would never load while a
failed boot keeps them. The template now loads the safeguards first.

Each plugin's PluginBootTest gains the template's row, which expects
includes/safeguards.php among the included files before the bootstrap
package's load.php, and the include-loader row drops its safeguards.php
assertion, which the order row covers. Both fail until the entries move.

Assisted-by: Claude Code:claude-opus-5-5
Linked Orders and Internal Comments loaded includes/safeguards.php
after Requirements::check_floors(), as the template did, so a site
below a floor would lose a safeguard's restriction while a failed boot
keeps it. Both safeguards files are empty today, but a restriction
either plugin adds must hold whenever the plugin cannot boot.

Each entry now requires the safeguards first, as the template's does.
The template's accompanying changes come along, since each plugin
carries its own copy: the AGENTS.md layout lines and safeguards
convention, docs/ARCHITECTURE.md's load order, the safeguards file's
docblock, and phpstan.safeguards.neon, which lint:php:phpstan runs at
PHP 7.4 and WordPress 5.2 and the release zip leaves out. Linked
Orders' convention names the template's recipes, as Internal
Comments' does, since it ships no docs/recipes.md.

X ruling 89.

Assisted-by: Claude Code:claude-opus-5-5
Locked Payment Methods for WooCommerce is the third plugin ported to
v2. It starts from wordpress-plugin-template's trunk (321d243) through
the template's own fill-in, run in a clone of the template: name
Locked Payment Methods for WooCommerce, slug
locked-payment-methods-for-woocommerce, namespace
DeepWebSolutions\LockedPaymentMethods, prefix dws_lpmwc, ports 8826 and
8827, repository ahegyes/wc-locked-payment-methods.

As the v1 successor it stays at 2.0.0, which the fill-in sets to 0.1.0
for a new plugin, and its Composer name is its mirror,
ahegyes/wc-locked-payment-methods. The directory keeps the template's
files but for CONTRIBUTING.md, the pull-request and issue templates,
Dependabot's configuration and the fill-in's workflow, script and
Quality job, which belong to the monorepo. The template's demo stays
until the port replaces it, and every suite passes as filled in.

X rulings 87 and 88.

Assisted-by: Claude Code:claude-opus-5-5
The fill-in names the Composer package after the plugin's slug,
ahegyes/locked-payment-methods-for-woocommerce, while a plugin in this
monorepo takes its mirror's name, which the split reads to find the
mirror: ahegyes/wc-locked-payment-methods. The scaffold's commit said
so but left the fill-in's name; the lock's content hash follows.

Assisted-by: Claude Code:claude-opus-5-5
Locked Payment Methods carried the template's demo, which a plugin
replaces: the greeting feature, the example block and the editor
script, with their options, settings fields, tests and build entries.

They go as the template's README describes. v1 shipped no scripts or
styles, and the port renders every screen through WordPress and
WooCommerce, so nothing is built: blocks/, assets/, the asset
registrars, the empty models/ and templates/ extension points, and
the block and React packages go; build stays and runs nothing, as the
README asks, and lint:styles keeps one pattern with
--allow-empty-input. The entry's build gate checks only the
bootstrap package, which the partial-deploy row now removes. The data
consent and the template's generic readers stay until the port
replaces them; the docs follow in the commit that describes the plugin.

Assisted-by: Claude Code:claude-opus-5-5
v1 kept its settings in an unlisted section of WooCommerce's Payments
tab, under three key spellings, and offered only enabled gateways, so
saving the section while a locked gateway was disabled unlocked it once
it was enabled again. The port moves them to a listed section on the
Advanced tab under the plugin's prefix, and copies v1's values.

SettingsTest expects the section on the Advanced tab under v1's id,
v1's General and Plugin groups with their defaults, every registered
gateway in the locked list with the disabled ones marked and kept
through a save, every role in the roles field whether or not the role
strategy is on, checkboxes storing 'yes' and 'no', v1's group filters
firing as deprecated on v1's field ids, and the Settings and support
links. MigrationsTest expects the first step to store every setting,
autoloaded, from v1's row where it holds a value of the right kind and
otherwise from the default, to leave v1's rows and the values a site
stored, to map v1's eight settings one to one, and to throw when a
write fails.

X ruling 93.

Assisted-by: Claude Code:claude-opus-5-5
v1 kept its settings in an unlisted section of the Payments tab, which
merchants could not find from WooCommerce's menus, and its locked list
offered only enabled gateways, so a save while a locked gateway was
disabled dropped it, and enabling the gateway again showed it to every
customer.

The section moves to the Advanced tab under v1's id, beside Linked
Orders', with v1's General, Integrations and Plugin blocks; every
setting is a flat option under dws_lpmwc_, and the on/off selects are
checkboxes. The locked list offers every registered gateway, marking
the disabled ones, so a disabled gateway stays locked, and the roles
field always shows. v1's group-field filters fire as deprecated on
v1's field ids, and a field one adds keeps the option v1 stored it
under. The first migration step copies each of v1's eight settings
where its row holds a value of the setting's kind, takes the default
otherwise, and leaves v1's rows, so a downgrade still locks what v1
locked. The plugin row links to the section and the support forum.
The integration switches arrive with their strategies.

X ruling 93.

Assisted-by: Claude Code:claude-opus-5-5
v1's lock existed only once v1 had booted, and it removed a gateway
only on an exact true, so a site below v1's floors, a failed boot or a
callback returning null showed every locked method to every customer.
v1 also judged order grants by the order-pay query variable alone, so
a checkout request naming another order's id took that order's grants.

SafeguardsTest expects the lock to remove the methods the plugin's
option lists, unlocking one only on an explicit false from should_lock
once the plugin has booted; to read v1's list while the plugin's
option is absent, and to stop once the plugin stores its own, an empty
one included (X ruling 102); to lock only the strings of a stored
list; to let the locked_ids filter and v1's list filters add methods
but never unlock stored ones; to fire v1's list and lock filters as
deprecated, in v1's order and under v1's safe id; to judge the
current user, and an order-pay page's order only given that order's
key; and, in fresh requests below the PHP floor and below
WooCommerce's, to hold with a relaxation hooked.

The request helper moves into a trait both PartialDeployTest and
SafeguardsTest use.

X rulings 89, 94 and 102.

Assisted-by: Claude Code:claude-opus-5-5
v1's lock existed only once v1 had booted and removed a gateway only on
an exact true, so a site below v1's floors, a failed boot or a callback
returning null showed every locked method to every customer; it also
took the order-pay query variable as the order being paid, so a
checkout request naming another order's id took that order's grants.

The lock is the plugin's safeguard. includes/safeguards.php filters
woocommerce_available_payment_gateways at 999 and removes every
offered gateway on the plugin's list, or on v1's while the plugin has
stored none, since the migration that copies v1's list runs only once
the plugin boots (X ruling 102). dws_lpmwc/payment_method/locked_ids
and v1's list filters only add ids, so nothing but an explicit false
from dws_lpmwc/payment_method/should_lock, once the plugin has booted,
unlocks a method. should_lock judges the current user and the order
the order-pay page pays, which counts only with that order's key, as
WooCommerce requires before it takes the payment. v1's list, value and
lock filters fire as deprecated where v1 fired them, the per-id one
under v1's safe id, for the offered gateways.

phpstan.safeguards.neon now scans WooCommerce's stubs and is the
safeguards' only profile, since an ignore WPCompat needs at WordPress
5.2 matches nothing at the plugin's own floor.

X rulings 89, 94 and 102.

Assisted-by: Claude Code:claude-opus-5-5
v1 granted its capabilities from a permissions component that checked
users at boot, and its free build granted the order capability that
only its premium build used.

CapabilitiesTest expects v1's four names and v1's grants: every
capability to administrators, and the user-profile and order ones to
shop managers.

X ruling 92.

Assisted-by: Claude Code:claude-opus-5-5
v1 granted its four capabilities from a permissions component that
checked users at boot, and removed them only on uninstall.

includes/capabilities.php names them as v1 did and declares v1's
grants, which core's CapabilityGrants applies whenever the declaration
changes or a declared role appears: every capability to
administrators, and the user-profile and order ones to shop managers.
Each form checks its capability in its callback, so nothing checks a
user at boot.

X ruling 92.

Assisted-by: Claude Code:claude-opus-5-5
v1's role strategy unlocked every locked method for every visitor,
guests included, while its roles list was empty, because v1's role
helper answers true for an empty list before it looks the user up.

UserRoleStrategyTest expects the strategy to load while its switch is
on, as v1's was by default; to unlock every locked method only for a
user holding a full-access role, never for a guest and never through
an empty list; to leave a method another callback unlocked unlocked;
to open the lock at checkout for such a user only; and to read the
roles through v1's General value filter, fired as deprecated.

X rulings 90 and 94.

Assisted-by: Claude Code:claude-opus-5-5
v1's role strategy unlocked every locked method for every visitor,
guests included, while its roles list was empty, because v1's role
helper answers true for an empty list before it looks the user up.

UserRoleStrategy is a component gated on its switch, on by default as
v1's was. On dws_lpmwc/payment_method/should_lock it returns false for
a user holding one of the full-access roles and leaves every other
answer alone, so a guest or an empty list unlocks nothing. It reads
the roles through v1's General value filter, which fires as deprecated
and now lives beside the safeguard's own read of the locked list in
includes/safeguards.php, the one file both can call. The main PHPStan
profile scans that file, which it no longer analyzes.

X rulings 90 and 94.

Assisted-by: Claude Code:claude-opus-5-5
v1 stored a user's grants as global user meta, one row per gateway,
so a grant on one store of a network applied on every store; its
Users column showed icons without text, its filter printed unescaped
markup and read only the top dropdown, and its profile section saved
nothing for a method outside the locked list.

UserProfileStrategyTest expects the strategy to load while its switch
is on, as v1's was by default; to unlock only the methods a user's
per-site list holds, ignoring the global key; to offer on the profile
the locked methods and the user's other grants, marking a grant
outside the list and a method another rule already unlocks; to show
nothing without the capability or anything to offer; to store the
checked methods and keep the grants a save names wrongly; and to list
and filter users by their grants in text, from either dropdown.
MigrationsTest expects the grants step to move v1's 'yes' rows into
the site's list beside its own grants, to delete every v1 grant row,
to converge when run again, and to throw, keeping v1's rows, when a
write fails.

X ruling 90.

Assisted-by: Claude Code:claude-opus-5-5
v1 stored a user's grants as global user meta, one row per gateway,
so a grant on one store of a network applied on every store; its
Users column showed icons without text, its filter printed unescaped
markup and read only the top dropdown, and its profile section saved
nothing for a method outside the locked list.

A user's grants are one per-site user option, the list of unlocked
payment method ids under the site's table prefix, read from that key
alone. UserProfileStrategy, gated on its switch, returns false on
should_lock for a granted method, and edits the list on the profile
through the framework's UserProfile host and an ObjectForm with one
checkboxes field, behind unlock_dws_lpm_via_user_profiles and
edit_user. The field offers the locked methods and the user's other
grants, since a save keeps only offered choices, and marks a grant
outside the list and a method another rule already unlocks. The
Users screen gains a column naming each locked method's state in
text and a filter by grant, read from the dropdown whose button
submitted the list. The second migration step moves v1's 'yes' rows
into every site's list beside its own grants, keeping v1's reach,
then deletes every v1 grant row; a partial run converges on the next.

X ruling 90.

Assisted-by: Claude Code:claude-opus-5-5
v1's premium build stored order grants as one post meta row per
gateway, edited them only on the posts screen, and deleted a disabled
control's grant on every save, so saving a paid order or one whose
customer was unlocked another way dropped its grants.

OrderStrategyTest expects the strategy to load only while its switch
is on, as v1's was off by default, and the booted plugin to run it
then; on each order storage, an order to unlock only the methods its
list holds when it is the order being paid, and a save of its box to
store the checked methods; the box to offer the locked methods and the
order's other grants, behind unlock_dws_lpm_via_orders, and to be
disabled on a paid order, so a save leaves its grants; the order-pay
page to unlock the order's methods given its key; and, on each
storage, the grants step to move v1's 'yes' rows into the order's list
beside its own grants and delete every v1 row. OrderStorage comes
from Linked Orders' tests.

X rulings 91 and 92.

Assisted-by: Claude Code:claude-opus-5-5
v1's premium build stored order grants as one post meta row per
gateway, edited them only on the posts screen, and deleted a disabled
control's grant on every save, so saving a paid order or one whose
customer was unlocked another way dropped its grants.

An order's grants are one order meta list, read through WooCommerce's
order data on either storage. OrderStrategy, gated on its switch and
off by default as v1's was, returns false on should_lock for a method
on the list of the order being paid, which the safeguard names only on
the order's own payment page given its key. It edits the list in a
box on the order screen through the framework's OrderMetaBox, behind
unlock_dws_lpm_via_orders and edit_post; a paid order's box is
disabled, so a save leaves its grants. The third migration step moves
v1's 'yes' rows, found in either storage's meta table, into each
order's list beside its own grants, and deletes the order's v1 rows.

The profile and order forms build their choices and titles through
two shared functions in includes/grants.php.

X rulings 91 and 92.

Assisted-by: Claude Code:claude-opus-5-5
v1's premium build kept each Groups group's grants in an option of
its own, which deleting the group left behind.

GroupsStrategyTest, against Groups 4.8.0 and in the groups group so
environments without Groups leave it out, expects the strategy to load
only while Groups is active and its switch is on; the settings to show
the switch while Groups is active; a member of a group or of one of
its subgroups to be unlocked for the group's methods; Groups' add and
edit forms to offer the locked methods to a user holding both
unlock_dws_lpm_via_groups and Groups' own capability, and their saves
to store the checked methods under the group, keep them on a choice
the field does not offer, and store nothing without the capability or
the field; deleting a group to forget its methods; and the grants step
to move v1's per-group options into one map and delete them.
SettingsTest's integrations rows follow whether Groups is active.

X rulings 91 and 92.

Assisted-by: Claude Code:claude-opus-5-5
v1's premium build kept each Groups group's grants in an option of
its own, which deleting the group left behind.

The groups' grants are one option mapping each group's id to its
list. GroupsStrategy, gated on Groups at v1's oldest version and on
its switch, off by default, returns false on should_lock for a method
a group of the user, or a group above one, unlocks. Groups verifies
its forms' nonce and its own capability before it fires the actions
that save them, and its add form has no group to bind a nonce to, so
the field rides Groups' add and edit forms: it renders and sanitizes
through the framework's Forms, for a user who also holds
unlock_dws_lpm_via_groups, reports a rejection as an admin notice,
and a deleted group's entry goes. The settings show the Groups switch
while Groups is active. The fourth migration step moves v1's
per-group options into the map and deletes them.

Groups 4.8.0 comes from WP Packages as a development package, mapped
and activated in Locked Payment Methods' own environments; PHPStan
scans its classes.

X rulings 91 and 92.

Assisted-by: Claude Code:claude-opus-5-5
WooCommerce Memberships plans take the same meta key as orders, each
on its own object, so the constant that named it for orders alone
names it for both.

Assisted-by: Claude Code:claude-opus-5-5
v1's premium build kept a WooCommerce Memberships plan's grants in
plan meta under v1's prefix and saved its plan tab without a nonce of
its own.

WooCommerceMembershipsStrategyTest, against a double of Memberships'
one function and in the wc-memberships group, since Memberships is a
paid plugin absent from WP Packages, expects the strategy to load only
while Memberships is active at v1's oldest version and its switch is
on; an active member of a plan to be unlocked for the plan's methods,
and an inactive member, another user or a guest not to be; the plan
data to offer a tab and its panel only to a user with the capability;
a save to store the checked methods only under the form's own nonce
for that plan; and the grants step to move v1's plan meta to the
plugin's key and delete it.

X rulings 91 and 92.

Assisted-by: Claude Code:claude-opus-5-5
v1's premium build kept a WooCommerce Memberships plan's grants in
plan meta under v1's prefix and saved its plan tab without a nonce of
its own.

A plan's grants are its post meta under the plugin's key.
WooCommerceMembershipsStrategy, gated on Memberships at v1's oldest
version and on its switch, off by default, returns false on
should_lock for a method a plan the user is an active member of
unlocks, skipping the cancelled memberships Memberships sometimes
returns. Its form shows as a tab of the plan data box through
MembershipPlanTab, a host on the framework's ObjectFormHostInterface,
so the form prints and verifies its own nonce for the plan, behind
unlock_dws_lpm_via_wc_memberships and edit_post. The settings show the
Memberships switch while Memberships is active. The fifth migration
step moves v1's plan meta to the plugin's key and deletes it.

Memberships is a paid plugin absent from WP Packages, so the rows run
against a double of its one function and its three hooks, unverified
against a licensed copy; PHPStan reads the function from a
declaration in tests/PHPStan.

X rulings 91 and 92.

Assisted-by: Claude Code:claude-opus-5-5
v1 exposed root getters, hook-tag builders, constants, boot hooks,
access checks and setting readers that returned true for "locked"
under names that read the other way and took v1's field ids, so code
written against v1 calls them.

DeprecatedApiTest expects v1's getters and constants to answer from
the plugin header, v1's hook-tag builders to build v1's names for the
plugin and for v1's twelve components, v1's boot hooks to fire after
the v2 ones, dws_lpmwc_is_payment_method_locked() to answer for a
user, v1's access checks to fire v1's filters and then answer from the
strategies, v1's per-strategy checks to answer from their strategy
alone and true while it is off, and v1's setting readers to map v1's
ids to the plugin's options through v1's value filters, each shim
deprecated with its replacement.

X rulings 94 and 95.

Assisted-by: Claude Code:claude-opus-5-5
v1 exposed root getters, hook-tag builders, constants, boot hooks,
access checks and setting readers that returned true for "locked"
under names that read the other way, ignored the locked list and took
v1's field ids, which no longer name options.

includes/deprecated.php keeps them through 2.x, each deprecated with
its replacement: the getters and constants answer from the plugin
header, without v1's whitelabel prefix; the hook-tag builders build
v1's names for the plugin and its twelve v1 components; v1's boot
hooks fire after the v2 ones; the access checks fire v1's per-user and
per-order filters, then answer from the strategies; the per-strategy
checks answer from their strategy alone, through an internal static
check each strategy's hook now wraps, and true while it is off; and
the setting readers map v1's ids to the plugin's options through v1's
page and group value filters. dws_lpmwc_is_payment_method_locked()
answers whether a method is on the list and no strategy unlocks it
for a user and an order, and the profile and Users screens ask it.

X rulings 94 and 95.

Assisted-by: Claude Code:claude-opus-5-5
v1 removed data only from a Freemius hook, deleting its options under
an unescaped LIKE and its capabilities, and left user, order and plan
grants behind.

FootprintTest expects the footprint to name v1's option prefix,
version rows and notices, order meta under both prefixes, the plan
meta under both keys, v1's global user grants and the four
capabilities. UninstallTest expects a consenting uninstall to remove
the Groups map, v1's options, a user's site grants, v1's global
grants, v1's notices, an order's and a plan's grants under both keys,
and the capabilities.

X ruling 96.

Assisted-by: Claude Code:claude-opus-5-5
v1 removed data only from a Freemius hook, deleting its options under
an unescaped LIKE and its capabilities, and left user, order and plan
grants behind.

The footprint, which the framework's uninstall engine removes where
the site consents through dws_lpmwc_delete_data, adds v1's option
prefix, version rows and notices; order meta under the plugin's and
v1's prefixes, on either storage; the plan meta under both keys; v1's
global user grants that a migration has not moved yet; and the four
capabilities. The Unit bootstrap loads the files whose constants the
footprint reads.

X ruling 96.

Assisted-by: Claude Code:claude-opus-5-5
Removing the template's demo also dropped the ignore rules for
blocks/build and assets/build, so the same commit added the build
output the scaffold had left in place: the example block's and the
greeting's compiled scripts and styles, which nothing loads.

Assisted-by: Claude Code:claude-opus-5-5
v1's readme advertised a premium build, linked a knowledge base and a
contact form that no longer exist, said only enabled payment methods
can be locked and placed the settings on the Payments tab; v1's
version history lived only in that readme.

Locked Payment Methods stays listed on wordpress.org, so the entry
drops the Update URI header, which leaves the bundled updater inert,
and the text-domain loader, since language packs load just in time,
and states what the plugin does. readme.txt keeps v1's Description,
Installation, FAQ and Screenshots, updated: the five ways of unlocking
a payment method as plain features, WooCommerce 11.2 and the Advanced
tab, any registered payment method lockable, grants per site on a
network, support on the forum. Its Upgrade Notice names the floors,
the settings' move and the explicit-false unlock, and points at the
changelog. CHANGELOG.md carries v1's history between the markers that
changelog:write copies into readme.txt, through the Pronamic tool now
in require-dev, and 23 fragments record 2.0.0's changes.

X rulings 88 and 97.

Assisted-by: Claude Code:claude-opus-5-5
The Groups, Memberships and order rows hook their own strategy for the
whole class and never unhooked it, so every later class in the process
ran it too. In the root's shared environment, which has no Groups, the
leftover Groups strategy threw on Groups_User from the user profile
rows' checkout filter.

Each class clones WordPress's hook table before it hooks its strategy
and puts the copy back after its last row, as WordPress's own test case
does around each test.

Assisted-by: Claude Code:claude-opus-5-5
The template's declaration marks a new plugin incompatible with
WooCommerce's order tables until its own evidence says otherwise, so
WooCommerce warned on every Locked Payment Methods site that stores
orders there, though the plugin reads and writes orders only through
WC CRUD and its order rows run on both storages.

The plugin declares itself compatible, and a boot row checks the
declaration. It declares nothing for the cart and checkout blocks,
whose checkout the lock covers through the gateways filter they share.

X ruling 88.

Assisted-by: Claude Code:claude-opus-5-5
wordpress.org reads the stable tag and the floors from readme.txt,
while WordPress and the release read them from the entry's headers,
and nothing kept the two in step.

A Unit row compares readme.txt's Stable tag, Requires at least and
Requires PHP with the entry's headers, and another checks the changelog
markers the readme sync fills, as Linked Orders' does.

Assisted-by: Claude Code:claude-opus-5-5
The Integration rows filter the gateways directly, so no row showed a
granted customer the block checkout or sent the Store API a checkout
naming a locked payment method.

An end-to-end spec locks cash on delivery and opens the store, which a
new WooCommerce install keeps as coming soon, then checks that a
customer it is granted to sees it on the block checkout, and that the
Store API refuses a guest's checkout naming it with
woocommerce_rest_checkout_payment_method_disabled.

Assisted-by: Claude Code:claude-opus-5-5
The plugin's README, AGENTS.md, architecture and tests docs, SECURITY
scope and manifest descriptions still described the template: its
demo, its fill-in, its blocks and its GitHub distribution. docs/
recipes.md, the template's guide, came with the scaffold, and
phpcs.dist.xml kept the comment of the build exclusions the demo's
removal dropped.

Each now describes Locked Payment Methods: what it does and how other
plugins lock and unlock payment methods, its strategies, its lock and
v1's API, its data under dws_lpmwc_ and what the migrations move from
v1, its grants forms, order storage and v1-hook conventions, and its
wordpress.org distribution. Release passes wp-org: true and Quality
lints readme.txt, as Linked Orders' workflows do, which the plugin's
now match. They point at the template's recipes, which it maintains.

Assisted-by: Claude Code:claude-opus-5-5
The root's shared environment, its Integration run and its plugins:*
fan-outs named Linked Orders and Internal Comments alone, so Locked
Payment Methods would have joined the CI lanes but not the shared site
where the plugins are tested side by side. The shared smoke check only
checked that the plugins were active, and the README's table listed
Linked Orders alone.

The shared environment maps and activates Locked Payment Methods, and
test:integration:run runs its Integration suite there without the
groups and wc-memberships groups, whose plugins the site lacks; every
Composer and npm fan-out lists it. The shared checks add that every
plugin boots from its own prefixed framework, and that a guest sees a
locked payment method on neither the classic nor the block checkout.
AGENTS.md and the README name the plugin, and the README Internal
Comments too.

X ruling 99.

Assisted-by: Claude Code:claude-opus-5-5
Only an explicit false from dws_lpmwc/payment_method/should_lock is
meant to unlock a payment method, but the strategies typed the lock
as bool. WordPress calls them in weak mode, so a 0 or an empty string
an earlier callback returned became false and unlocked the method for
everyone, guests included, and a null threw a TypeError that broke the
checkout. The lock itself took the gateways as an array, so another
plugin's gateways filter returning anything else broke it too.

Each strategy takes the lock untyped and returns it unchanged unless
its subject unlocks the method, and the lock passes on a gateways
value that is not an array, which WooCommerce casts to an empty list.
Rows hook a null, a 0 and an empty string before the strategies, and
a null in place of the gateways.

Assisted-by: Claude Code:claude-opus-5-5
dws_lpmwc_is_payment_method_locked() and the grants forms read the
settings' locked list, while checkout also locks the ids the list
filters add, and their docblocks called it "the locked list", which
reads as the list checkout uses.

The docblocks and the architecture name the settings' list and say
why the list filters stay out: they judge the payment methods a
checkout offers, as v1's did, so a callback written for a cart never
runs on a profile or the Users screen.

Assisted-by: Claude Code:claude-opus-5-5
The Users screen's filter narrowed every user query run on users.php
through pre_get_users, as v1's did, so another plugin's user query on
that screen, such as a dropdown or a count, saw only the users granted
the filtered payment method.

The filter adds its meta query on users_list_table_query_args, which
only the list tables apply to their own query, and a row checks that a
query of its own on the Users screen keeps every user.

Assisted-by: Claude Code:claude-opus-5-5
WordPress uninstalls a plugin in a request that never included it,
and uninstall.php loaded footprint.php and options.php but not the
capabilities and grants files whose constants the footprint names. The
footprint threw on an undefined constant, so a web uninstall kept every
row despite the site's consent, and WP-CLI failed. The uninstall rows
ran in processes that had loaded the plugin, which defined them.

uninstall.php requires capabilities.php and grants.php too, and a row
uninstalls through a WP-CLI request that skips the plugin, as
WordPress's own uninstall request does.

Assisted-by: Claude Code:claude-opus-5-5
The order grants step queued the deletion of v1's grant rows and the
new list in one save, and WooCommerce deletes an order's meta before
it adds any. When the list's insert failed, the step threw after v1's
rows were gone, so its retry found nothing to move and the order lost
its grants.

The step saves the merged list and verifies it, then deletes v1's rows
in a second save and verifies that, so a failure at either save leaves
what a retry needs. A row refuses the list's insert on the posts
storage, then checks that v1's grant survives and the retry moves it.

Assisted-by: Claude Code:claude-opus-5-5
The block checkout and the Store API's checkout leave a draft order
each time the checkout spec runs, and an uninstall row created an order
before checking a capability outside its try, so a failed check left
the order behind. The tests site's start enables WooCommerce's order
tables, whose check refuses while any order is unsynced, so the next
quality-check failed to start the site.

The checkout spec deletes every draft order after its rows, and the
uninstall row checks the capability inside the try whose finally
deletes the order.

Assisted-by: Claude Code:claude-opus-5-5
The lock passed should_lock the order an order-pay URL names once the
request carried that order's key. WordPress parses the order-pay query
var on AJAX and REST requests too, and WooCommerce's AJAX checkout
counts as the checkout, so a customer posting the cart checkout to
?wc-ajax=checkout&order-pay=<id>&key=<key> of an order they hold had
that order's unlocked payment methods offered for a new order from
the cart. The Store API's checkout took the same path.

The order-pay order counts only outside AJAX and REST requests, where
the order-pay page and its pay form run, and rows give an AJAX and a
REST request a valid key. The changelog's security entry says so.

Assisted-by: Claude Code:claude-opus-5-5
The boot row asserted that includes/_disabled-example.php never loads,
but no such file exists, so the row passed even with the loader's
underscore check removed.

The row writes an underscore-prefixed include defining a constant and
checks in a fresh request that the constant stays undefined, as Linked
Orders' row does; it fails once the loader's check is removed.

Assisted-by: Claude Code:claude-opus-5-5
The order grants step probed for each meta table with SHOW TABLES and
skipped a table the probe did not name, so a failed probe read as an
absent table. The step then finished, the runner recorded it, and the
orders in that table kept v1's grants, which v2 never reads.

A probe that fails throws, so the runner runs the step again on a later
request, and a row breaks the probe and expects the throw.

Assisted-by: Claude Code:claude-opus-5-5
The footprint swept every user meta key starting with dws_wc_lpm_,
though v1 stored only its grants, dws_wc_lpm_grant_access_{gateway},
under it. A user meta prefix sweep also matches WordPress's own
per-site keys, such as {table prefix}capabilities, when the site's
table prefix starts with it, so the wider prefix put every user's
roles at risk on a site whose table prefix begins dws_wc_lpm_.

The footprint names v1's grants prefix alone, and the Unit row and the
architecture say so.

Assisted-by: Claude Code:claude-opus-5-5
The lock passed on a gateways value that was not an array, so an
earlier callback that returned the gateways as an object skipped the
lock, and WooCommerce, which casts the filtered value to an array,
then offered every locked payment method.

The lock casts the value as WooCommerce does before it judges it, and
rows replace the gateways with null and with an object.

Assisted-by: Claude Code:claude-opus-5-5
dws_lpmwc/payment_method/locked_ids fired only in the checkout's
gateways filter, so a payment method it locked was hidden at checkout
but reported unlocked by dws_lpmwc_is_payment_method_locked() and
offered on no grants form, leaving it to full-access roles and
should_lock callbacks.

The rows check that the lock check and the profile form count an id
the filter adds, and that the filter runs right after v1's value
filter with the ids alone, before v1's list filter at checkout.

Assisted-by: Claude Code:claude-opus-5-5
dws_lpmwc/payment_method/locked_ids fired only in the checkout's
gateways filter with the offered gateways, so the lock check, the
grants forms and the Users filter read a shorter list than checkout
did, and a payment method locked from code could be unlocked only by
full-access roles or should_lock callbacks.

The filter fires inside dws_lpmwc_get_locked_payment_method_ids() with
the ids alone, after v1's value filter, so every reader sees the ids it
adds; v1's deprecated list filter still fires only at checkout, with
the offered gateways, as v1 fired it.

Assisted-by: Claude Code:claude-opus-5-5
The Users column and the grants choices asked
dws_lpmwc_is_payment_method_locked() about ids they had just read from
the locked list, so every row and payment method read the options and
ran the list filters again, locked_ids callbacks included.

Both call dws_lpmwc_should_lock() for the id they already know is
locked, which judges it the same way.

Assisted-by: Claude Code:claude-opus-5-5
The row read "skipped" from a fresh request whenever the constant the
underscore include defines stayed undefined, which also happens when
the plugin never loads or boots in that request or the include was
never written, so the row could pass without testing the loader.

The row checks the write and the request's exit status, and prints
"not booted" unless the plugin's boot succeeded.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes
ahegyes merged commit e736dab into trunk Nov 1, 2026
50 checks passed
@ahegyes
ahegyes deleted the feat/lpm-port branch November 1, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant