Skip to main content

OutboundChargeGuard

\Benecaster\Payment\OutboundChargeGuard

Class Free

Blocks money movement and subscription-lifecycle writes from an install that is a copy of another one. Core calls it before every outbound Stripe operation it makes; an add-on that moves money must call it too, because core cannot do it on the add-on’s behalf.

This is the half of the staging payment guards that covers calls made where nobody is at a keyboard: a renewal cron, a replayed webhook, a manual retry on a site whose keys were accepted before it was cloned. A separate interactive guard refuses live keys at the point someone pastes them, but only this one stops the automated paths.

It consults only InstallIdentity, never the staging environment check, and that asymmetry is deliberate rather than an oversight. Refusing a key paste is interactive and instantly recoverable — someone is at the keyboard and is told why. Refusing a charge is not: a false positive stops real revenue with nobody watching. Hostname patterns guess, and some managed-hosting domains that look like staging serve genuine production sites. So the money guard uses only the signal that cannot be wrong by coincidence, and fails open on every uncertain case. Do not “make this consistent” with the interactive guard by adding the hostname check.

Scope is money and subscription mutations, which is wider than the licence server’s “money, not mutation” rule — deliberately, because the plugin’s exposure differs. A replayed webhook here can cancel, resume or re-tier a real customer’s subscription. That moves no cent, so a money-only line would read as unguarded, yet the subscriber loses what they paid for.

Reads and customer-record writes stay unguarded, as does opening a billing portal session: it writes nothing, and the mutations it leads to are the subscriber acting on their own subscription, which is legitimate no matter which install minted the link.

Resolve the class from the service container rather than constructing it: benecaster()->container()->make( \Benecaster\Payment\OutboundChargeGuard::class ).

Stop a third-party payment gateway charging real customers from a staging clone

Premium Advanced

A staging site is usually a byte-for-byte copy of production, live payment credentials included, so an automated path — a renewal cron, a replayed webhook, a manual retry — can charge real customers from the copy. Core protects its own Stripe calls by comparing the URL the install first recorded against the one it is running at now, and refuses to move money when the two differ.

Core cannot extend that protection to your gateway. A gateway registered through benecaster_payment_gateways never passes through core’s Stripe client, so nothing in core sits between your add-on and its API. Call the guard yourself before anything that moves money or changes a subscription’s lifecycle, and catch the refusal at every one of your own entry points.

How you catch it matters more than that you catch it. NonProductionChargeBlocked extends \RuntimeException, so a broad \RuntimeException or \Throwable arm placed first will swallow it and answer with whatever that arm says. Core shipped exactly this bug and corrected it at the batch-end review: the broad arm reported “Stripe is not configured”, sending the operator to check credentials that were fine. Catch the specific exception first.

Answer 409, never a 5xx. A refusal is a settled state — nothing about retrying changes the answer on a copy — and an uncaught exception inside a webhook handler becomes a 500, which a payment provider answers by retrying. The one thing worse than the charge you blocked is blocking it in a way that makes the provider try again all day.

Never pass the exception’s message back to a caller, as the example below deliberately does not. It names the recorded production URL, the current URL and the constant that disarms the guard — infrastructure detail on endpoints a logged-out visitor may be able to reach. Core shipped three boundaries that echoed it — signup, buy-up purchase and donation intent — and corrected all three. The operator’s copy of the same facts is already in the debug log.

The operation string you pass is what appears in that log, one line per operation per hour alongside the recorded and current URLs, so name it after the call rather than after your add-on. Reads and customer-record writes are deliberately left unguarded, matching core. A genuine production site that trips the check is re-enabled by adding define( 'BENECASTER_IS_PRODUCTION', true ); to wp-config.php — you do not need to handle that case yourself.

<?php
use Benecaster\Payment\NonProductionChargeBlocked;
use Benecaster\Payment\OutboundChargeGuard;

add_action( 'benecaster_boot', function ( \Benecaster\Container $container ) {
    $guard = $container->make( OutboundChargeGuard::class );

    // Inside your gateway, before any charge or lifecycle call.
    try {
        $guard->assert_may_move_money( 'myaddon_create_subscription' );

        $subscription = my_gateway_api()->createSubscription( $customer, $plan );
    } catch ( NonProductionChargeBlocked $e ) {
        // Refuse the way THIS entry point already refuses. A REST route
        // returns WP_Error; a webhook returns 200 without acting, so the
        // provider does not retry; cron logs and skips.
        return new WP_Error(
            'myaddon_billing_unavailable',
            __( 'Billing is unavailable on this site.', 'my-addon' ),
            [ 'status' => 409 ]
        );
    }
} );

View on GitHub →

Constructor Dependencies

Type Description
\Benecaster\Staging\InstallIdentity The sole signal consulted. Answers whether this install is still the one that armed itself.
\Benecaster\Admin\DebugLog Receives one evidence line per refused operation per hour.

Methods

Method Visibility Since Description
assert_may_move_money( string $operation ): void Public Refuses the operation when this install is a copy, by throwing NonProductionChargeBlocked. Returns silently otherwise. Call it before the outbound call, not after. The $operation string is what lands in the debug log and in the exception message, so name the call, not the add-on — the operator reading the log needs to know which operation was refused, and your add-on's name is already obvious from context.

Notes

Log lines are throttled to one per operation per hour, because the paths this guard sits on are the automated ones — a renewal cron on a busy clone would otherwise write a line per subscriber per run, burying the first occurrence, which is the only one anybody needs. The throttle is keyed on the operation string, so distinct operations each get their own line.

Subscriber-facing REST routes translate the refusal into benecaster_billing_unavailable (409) with a deliberately generic message. Never pass the exception's message to an API caller — it names both site URLs and the wp-config.php constant that disarms the guard, and several of these routes are reachable by a logged-out visitor.