pushing stuff that was worked on
This commit is contained in:
1 parent
773883a0f3
commit
197938f482
22 files changed
+1251
-124
No files matched your search
@@ -101,13 +101,18 @@ After that, the starter site's content and Customizer settings persist in
|
|||||||
the database like any other WordPress content — a redeploy or a fresh
|
the database like any other WordPress content — a redeploy or a fresh
|
||||||
`deploy.sh` run doesn't touch it or need to repeat it.
|
`deploy.sh` run doesn't touch it or need to repeat it.
|
||||||
|
|
||||||
### Payments: Helcim
|
### Payments: Helcim (preauth/capture)
|
||||||
|
|
||||||
`deploy.sh` installs and activates the free, wordpress.org-hosted **Helcim
|
The Helcim gateway is a **custom `WC_Payment_Gateway`** shipped inside
|
||||||
for WooCommerce** plugin (`helcim-commerce-for-woocommerce`) automatically
|
`bookstore-core` (`Payments\HelcimGateway`, built directly against
|
||||||
in every environment — nothing to do here for that part.
|
HelcimPay.js) — not the off-the-shelf `helcim-commerce-for-woocommerce`
|
||||||
|
plugin, which `deploy.sh` deliberately does not install (it only
|
||||||
|
supports immediate charges; see design doc Appendix B). Checkout
|
||||||
|
authorizes (holds) the card; the charge only actually happens once the
|
||||||
|
supplier order succeeds (`Order\SupplierOrderJob`) — see that class and
|
||||||
|
`Payments\HelcimClient`'s docblocks for the full sequence.
|
||||||
|
|
||||||
The plugin needs a Helcim **sandbox API token**, which isn't something a
|
The gateway needs a Helcim **sandbox API token**, which isn't something a
|
||||||
public API can hand out, so it's a one-time manual step per environment:
|
public API can hand out, so it's a one-time manual step per environment:
|
||||||
|
|
||||||
1. Sign up for a free Helcim account.
|
1. Sign up for a free Helcim account.
|
||||||
@@ -118,13 +123,17 @@ public API can hand out, so it's a one-time manual step per environment:
|
|||||||
|
|
||||||
`deploy.sh` writes the token to a gitignored secrets file the same way it
|
`deploy.sh` writes the token to a gitignored secrets file the same way it
|
||||||
already does for `HARDCOVER_API_TOKEN` (see `secrets/<env>/` above), and
|
already does for `HARDCOVER_API_TOKEN` (see `secrets/<env>/` above), and
|
||||||
`Payments\HelcimConfig::ensure_configured_from_env()` seeds the gateway's
|
`Payments\HelcimGatewayConfig::ensure_configured_from_env()` seeds the
|
||||||
WooCommerce settings (sandbox mode, disabled by default — a human still
|
gateway's WooCommerce settings (sandbox mode, disabled by default — a
|
||||||
enables it at checkout once ready) the first time it finds a token. Until a
|
human still enables it at checkout once ready) the first time it finds a
|
||||||
token exists, the plugin is installed and inert; nothing else in this repo
|
token. **Until a token exists, `Payments\HelcimClient` runs in fake
|
||||||
is blocked on it. Go-live (switching the gateway from sandbox to Helcim's
|
mode** — every preauth/capture/reverse call returns a synthetic approved
|
||||||
live environment) is a deliberate manual step on the settings screen, not
|
response instead of hitting the real API, so the whole checkout ->
|
||||||
something this repo automates.
|
preauth -> fraud review -> supplier order -> capture/reverse flow is
|
||||||
|
testable end-to-end without one (same pattern as the synthetic
|
||||||
|
`gutenberg_test` supplier). Go-live (switching from sandbox to Helcim's
|
||||||
|
live environment) is a deliberate manual step on the settings screen,
|
||||||
|
not something this repo automates.
|
||||||
|
|
||||||
### Admin access restriction (production)
|
### Admin access restriction (production)
|
||||||
|
|
||||||
@@ -201,28 +210,30 @@ with a bigger `--limit` continues forward instead of rescanning. Pricing
|
|||||||
`includes/Pricing/PricingEngine.php` and
|
`includes/Pricing/PricingEngine.php` and
|
||||||
`includes/Shipping/BSC_Free_Shipping.php`.
|
`includes/Shipping/BSC_Free_Shipping.php`.
|
||||||
|
|
||||||
## Checkout + order engine (Week 3)
|
## Checkout + order engine (Week 3, payment flow revised)
|
||||||
|
|
||||||
Helcim's WooCommerce plugin is installed (see "Payments: Helcim" above),
|
A synchronous pre-authorization supplier/margin recheck runs right
|
||||||
paired with a synchronous pre-charge supplier/margin recheck and a
|
before the card is held (`Order\CheckoutRecheck`, price/margin only —
|
||||||
fraud-check hook — both run right before the payment gateway is called
|
never charges anything). The custom Helcim gateway (see "Payments:
|
||||||
(`Order\CheckoutRecheck`), never after. A passing order moves through a
|
Helcim" above) then holds the card and, once the fraud check
|
||||||
custom status sequence beyond WooCommerce's own pending/processing/
|
(`Payments\FraudCheck`) passes, moves the order to `bsc-authorized`
|
||||||
completed: `bsc-ordered` (submitted to the supplier via Action
|
(held, not charged). `Order\SupplierOrderJob` picks it up from there:
|
||||||
Scheduler, `Order\SupplierOrderJob`) → `bsc-shipped` → `completed`. Any
|
submits to the supplier, and only on success **captures** the hold (the
|
||||||
failure at any stage — no sellable offer, margin no longer holds, fraud
|
card is actually charged for the first time here) before moving to
|
||||||
check fails, or the supplier submission itself fails after charging — lands
|
`bsc-ordered` → `bsc-shipped` → `completed`. Any failure at any stage —
|
||||||
the order in `bsc-exception` ("Needs Review"), which is the review queue:
|
no sellable offer, margin no longer holds, fraud check fails, or the
|
||||||
just a filterable WooCommerce order status, not a separate table. A
|
supplier submission itself fails — lands the order in `bsc-exception`
|
||||||
post-charge failure also triggers an automatic refund
|
("Needs Review"), which is the review queue: just a filterable
|
||||||
(`wc_create_refund(..., refund_payment: true)`), per the design doc's
|
WooCommerce order status, not a separate table. Because nothing is
|
||||||
Appendix B decision that an undo is always a refund, never a void.
|
charged until capture, most failures are a **void** (`AuditTrail::record_void()`,
|
||||||
|
zero cost) rather than a refund — a refund only happens for the rarer
|
||||||
|
case of something going wrong *after* a real capture.
|
||||||
|
|
||||||
`wp bookstore force-offer-status <isbn13> <active|stale|discontinued>` (see
|
`wp bookstore force-offer-status <isbn13> <active|stale|discontinued>` (see
|
||||||
`wp bookstore help`) forces a specific ISBN's synthetic offer into a
|
`wp bookstore help`) forces a specific ISBN's synthetic offer into a
|
||||||
specific state — `SyntheticOfferGenerator`'s rolls are random, so this is
|
specific state — `SyntheticOfferGenerator`'s rolls are random, so this is
|
||||||
how to reliably exercise the recheck's failure paths (price-increase,
|
how to reliably exercise the recheck's failure paths (price-increase,
|
||||||
out-of-stock, discontinued-after-charge) in a real checkout.
|
out-of-stock, discontinued-after-supplier-submission) in a real checkout.
|
||||||
|
|
||||||
Real BooksRun/Ingram supplier adapters are still Week 4 — `SupplierOrderJob`
|
Real BooksRun/Ingram supplier adapters are still Week 4 — `SupplierOrderJob`
|
||||||
runs against the synthetic `gutenberg_test` supplier for now, deliberately
|
runs against the synthetic `gutenberg_test` supplier for now, deliberately
|
||||||
|
|||||||
+20
-3
@@ -68,8 +68,9 @@ printf '%s' "$DB_ROOT_PASSWORD_VALUE" > "$SECRETS_DIR/db_root_password"
|
|||||||
HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)"
|
HARDCOVER_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HARDCOVER_API_TOKEN)"
|
||||||
printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token"
|
printf '%s' "$HARDCOVER_API_TOKEN_VALUE" > "$SECRETS_DIR/hardcover_api_token"
|
||||||
# Same reasoning/pattern as Hardcover above — optional, written unconditionally.
|
# Same reasoning/pattern as Hardcover above — optional, written unconditionally.
|
||||||
# Payments\HelcimConfig::is_configured() is false (and the gateway stays
|
# Payments\HelcimGatewayConfig::is_configured() is false (and the
|
||||||
# unconfigured) until a real sandbox token lands in HELCIM_API_TOKEN.
|
# gateway falls back to fake mode — see HelcimClient) until a real
|
||||||
|
# sandbox token lands in HELCIM_API_TOKEN.
|
||||||
HELCIM_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HELCIM_API_TOKEN)"
|
HELCIM_API_TOKEN_VALUE="$(env_get "$ENV_FILE" HELCIM_API_TOKEN)"
|
||||||
printf '%s' "$HELCIM_API_TOKEN_VALUE" > "$SECRETS_DIR/helcim_api_token"
|
printf '%s' "$HELCIM_API_TOKEN_VALUE" > "$SECRETS_DIR/helcim_api_token"
|
||||||
if [[ -z "$HELCIM_API_TOKEN_VALUE" ]]; then
|
if [[ -z "$HELCIM_API_TOKEN_VALUE" ]]; then
|
||||||
@@ -117,11 +118,27 @@ if ! $WP core is-installed; then
|
|||||||
--skip-email
|
--skip-email
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# WordPress defaults to plain "?p=123" permalinks on a fresh install,
|
||||||
|
# which silently breaks routing for /my-account/, the Store API
|
||||||
|
# (/wp-json/wc/store/v1/...), and any other pretty-URL path — confirmed
|
||||||
|
# directly (my-account fell through to the homepage instead of 404ing,
|
||||||
|
# which made the problem non-obvious). "Rewrite rules are empty" further
|
||||||
|
# down in this script was this same root cause going unfixed on every
|
||||||
|
# deploy until now.
|
||||||
|
echo "==> setting pretty permalinks (required for Store API / my-account routing)"
|
||||||
|
$WP rewrite structure '/%postname%/'
|
||||||
|
$WP rewrite flush --hard
|
||||||
|
|
||||||
echo "==> installing/activating theme (Blocksy — free; Book Store starter site needs a manual license + import, see README)"
|
echo "==> installing/activating theme (Blocksy — free; Book Store starter site needs a manual license + import, see README)"
|
||||||
$WP theme install blocksy --activate
|
$WP theme install blocksy --activate
|
||||||
|
|
||||||
echo "==> installing/activating required plugins"
|
echo "==> installing/activating required plugins"
|
||||||
$WP plugin install woocommerce redis-cache blocksy-companion helcim-commerce-for-woocommerce --activate
|
# helcim-commerce-for-woocommerce (the off-the-shelf, immediate-charge-
|
||||||
|
# only plugin) is deliberately NOT installed here anymore — Payments\
|
||||||
|
# HelcimGateway (shipped inside bookstore-core) replaces it with a
|
||||||
|
# preauth-then-capture flow built directly against HelcimPay.js. See
|
||||||
|
# design doc Appendix B's revised decision.
|
||||||
|
$WP plugin install woocommerce redis-cache blocksy-companion --activate
|
||||||
$WP plugin activate bookstore-core
|
$WP plugin activate bookstore-core
|
||||||
$WP redis enable || true
|
$WP redis enable || true
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Blocks/Store API payment method for Payments\HelcimGateway (id
|
||||||
|
* bsc_helcim). Registered via wc.wcBlocksRegistry.registerPaymentMethod
|
||||||
|
* since this store only serves Cart/Checkout blocks — see
|
||||||
|
* Payments\HelcimBlocksIntegration's docblock.
|
||||||
|
*
|
||||||
|
* Flow: customer clicks "Enter card details" -> POST /init (server talks
|
||||||
|
* to Helcim, returns checkoutToken+secretToken) -> load HelcimPay.js,
|
||||||
|
* render its iframe -> customer completes card entry -> HelcimPay.js
|
||||||
|
* postMessages the result to window -> POST /confirm (server validates
|
||||||
|
* the hash and stashes the preauth server-side, keyed to this session —
|
||||||
|
* see Payments\CheckoutSession) -> only then does onPaymentSetup allow
|
||||||
|
* WooCommerce to actually submit the order, which is what triggers
|
||||||
|
* HelcimGateway::process_payment() to pick up that stashed preauth.
|
||||||
|
*
|
||||||
|
* Fake mode (no real Helcim token configured yet — see HelcimClient's
|
||||||
|
* docblock): skips loading the real HelcimPay.js iframe entirely in
|
||||||
|
* favor of a "Simulate card entry" button that drives the exact same
|
||||||
|
* /init + /confirm calls, so this whole flow is testable today.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
var registerPaymentMethod = window.wc.wcBlocksRegistry.registerPaymentMethod;
|
||||||
|
var el = window.wp.element.createElement;
|
||||||
|
var useState = window.wp.element.useState;
|
||||||
|
var useEffect = window.wp.element.useEffect;
|
||||||
|
var settings = window.bscHelcimData || {};
|
||||||
|
var label = settings.title || 'Credit Card';
|
||||||
|
|
||||||
|
function restPost(path, body) {
|
||||||
|
return fetch(settings.restUrl + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': settings.nonce,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body || {}),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().then(function (data) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Payment request failed.');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHelcimPayScript() {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
if (window.appendHelcimPayIframe) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.src = 'https://secure.helcim.app/helcim-pay/services/start.js';
|
||||||
|
script.onload = resolve;
|
||||||
|
script.onerror = function () {
|
||||||
|
reject(new Error('Could not load HelcimPay.js.'));
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact event shape per Helcim's own documented example: event.data = {eventName, eventStatus, eventMessage}. */
|
||||||
|
function waitForHelcimResult(checkoutToken) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var identifierKey = 'helcim-pay-js-' + checkoutToken;
|
||||||
|
function handleMessage(event) {
|
||||||
|
if (!event.data || event.data.eventName !== identifierKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data.eventStatus === 'SUCCESS') {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
resolve(event.data.eventMessage);
|
||||||
|
} else if (event.data.eventStatus === 'ABORTED') {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
reject(new Error('Card entry failed or was cancelled.'));
|
||||||
|
}
|
||||||
|
// 'HIDE' (modal closed without ABORTED/SUCCESS) is ignored here deliberately.
|
||||||
|
}
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRealFlow() {
|
||||||
|
var checkoutToken, secretToken;
|
||||||
|
return restPost('/init', {})
|
||||||
|
.then(function (init) {
|
||||||
|
checkoutToken = init.checkoutToken;
|
||||||
|
secretToken = init.secretToken;
|
||||||
|
return loadHelcimPayScript();
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
window.appendHelcimPayIframe(checkoutToken);
|
||||||
|
return waitForHelcimResult(checkoutToken);
|
||||||
|
})
|
||||||
|
.then(function (eventMessage) {
|
||||||
|
return restPost('/confirm', {
|
||||||
|
rawDataResponse: eventMessage.data || eventMessage,
|
||||||
|
checkoutToken: checkoutToken,
|
||||||
|
secretToken: secretToken,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFakeFlow() {
|
||||||
|
return restPost('/init', {}).then(function () {
|
||||||
|
return restPost('/confirm', {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Content(props) {
|
||||||
|
var stateHook = useState('idle'); // idle | working | ready | error
|
||||||
|
var status = stateHook[0];
|
||||||
|
var setStatus = stateHook[1];
|
||||||
|
var errorHook = useState('');
|
||||||
|
var error = errorHook[0];
|
||||||
|
var setError = errorHook[1];
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function () {
|
||||||
|
var unsubscribe = props.eventRegistration.onPaymentSetup(function () {
|
||||||
|
if (status !== 'ready') {
|
||||||
|
return {
|
||||||
|
type: props.emitResponse.responseTypes.ERROR,
|
||||||
|
message: 'Please complete card entry before placing your order.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: props.emitResponse.responseTypes.SUCCESS };
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
},
|
||||||
|
[status, props.eventRegistration, props.emitResponse]
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
setStatus('working');
|
||||||
|
setError('');
|
||||||
|
var flow = settings.fakeMode ? runFakeFlow() : runRealFlow();
|
||||||
|
flow
|
||||||
|
.then(function () {
|
||||||
|
setStatus('ready');
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
setStatus('error');
|
||||||
|
setError(e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return el(
|
||||||
|
'div',
|
||||||
|
{ className: 'bsc-helcim-payment-method' },
|
||||||
|
settings.description ? el('p', {}, settings.description) : null,
|
||||||
|
status === 'ready'
|
||||||
|
? el('p', {}, '✓ Card authorized — ready to place order.')
|
||||||
|
: el(
|
||||||
|
'button',
|
||||||
|
{ type: 'button', onClick: handleClick, disabled: status === 'working' },
|
||||||
|
status === 'working'
|
||||||
|
? 'Verifying card…'
|
||||||
|
: settings.fakeMode
|
||||||
|
? 'Simulate card entry (dev/fake mode)'
|
||||||
|
: 'Enter card details'
|
||||||
|
),
|
||||||
|
error ? el('p', { style: { color: 'red' } }, error) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPaymentMethod({
|
||||||
|
name: 'bsc_helcim',
|
||||||
|
label: label,
|
||||||
|
content: el(Content),
|
||||||
|
edit: el(Content),
|
||||||
|
canMakePayment: function () {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
ariaLabel: label,
|
||||||
|
supports: {
|
||||||
|
features: ['products'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -51,7 +51,7 @@ register_activation_hook(__FILE__, function () {
|
|||||||
|
|
||||||
add_action('plugins_loaded', function () {
|
add_action('plugins_loaded', function () {
|
||||||
\Bookstore\Core\Schema::maybe_upgrade();
|
\Bookstore\Core\Schema::maybe_upgrade();
|
||||||
\Bookstore\Core\Payments\HelcimConfig::ensure_configured_from_env();
|
\Bookstore\Core\Payments\HelcimGatewayConfig::ensure_configured_from_env();
|
||||||
});
|
});
|
||||||
|
|
||||||
add_filter('woocommerce_shipping_methods', function (array $methods) {
|
add_filter('woocommerce_shipping_methods', function (array $methods) {
|
||||||
@@ -59,6 +59,22 @@ add_filter('woocommerce_shipping_methods', function (array $methods) {
|
|||||||
return $methods;
|
return $methods;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Preauth-then-capture payment flow against HelcimPay.js directly —
|
||||||
|
// design doc Appendix B's revised decision (see includes/Payments/).
|
||||||
|
// Replaces the off-the-shelf helcim-commerce-for-woocommerce plugin's
|
||||||
|
// immediate-charge gateway, not just adds to it — see deploy.sh, which
|
||||||
|
// no longer installs that plugin.
|
||||||
|
add_filter('woocommerce_payment_gateways', function (array $gateways) {
|
||||||
|
$gateways[] = \Bookstore\Core\Payments\HelcimGateway::class;
|
||||||
|
return $gateways;
|
||||||
|
});
|
||||||
|
|
||||||
|
add_action('woocommerce_blocks_payment_method_type_registration', function ($registry) {
|
||||||
|
$registry->register(new \Bookstore\Core\Payments\HelcimBlocksIntegration());
|
||||||
|
});
|
||||||
|
|
||||||
|
\Bookstore\Core\Payments\CheckoutController::register();
|
||||||
|
|
||||||
// Membership tiers/benefits: design doc §07 extension (see includes/Membership/).
|
// Membership tiers/benefits: design doc §07 extension (see includes/Membership/).
|
||||||
\Bookstore\Core\Membership\MemberDiscount::register();
|
\Bookstore\Core\Membership\MemberDiscount::register();
|
||||||
\Bookstore\Core\Membership\Registration::register();
|
\Bookstore\Core\Membership\Registration::register();
|
||||||
@@ -90,10 +106,10 @@ add_filter('woocommerce_email_classes', function (array $emails) {
|
|||||||
return $emails;
|
return $emails;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Appendix B: "the store loses only the processing fee" — recorded
|
// Recorded against the order-audit ledger for every refund — now mostly
|
||||||
// against the order-audit ledger for every refund, whether triggered by
|
// just admin-initiated manual refunds after a real capture, since
|
||||||
// an admin's manual refund click or SupplierOrderJob's automatic
|
// SupplierOrderJob's automatic pre-capture failure path is a void
|
||||||
// post-charge-failure refund.
|
// (AuditTrail::record_void(), zero cost) rather than a refund.
|
||||||
add_action('woocommerce_order_refunded', function (int $order_id, int $refund_id) {
|
add_action('woocommerce_order_refunded', function (int $order_id, int $refund_id) {
|
||||||
\Bookstore\Core\Order\AuditTrail::record_refund($order_id);
|
\Bookstore\Core\Order\AuditTrail::record_refund($order_id);
|
||||||
}, 10, 2);
|
}, 10, 2);
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ class Tier
|
|||||||
* Seeds guest/free_member with today's real defaults — only if
|
* Seeds guest/free_member with today's real defaults — only if
|
||||||
* missing by slug, so a human's later edits via the CLI/settings
|
* missing by slug, so a human's later edits via the CLI/settings
|
||||||
* screen are never clobbered on a later plugins_loaded (same
|
* screen are never clobbered on a later plugins_loaded (same
|
||||||
* never-overwrite convention as Payments\HelcimConfig).
|
* never-overwrite convention as Payments\HelcimGatewayConfig).
|
||||||
*/
|
*/
|
||||||
public static function seed_defaults(): void
|
public static function seed_defaults(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ defined('ABSPATH') || exit;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* bsc_order_audit: one row per order line, written once at checkout
|
* bsc_order_audit: one row per order line, written once at checkout
|
||||||
* (CheckoutRecheck::finalize_order()) and updated once on refund
|
* (CheckoutRecheck::run_checks()) and updated once — either on refund
|
||||||
* (record_refund()). Style mirrors Catalog\SupplierOffer exactly.
|
* (record_refund(), a post-capture reversal) or on void (record_void(),
|
||||||
|
* a pre-capture supplier-order failure — the far more common case under
|
||||||
|
* the preauth-then-capture flow). Style mirrors Catalog\SupplierOffer
|
||||||
|
* exactly.
|
||||||
*/
|
*/
|
||||||
class AuditTrail
|
class AuditTrail
|
||||||
{
|
{
|
||||||
@@ -63,6 +66,9 @@ class AuditTrail
|
|||||||
* A full refund (Appendix B: "the store loses only the processing
|
* A full refund (Appendix B: "the store loses only the processing
|
||||||
* fee") recovers nothing else — record that as the only real loss
|
* fee") recovers nothing else — record that as the only real loss
|
||||||
* against the expectation this row already captured at checkout time.
|
* against the expectation this row already captured at checkout time.
|
||||||
|
* Only reachable now for a refund *after* a real capture (e.g. a
|
||||||
|
* manual admin refund post-fulfillment) — a failed supplier order
|
||||||
|
* before capture goes through record_void() instead, below.
|
||||||
*/
|
*/
|
||||||
public static function record_refund(int $order_id): void
|
public static function record_refund(int $order_id): void
|
||||||
{
|
{
|
||||||
@@ -75,4 +81,22 @@ class AuditTrail
|
|||||||
$order_id
|
$order_id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preauth-then-capture rework: a failed supplier order before capture
|
||||||
|
* reverses the hold (Order\SupplierOrderJob::fail_and_release())
|
||||||
|
* rather than refunding a charge that was never made — genuinely
|
||||||
|
* zero cost, not the processing-fee loss record_refund() models.
|
||||||
|
*/
|
||||||
|
public static function record_void(int $order_id): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"UPDATE " . self::table() . "
|
||||||
|
SET processor_cost_actual = 0,
|
||||||
|
actual_contribution = 0
|
||||||
|
WHERE order_id = %d",
|
||||||
|
$order_id
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,25 @@
|
|||||||
|
|
||||||
namespace Bookstore\Core\Order;
|
namespace Bookstore\Core\Order;
|
||||||
|
|
||||||
use Bookstore\Core\Payments\FraudCheck;
|
use Bookstore\Core\Payments\CheckoutSession;
|
||||||
|
use Bookstore\Core\Payments\HelcimApiException;
|
||||||
|
use Bookstore\Core\Payments\HelcimClient;
|
||||||
|
use Bookstore\Core\Payments\HelcimGateway;
|
||||||
use Bookstore\Core\Pricing\PricingEngine;
|
use Bookstore\Core\Pricing\PricingEngine;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appendix B: "Supplier stock, price, and margin are rechecked live,
|
* Appendix B (revised for the preauth-then-capture rework): "Supplier
|
||||||
* synchronously, immediately before the customer's card is charged."
|
* stock, price, and margin are rechecked live, synchronously,
|
||||||
|
* immediately before the customer's card is authorized (held)." The
|
||||||
|
* fraud check moved out of run_checks() and into
|
||||||
|
* Payments\HelcimGateway::process_payment() — it now runs *after* the
|
||||||
|
* preauth exists rather than before, per the new checkout -> preauth ->
|
||||||
|
* fraud review -> supplier order -> capture sequence. This class still
|
||||||
|
* owns price/margin only, and (new) reversing any preauth that's
|
||||||
|
* already been taken if that recheck fails here — see the "wrinkle"
|
||||||
|
* note on validate_order_before_payment() below.
|
||||||
*
|
*
|
||||||
* Two checkout implementations, two different hook sets — confirmed by
|
* Two checkout implementations, two different hook sets — confirmed by
|
||||||
* reading wp-content/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
|
* reading wp-content/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
|
||||||
@@ -132,6 +143,13 @@ class CheckoutRecheck
|
|||||||
* *supplier-submission* execution (SupplierOrderJob's own meta+lock
|
* *supplier-submission* execution (SupplierOrderJob's own meta+lock
|
||||||
* guards — see that class), which is what the design doc's "duplicate
|
* guards — see that class), which is what the design doc's "duplicate
|
||||||
* webhook/job... do not double-order" requirement is actually about.
|
* webhook/job... do not double-order" requirement is actually about.
|
||||||
|
*
|
||||||
|
* New wrinkle from the preauth rework: by the time this hook fires,
|
||||||
|
* the customer has already completed a preauth via the Blocks
|
||||||
|
* checkout JS (Payments\HelcimGateway isn't invoked yet — that only
|
||||||
|
* happens after this hook passes). If the recheck fails here, that
|
||||||
|
* hold is real and live but process_payment() will never run to
|
||||||
|
* clean it up — so run_checks()'s failure path reverses it directly.
|
||||||
*/
|
*/
|
||||||
public static function validate_order_before_payment(\WC_Order $order, \WP_Error $validation_errors): void
|
public static function validate_order_before_payment(\WC_Order $order, \WP_Error $validation_errors): void
|
||||||
{
|
{
|
||||||
@@ -155,12 +173,12 @@ class CheckoutRecheck
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared by both checkout implementations. Returns null on success
|
* Shared by both checkout implementations. Returns null on success
|
||||||
* (audit rows already written, fraud check already logged) or a
|
* (audit rows already written) or a customer-facing failure message
|
||||||
* customer-facing failure message (order already routed to the
|
* (order already routed to the exception status, any preauth already
|
||||||
* exception status) on failure. Audit rows are only ever inserted
|
* reversed) on failure. Audit rows are only ever inserted after every
|
||||||
* after every item AND the fraud check have passed — never partially,
|
* item has passed — never partially, which matters because a failed
|
||||||
* which matters because a failed attempt can be resubmitted against
|
* attempt can be resubmitted against the same draft order under
|
||||||
* the same draft order under Blocks checkout.
|
* Blocks checkout.
|
||||||
*/
|
*/
|
||||||
private static function run_checks(\WC_Order $order): ?string
|
private static function run_checks(\WC_Order $order): ?string
|
||||||
{
|
{
|
||||||
@@ -177,13 +195,14 @@ class CheckoutRecheck
|
|||||||
$quoted_unit = (float) $item->get_total() / $quantity;
|
$quoted_unit = (float) $item->get_total() / $quantity;
|
||||||
|
|
||||||
if (!$recheck || $recheck->price > $quoted_unit + self::PRICE_CUSHION) {
|
if (!$recheck || $recheck->price > $quoted_unit + self::PRICE_CUSHION) {
|
||||||
|
self::reverse_pending_preauth();
|
||||||
self::route_to_exception($order, sprintf(
|
self::route_to_exception($order, sprintf(
|
||||||
'Supplier/margin recheck failed for "%s" at order-finalize (was quoted $%.2f, fresh price $%.2f).',
|
'Supplier/margin recheck failed for "%s" at order-finalize (was quoted $%.2f, fresh price $%.2f).',
|
||||||
$item->get_name(),
|
$item->get_name(),
|
||||||
$quoted_unit,
|
$quoted_unit,
|
||||||
$recheck->price ?? 0.0
|
$recheck->price ?? 0.0
|
||||||
));
|
));
|
||||||
return 'Sorry, part of your order is no longer available at its listed price. You have not been charged — please try again.';
|
return 'Sorry, part of your order is no longer available at its listed price. Any card hold has been released — please try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$offer = $recheck->offer;
|
$offer = $recheck->offer;
|
||||||
@@ -206,12 +225,6 @@ class CheckoutRecheck
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$fraud = FraudCheck::run($order);
|
|
||||||
if (!$fraud['pass']) {
|
|
||||||
self::route_to_exception($order, 'Fraud check failed: ' . $fraud['reason']);
|
|
||||||
return 'Your order could not be processed and has been placed under review. You have not been charged.';
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($pending_audit_rows as $row) {
|
foreach ($pending_audit_rows as $row) {
|
||||||
AuditTrail::insert(...$row);
|
AuditTrail::insert(...$row);
|
||||||
}
|
}
|
||||||
@@ -219,6 +232,28 @@ class CheckoutRecheck
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraud check already ran (Payments\HelcimGateway::process_payment(),
|
||||||
|
* after the preauth) by the time price/margin ever gets a chance to
|
||||||
|
* fail here — this only fires when the preauth exists but the recheck
|
||||||
|
* itself fails first. Best-effort: nothing was ever charged either
|
||||||
|
* way, and an unreversed hold just expires on its own within Helcim's
|
||||||
|
* 7-day window if this call fails.
|
||||||
|
*/
|
||||||
|
private static function reverse_pending_preauth(): void
|
||||||
|
{
|
||||||
|
$pending = CheckoutSession::pending_preauth();
|
||||||
|
if (!$pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CheckoutSession::clear_pending_preauth();
|
||||||
|
try {
|
||||||
|
HelcimClient::reverse($pending['preauth_transaction_id'], HelcimGateway::client_ip());
|
||||||
|
} catch (HelcimApiException $e) {
|
||||||
|
error_log('bsc_helcim: reverse failed after a failed price/margin recheck: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static function route_to_exception(\WC_Order $order, string $reason): void
|
private static function route_to_exception(\WC_Order $order, string $reason): void
|
||||||
{
|
{
|
||||||
$order->update_status(OrderStatuses::EXCEPTION, $reason);
|
$order->update_status(OrderStatuses::EXCEPTION, $reason);
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ namespace Bookstore\Core\Order;
|
|||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Design doc §04/Appendix B state machine: Pending -> Supplier Verify ->
|
* Design doc §04/Appendix B state machine, revised again for the
|
||||||
* Margin Verify -> Fraud Check -> Charged -> Supplier Ordered -> Shipped
|
* preauth-then-capture rework: Pending -> Margin Verify -> Authorized
|
||||||
* -> Complete. Pending/Charged/Complete map onto WooCommerce's own
|
* (Held) -> Fraud Check -> Supplier Ordered -> Charged -> Shipped ->
|
||||||
* pending/processing/completed; Supplier Verify, Margin Verify, and Fraud
|
* Complete. Pending/Charged/Complete map onto WooCommerce's own
|
||||||
* Check all complete within one request (CheckoutRecheck) and are logged
|
* pending/processing/completed. Margin Verify still completes within one
|
||||||
* as order notes rather than full status transitions — a transition per
|
* request (CheckoutRecheck) and is logged as an order note rather than a
|
||||||
* millisecond-scale sub-step would register three more email/report
|
* full status transition. Authorized IS a real persisted status now —
|
||||||
* buckets for no observable benefit. What *does* need a real, persisted,
|
* unlike the single-request sub-steps, it can legitimately sit for a
|
||||||
* filterable status is "every failure path must end in a visible review
|
* while (fraud review, the supplier-order Action Scheduler job) with
|
||||||
* state" — hence exactly one new failure status (bsc-exception) plus the
|
* real money on hold, so it needs to be visible/filterable in wp-admin,
|
||||||
* two remaining forward-progress steps the state machine names
|
* not just logged. Same reasoning already covered bsc-exception
|
||||||
* (bsc-supplier-ordered, bsc-shipped).
|
* ("every failure path must end in a visible review state") and the two
|
||||||
|
* forward-progress steps (bsc-ordered, bsc-shipped).
|
||||||
*
|
*
|
||||||
* register_post_status()/the wc_order_statuses filter is the standard
|
* register_post_status()/the wc_order_statuses filter is the standard
|
||||||
* WooCommerce recipe for adding a custom order status and is unaffected
|
* WooCommerce recipe for adding a custom order status and is unaffected
|
||||||
@@ -33,6 +34,11 @@ class OrderStatuses
|
|||||||
// the nonsensical 'bsc-supplier-orde'. 'wc-bsc-ordered' is 14 chars.
|
// the nonsensical 'bsc-supplier-orde'. 'wc-bsc-ordered' is 14 chars.
|
||||||
public const SUPPLIER_ORDERED = 'bsc-ordered';
|
public const SUPPLIER_ORDERED = 'bsc-ordered';
|
||||||
public const SHIPPED = 'bsc-shipped';
|
public const SHIPPED = 'bsc-shipped';
|
||||||
|
// Reintroduced by the preauth-then-capture rework (design doc
|
||||||
|
// Appendix B, revised again) — sits between Pending and (native)
|
||||||
|
// Processing: the card is held, not yet charged. 'wc-bsc-authorized'
|
||||||
|
// is 17 chars, fits the same VARCHAR(20) constraint above.
|
||||||
|
public const AUTHORIZED = 'bsc-authorized';
|
||||||
|
|
||||||
public static function register(): void
|
public static function register(): void
|
||||||
{
|
{
|
||||||
@@ -42,6 +48,15 @@ class OrderStatuses
|
|||||||
|
|
||||||
public static function register_post_statuses(): void
|
public static function register_post_statuses(): void
|
||||||
{
|
{
|
||||||
|
register_post_status('wc-' . self::AUTHORIZED, [
|
||||||
|
'label' => 'Authorized (Held)',
|
||||||
|
'public' => false,
|
||||||
|
'exclude_from_search' => false,
|
||||||
|
'show_in_admin_all_list' => true,
|
||||||
|
'show_in_admin_status_list' => true,
|
||||||
|
'label_count' => self::noop_label_count('Authorized (Held)'),
|
||||||
|
]);
|
||||||
|
|
||||||
register_post_status('wc-' . self::EXCEPTION, [
|
register_post_status('wc-' . self::EXCEPTION, [
|
||||||
'label' => 'Needs Review',
|
'label' => 'Needs Review',
|
||||||
'public' => false,
|
'public' => false,
|
||||||
@@ -75,6 +90,9 @@ class OrderStatuses
|
|||||||
$with_new = [];
|
$with_new = [];
|
||||||
foreach ($statuses as $key => $label) {
|
foreach ($statuses as $key => $label) {
|
||||||
$with_new[$key] = $label;
|
$with_new[$key] = $label;
|
||||||
|
if ($key === 'wc-pending') {
|
||||||
|
$with_new['wc-' . self::AUTHORIZED] = 'Authorized (Held)';
|
||||||
|
}
|
||||||
if ($key === 'wc-processing') {
|
if ($key === 'wc-processing') {
|
||||||
$with_new['wc-' . self::SUPPLIER_ORDERED] = 'Supplier Ordered';
|
$with_new['wc-' . self::SUPPLIER_ORDERED] = 'Supplier Ordered';
|
||||||
$with_new['wc-' . self::SHIPPED] = 'Shipped';
|
$with_new['wc-' . self::SHIPPED] = 'Shipped';
|
||||||
|
|||||||
@@ -3,33 +3,47 @@
|
|||||||
namespace Bookstore\Core\Order;
|
namespace Bookstore\Core\Order;
|
||||||
|
|
||||||
use Bookstore\Core\Catalog\SupplierOffer;
|
use Bookstore\Core\Catalog\SupplierOffer;
|
||||||
|
use Bookstore\Core\Payments\HelcimApiException;
|
||||||
|
use Bookstore\Core\Payments\HelcimClient;
|
||||||
|
use Bookstore\Core\Payments\HelcimGateway;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Design doc §04/§06: "Supplier Ordered" — submitting the charged order to
|
* Design doc §04/§06: "Supplier Ordered" — submitting the order to the
|
||||||
* the actual supplier. Routed through Action Scheduler now, even against
|
* actual supplier. Routed through Action Scheduler now, even against the
|
||||||
* the synthetic gutenberg_test supplier which has no real API to be
|
* synthetic gutenberg_test supplier which has no real API to be
|
||||||
* slow/flaky against, so Week 4's BooksRun/Ingram integration only swaps
|
* slow/flaky against, so Week 4's BooksRun/Ingram integration only swaps
|
||||||
* this class's run() body for a real HTTP call — the scheduling, locking,
|
* this class's run() body for a real HTTP call — the scheduling, locking,
|
||||||
* status transition, and exception-routing plumbing is already correct
|
* status transition, and exception-routing plumbing is already correct
|
||||||
* and untouched. Also where the plan doc's "BooksRun/API timeout -> retry
|
* and untouched. Also where the plan doc's "BooksRun/API timeout -> retry
|
||||||
* safely via queue; do not double-order" idempotency requirement actually
|
* safely via queue; do not double-order" idempotency requirement actually
|
||||||
* lives, ahead of Week 4 needing it for real.
|
* lives, ahead of Week 4 needing it for real.
|
||||||
|
*
|
||||||
|
* Preauth-then-capture rework: this class now runs BEFORE the card is
|
||||||
|
* actually charged, not after. It triggers on the order reaching
|
||||||
|
* Order\OrderStatuses::AUTHORIZED (preauth held, fraud check already
|
||||||
|
* passed — Payments\HelcimGateway) rather than woocommerce_payment_complete.
|
||||||
|
* A successful supplier submission captures the held funds for the first
|
||||||
|
* time (payment_complete() only fires here now, at capture); a failed one
|
||||||
|
* reverses the hold instead of refunding a charge that was never made —
|
||||||
|
* the whole point of this rework, replacing Appendix B's accepted
|
||||||
|
* "refund, lose the processing fee" risk with a genuinely zero-cost void.
|
||||||
*/
|
*/
|
||||||
class SupplierOrderJob
|
class SupplierOrderJob
|
||||||
{
|
{
|
||||||
public static function register(): void
|
public static function register(): void
|
||||||
{
|
{
|
||||||
add_action('woocommerce_payment_complete', [self::class, 'schedule_for_order']);
|
add_action('woocommerce_order_status_' . OrderStatuses::AUTHORIZED, [self::class, 'schedule_for_order']);
|
||||||
add_action('bsc_submit_supplier_order', [self::class, 'run']);
|
add_action('bsc_submit_supplier_order', [self::class, 'run']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* woocommerce_payment_complete is documented to sometimes fire more
|
* WooCommerce's per-status transition hooks can fire more than once
|
||||||
* than once for the same order depending on gateway/flow — this guard
|
* for the same order (e.g. a status re-saved without actually
|
||||||
* (checked again, more strongly, inside run() itself) is what stops a
|
* changing) — this guard (checked again, more strongly, inside run()
|
||||||
* duplicate firing from scheduling the submission twice.
|
* itself) is what stops a duplicate firing from scheduling the
|
||||||
|
* submission twice.
|
||||||
*/
|
*/
|
||||||
public static function schedule_for_order(int $order_id): void
|
public static function schedule_for_order(int $order_id): void
|
||||||
{
|
{
|
||||||
@@ -72,7 +86,7 @@ class SupplierOrderJob
|
|||||||
if (!$offer) {
|
if (!$offer) {
|
||||||
$order->update_meta_data('_bsc_supplier_order_status', 'failed');
|
$order->update_meta_data('_bsc_supplier_order_status', 'failed');
|
||||||
$order->save();
|
$order->save();
|
||||||
self::fail_and_refund(
|
self::fail_and_release(
|
||||||
$order,
|
$order,
|
||||||
"Pinned supplier offer #{$audit_row->offer_id} is no longer sellable at fulfillment time."
|
"Pinned supplier offer #{$audit_row->offer_id} is no longer sellable at fulfillment time."
|
||||||
);
|
);
|
||||||
@@ -83,44 +97,66 @@ class SupplierOrderJob
|
|||||||
$order->add_order_note('Supplier order submitted (synthetic: gutenberg_test).');
|
$order->add_order_note('Supplier order submitted (synthetic: gutenberg_test).');
|
||||||
$order->update_meta_data('_bsc_supplier_order_status', 'submitted');
|
$order->update_meta_data('_bsc_supplier_order_status', 'submitted');
|
||||||
$order->save();
|
$order->save();
|
||||||
$order->update_status(OrderStatuses::SUPPLIER_ORDERED);
|
|
||||||
|
self::capture_and_complete($order);
|
||||||
} finally {
|
} finally {
|
||||||
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_key));
|
$wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appendix B residual risk, made concrete: "a charge that later needs
|
* The supplier order just succeeded — this is the first point the
|
||||||
* to be undone... becomes a refund instead of a void. The customer is
|
* card is actually charged. Captures the preauth held since checkout,
|
||||||
* made whole in full; the store loses only the processing fee."
|
* then calls WC's own payment_complete() (which now fires
|
||||||
* refund_payment=true is what makes wc_create_refund() actually invoke
|
* woocommerce_payment_complete for real, restoring that hook's
|
||||||
* the gateway's process_refund() rather than just recording a line
|
* original meaning) before moving to Supplier Ordered.
|
||||||
* item.
|
|
||||||
*
|
*
|
||||||
* wc_create_refund() returns a WP_Error — not false, not a thrown
|
* Capture failing here (card expired/declined during the hold window
|
||||||
* exception — when the active gateway doesn't support automatic
|
* — rare, but real) is its own distinct failure mode from a supplier-
|
||||||
* refunds (confirmed directly: WooCommerce's own stock Cheque/BACS/COD
|
* side problem: the supplier submission already (synthetically, for
|
||||||
* gateways all lack process_refund(), and this is exactly the gateway
|
* now) succeeded, so this can't cleanly fall back to fail_and_release()
|
||||||
* used to test this path without real Helcim credentials yet). Left
|
* the way an unsellable offer can. Routes to exception with an
|
||||||
* unchecked, the refund silently never happens with no trace anywhere
|
* explicit "needs manual follow-up" note instead — the same manual-
|
||||||
* — the order still reaches bsc-exception, but "every failure path
|
* fallback philosophy Appendix B already accepted for one-off
|
||||||
* must end in a visible review state" needs the refund's own outcome
|
* unfulfillable orders.
|
||||||
* to be visible too, not just the parent order state.
|
|
||||||
*/
|
*/
|
||||||
private static function fail_and_refund(\WC_Order $order, string $reason): void
|
private static function capture_and_complete(\WC_Order $order): void
|
||||||
|
{
|
||||||
|
$preauth_id = (int) $order->get_meta('_bsc_preauth_transaction_id');
|
||||||
|
$amount = (float) $order->get_total();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$capture = HelcimClient::capture($preauth_id, $amount, HelcimGateway::client_ip());
|
||||||
|
} catch (HelcimApiException $e) {
|
||||||
|
$order->update_status(
|
||||||
|
OrderStatuses::EXCEPTION,
|
||||||
|
"Supplier order succeeded but capture failed ({$e->getMessage()}) — needs manual follow-up, do not resubmit to the supplier."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->payment_complete((string) ($capture['transactionId'] ?? $preauth_id));
|
||||||
|
$order->update_status(OrderStatuses::SUPPLIER_ORDERED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appendix B's residual risk ("a charge that later needs to be
|
||||||
|
* undone becomes a refund... the store loses only the processing
|
||||||
|
* fee") is exactly what this rework was for: nothing has been
|
||||||
|
* charged yet at this point, so a failed supplier order releases the
|
||||||
|
* hold instead — genuinely zero cost, not "only the processing fee."
|
||||||
|
*/
|
||||||
|
private static function fail_and_release(\WC_Order $order, string $reason): void
|
||||||
{
|
{
|
||||||
$order->update_status(OrderStatuses::EXCEPTION, $reason);
|
$order->update_status(OrderStatuses::EXCEPTION, $reason);
|
||||||
|
|
||||||
$refund = wc_create_refund([
|
$preauth_id = (int) $order->get_meta('_bsc_preauth_transaction_id');
|
||||||
'order_id' => $order->get_id(),
|
try {
|
||||||
'amount' => (float) $order->get_total(),
|
HelcimClient::reverse($preauth_id, HelcimGateway::client_ip());
|
||||||
'reason' => $reason,
|
AuditTrail::record_void($order->get_id());
|
||||||
'refund_payment' => true,
|
} catch (HelcimApiException $e) {
|
||||||
]);
|
|
||||||
|
|
||||||
if (is_wp_error($refund)) {
|
|
||||||
$order->add_order_note(
|
$order->add_order_note(
|
||||||
'Automatic refund could not be issued (' . $refund->get_error_message() . ') — needs a manual refund.'
|
'Could not release the card hold (' . $e->getMessage() . ') — it will expire on its own within 7 days; no manual refund needed.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Payments;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two REST endpoints the Blocks checkout JS (assets/js/helcim-checkout-block.js)
|
||||||
|
* calls to get a validated preauth before the WooCommerce order is even
|
||||||
|
* submitted — see Payments\CheckoutSession's docblock for why this has
|
||||||
|
* to happen before order creation rather than inside process_payment().
|
||||||
|
*
|
||||||
|
* init: server-side call to HelcimClient::initialize_checkout() (keeps
|
||||||
|
* the API token off the client entirely) for the current cart total;
|
||||||
|
* returns checkoutToken + secretToken to the client — this matches
|
||||||
|
* Helcim's own documented HelcimPay.js pattern (their reference
|
||||||
|
* validateResponse() example sends secretToken back to your endpoint
|
||||||
|
* alongside the raw transaction response, rather than keeping it purely
|
||||||
|
* server-side), not a deviation from it.
|
||||||
|
* confirm: the client forwards {rawDataResponse, checkoutToken,
|
||||||
|
* secretToken} here after HelcimPay.js's iframe completes (or, in fake
|
||||||
|
* mode, after clicking "Simulate card entry"). Hash validation per
|
||||||
|
* Helcim's documented algorithm: sha256(json_encode(rawDataResponse) .
|
||||||
|
* secretToken), compared against the response's own hash field — the
|
||||||
|
* exact field Helcim compares it to wasn't shown verbatim in what's
|
||||||
|
* accessible without a real sandbox account, so this is a best-effort
|
||||||
|
* implementation of their documented algorithm, not independently
|
||||||
|
* confirmed against a real transaction yet.
|
||||||
|
*/
|
||||||
|
class CheckoutController
|
||||||
|
{
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
add_action('rest_api_init', [self::class, 'register_routes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_routes(): void
|
||||||
|
{
|
||||||
|
register_rest_route('bsc/v1', '/helcim/init', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [self::class, 'init'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route('bsc/v1', '/helcim/confirm', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [self::class, 'confirm'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmed directly (live testing): a plain register_rest_route()
|
||||||
|
* callback does NOT get WC()->cart/WC()->session populated from the
|
||||||
|
* customer's session cookie the way WooCommerce's own Store API
|
||||||
|
* routes do (their cart endpoint saw the item; this one reported
|
||||||
|
* "Cart is empty" for the identical session/cookie). wc_load_cart()
|
||||||
|
* is WooCommerce's own documented fix for exactly this — background/
|
||||||
|
* REST/AJAX contexts outside the normal front-end bootstrap that
|
||||||
|
* still need the real session-backed cart.
|
||||||
|
*/
|
||||||
|
private static function ensure_cart_loaded(): void
|
||||||
|
{
|
||||||
|
if (function_exists('wc_load_cart')) {
|
||||||
|
wc_load_cart();
|
||||||
|
}
|
||||||
|
// Confirmed directly (live testing): a freshly wc_load_cart()'d
|
||||||
|
// cart's ->get_total('edit') reads 0 until something triggers its
|
||||||
|
// lazy total calculation — is_empty()/get_cart() happen to do
|
||||||
|
// this as a side effect, which is why init() (checks is_empty()
|
||||||
|
// first) got the right amount but confirm() (reads the total as
|
||||||
|
// its first cart touch) silently got 0. Calculating explicitly
|
||||||
|
// here removes the dependency on that incidental ordering.
|
||||||
|
if (WC()->cart) {
|
||||||
|
WC()->cart->calculate_totals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function init(\WP_REST_Request $request): \WP_REST_Response
|
||||||
|
{
|
||||||
|
self::ensure_cart_loaded();
|
||||||
|
|
||||||
|
if (!WC()->cart || WC()->cart->is_empty()) {
|
||||||
|
return new \WP_REST_Response(['error' => 'Cart is empty.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = (float) WC()->cart->get_total('edit');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = HelcimClient::initialize_checkout($amount, get_woocommerce_currency(), 'preauth');
|
||||||
|
} catch (HelcimApiException $e) {
|
||||||
|
return new \WP_REST_Response(['error' => $e->getMessage()], 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \WP_REST_Response([
|
||||||
|
'checkoutToken' => $result['checkoutToken'],
|
||||||
|
'secretToken' => $result['secretToken'] ?? null,
|
||||||
|
'amount' => $amount,
|
||||||
|
'fakeMode' => !empty($result['fake']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function confirm(\WP_REST_Request $request): \WP_REST_Response
|
||||||
|
{
|
||||||
|
self::ensure_cart_loaded();
|
||||||
|
$amount = (float) WC()->cart->get_total('edit');
|
||||||
|
|
||||||
|
if (!HelcimGatewayConfig::is_configured()) {
|
||||||
|
$transaction = [
|
||||||
|
'transactionId' => random_int(900000000, 999999999),
|
||||||
|
'status' => 'APPROVED',
|
||||||
|
'cardToken' => 'fake_card_token_' . wp_generate_uuid4(),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$raw = $request->get_param('rawDataResponse');
|
||||||
|
$secret = (string) $request->get_param('secretToken');
|
||||||
|
$transaction = is_array($raw) ? $raw : json_decode((string) $raw, true);
|
||||||
|
|
||||||
|
if (!is_array($transaction) || !self::hash_is_valid($transaction, $secret)) {
|
||||||
|
return new \WP_REST_Response(['error' => 'Could not validate the payment result.'], 400);
|
||||||
|
}
|
||||||
|
if (empty($transaction['transactionId']) || ($transaction['status'] ?? '') !== 'APPROVED') {
|
||||||
|
return new \WP_REST_Response(['error' => 'Payment was not approved.'], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckoutSession::stash_pending_preauth([
|
||||||
|
'preauth_transaction_id' => (int) $transaction['transactionId'],
|
||||||
|
'card_token' => $transaction['cardToken'] ?? null,
|
||||||
|
'amount' => $amount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new \WP_REST_Response(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helcim's documented algorithm: sha256(json_encode(transaction data)
|
||||||
|
* . secretToken), compared against the response's own hash field —
|
||||||
|
* see this class's docblock for the "not independently confirmed
|
||||||
|
* against a real transaction yet" caveat.
|
||||||
|
*/
|
||||||
|
private static function hash_is_valid(array $transaction, string $secret): bool
|
||||||
|
{
|
||||||
|
if (empty($transaction['hash']) || $secret === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$data = $transaction;
|
||||||
|
unset($data['hash']);
|
||||||
|
$expected = hash('sha256', wp_json_encode($data) . $secret);
|
||||||
|
return hash_equals($expected, (string) $transaction['hash']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Payments;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preauth has to exist before the WooCommerce order does (the
|
||||||
|
* customer is present entering card details in the HelcimPay.js iframe
|
||||||
|
* before submitting checkout), but Order\CheckoutRecheck's price/margin
|
||||||
|
* validation still runs after that, right before process_payment() — if
|
||||||
|
* it fails there, a live uncaptured hold already exists and must be
|
||||||
|
* reversed even though process_payment() is never reached. A short-lived
|
||||||
|
* transient, keyed by the same customer-session identity
|
||||||
|
* Order\CheckoutRecheck::lock_key() already uses for its advisory lock,
|
||||||
|
* is what lets both HelcimGateway::process_payment() and
|
||||||
|
* CheckoutRecheck's failure path find (and clear) the same pending
|
||||||
|
* preauth regardless of which one runs first.
|
||||||
|
*/
|
||||||
|
class CheckoutSession
|
||||||
|
{
|
||||||
|
private const TTL = 20 * MINUTE_IN_SECONDS;
|
||||||
|
|
||||||
|
public static function identity(): string
|
||||||
|
{
|
||||||
|
return WC()->session ? (string) WC()->session->get_customer_id() : (string) session_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function transient_key(): string
|
||||||
|
{
|
||||||
|
return 'bsc_preauth_' . md5(self::identity());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{preauth_transaction_id:int, card_token:?string, amount:float} $data */
|
||||||
|
public static function stash_pending_preauth(array $data): void
|
||||||
|
{
|
||||||
|
set_transient(self::transient_key(), $data, self::TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{preauth_transaction_id:int, card_token:?string, amount:float}|null */
|
||||||
|
public static function pending_preauth(): ?array
|
||||||
|
{
|
||||||
|
$data = get_transient(self::transient_key());
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clear_pending_preauth(): void
|
||||||
|
{
|
||||||
|
delete_transient(self::transient_key());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,12 @@ defined('ABSPATH') || exit;
|
|||||||
* the extension point an optional paid plugin (Shop Defender) plugs into
|
* the extension point an optional paid plugin (Shop Defender) plugs into
|
||||||
* later via add_filter('bsc_fraud_check_result', ...). The step still
|
* later via add_filter('bsc_fraud_check_result', ...). The step still
|
||||||
* runs and is logged on every order, unconditionally, so "Fraud Check" is
|
* runs and is logged on every order, unconditionally, so "Fraud Check" is
|
||||||
* a real, auditable stage in the state machine rather than an absent one
|
* a real, auditable stage in the state machine rather than an absent one.
|
||||||
* — see Order\CheckoutRecheck::finalize_order(), which calls this right
|
* Called from HelcimGateway::process_payment(), right after the card is
|
||||||
* before an order is allowed to proceed to the payment gateway.
|
* preauthorized (held) but before the order is allowed to proceed to
|
||||||
|
* Authorized status — moved here from Order\CheckoutRecheck by the
|
||||||
|
* preauth-then-capture rework: fraud review now runs after the hold
|
||||||
|
* exists, not before, per design doc Appendix B's revised sequence.
|
||||||
*/
|
*/
|
||||||
class FraudCheck
|
class FraudCheck
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Payments;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
class HelcimApiException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Payments;
|
||||||
|
|
||||||
|
use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks/Store API side of Payments\HelcimGateway — this store only
|
||||||
|
* serves Cart/Checkout blocks (confirmed live, no shortcode checkout
|
||||||
|
* exists — see Order\CheckoutRecheck's docblock), so a plain
|
||||||
|
* WC_Payment_Gateway alone isn't enough to show up at checkout; it also
|
||||||
|
* needs registering here plus a matching JS bundle
|
||||||
|
* (assets/js/helcim-checkout-block.js) via
|
||||||
|
* wc.wcBlocksRegistry.registerPaymentMethod. process_payment() on the
|
||||||
|
* gateway itself is still what actually runs server-side — this class
|
||||||
|
* only handles the block rendering/script-enqueueing side.
|
||||||
|
*/
|
||||||
|
class HelcimBlocksIntegration extends AbstractPaymentMethodType
|
||||||
|
{
|
||||||
|
protected $name = 'bsc_helcim';
|
||||||
|
|
||||||
|
public function initialize(): void
|
||||||
|
{
|
||||||
|
$this->settings = get_option(HelcimGatewayConfig::OPTION_NAME, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function is_active(): bool
|
||||||
|
{
|
||||||
|
return ($this->settings['enabled'] ?? 'no') === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_payment_method_script_handles(): array
|
||||||
|
{
|
||||||
|
$asset_path = BSC_PLUGIN_DIR . 'assets/js/helcim-checkout-block.js';
|
||||||
|
|
||||||
|
wp_register_script(
|
||||||
|
'bsc-helcim-checkout-block',
|
||||||
|
plugins_url('assets/js/helcim-checkout-block.js', BSC_PLUGIN_FILE),
|
||||||
|
['wc-blocks-registry', 'wc-settings', 'wp-element', 'wp-i18n'],
|
||||||
|
file_exists($asset_path) ? (string) filemtime($asset_path) : BSC_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_localize_script('bsc-helcim-checkout-block', 'bscHelcimData', [
|
||||||
|
'title' => $this->settings['title'] ?? 'Credit Card',
|
||||||
|
'description' => $this->settings['description'] ?? '',
|
||||||
|
'restUrl' => esc_url_raw(rest_url('bsc/v1/helcim')),
|
||||||
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
|
'fakeMode' => !HelcimGatewayConfig::is_configured(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['bsc-helcim-checkout-block'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_payment_method_data(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => $this->settings['title'] ?? 'Credit Card',
|
||||||
|
'description' => $this->settings['description'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Payments;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around Helcim's Payment API (devdocs.helcim.com), built
|
||||||
|
* directly against HelcimPay.js/the raw API rather than the off-the-shelf
|
||||||
|
* helcim-commerce-for-woocommerce plugin — see design doc Appendix B's
|
||||||
|
* revised decision. Endpoints/parameters confirmed against Helcim's
|
||||||
|
* current developer docs:
|
||||||
|
* - POST /v2/helcim-pay/initialize {paymentType, amount, currency}
|
||||||
|
* -> {checkoutToken, secretToken}
|
||||||
|
* - POST /v2/payment/capture {preAuthTransactionId, amount, ipAddress}
|
||||||
|
* -> transaction object (incl. cardToken, transactionId)
|
||||||
|
* - POST /v2/payment/reverse {cardTransactionId, ipAddress}
|
||||||
|
* -> transaction object
|
||||||
|
* Capture/reverse's exact base path (the /v2 prefix specifically) was
|
||||||
|
* not shown alongside their parameter docs the way initialize's was —
|
||||||
|
* confirm against a real sandbox call before this is ever used in
|
||||||
|
* anything but fake mode.
|
||||||
|
*
|
||||||
|
* FAKE MODE: HelcimGatewayConfig::is_configured() is false until a real
|
||||||
|
* token exists (none does yet, pre-launch). Rather than blocking all
|
||||||
|
* development on getting Helcim sandbox access, every method here
|
||||||
|
* returns a synthetic approved response when unconfigured — same
|
||||||
|
* pattern this project already uses on the supplier side before real
|
||||||
|
* BooksRun access existed (Import\SyntheticOfferGenerator, the
|
||||||
|
* gutenberg_test supplier). This lets the entire checkout -> preauth ->
|
||||||
|
* fraud -> supplier -> capture/reverse flow be driven for real through
|
||||||
|
* the WooCommerce/Blocks plumbing in dev today. The instant a real token
|
||||||
|
* lands in HELCIM_API_TOKEN, every call here switches to hitting the
|
||||||
|
* real API — no code change required anywhere.
|
||||||
|
*/
|
||||||
|
class HelcimClient
|
||||||
|
{
|
||||||
|
private const API_BASE = 'https://api.helcim.com/v2';
|
||||||
|
|
||||||
|
public static function initialize_checkout(float $amount, string $currency, string $payment_type): array
|
||||||
|
{
|
||||||
|
if (!HelcimGatewayConfig::is_configured()) {
|
||||||
|
return [
|
||||||
|
'checkoutToken' => 'fake_checkout_' . wp_generate_uuid4(),
|
||||||
|
'secretToken' => 'fake_secret_' . wp_generate_uuid4(),
|
||||||
|
'fake' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::post('/helcim-pay/initialize', [
|
||||||
|
'paymentType' => $payment_type,
|
||||||
|
'amount' => $amount,
|
||||||
|
'currency' => $currency,
|
||||||
|
], with_idempotency_key: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function capture(int $preauth_transaction_id, float $amount, string $ip_address): array
|
||||||
|
{
|
||||||
|
if (!HelcimGatewayConfig::is_configured()) {
|
||||||
|
return self::fake_transaction_response($amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::post('/payment/capture', [
|
||||||
|
'preAuthTransactionId' => $preauth_transaction_id,
|
||||||
|
'amount' => $amount,
|
||||||
|
'ipAddress' => $ip_address,
|
||||||
|
], with_idempotency_key: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reverse(int $transaction_id, string $ip_address): array
|
||||||
|
{
|
||||||
|
if (!HelcimGatewayConfig::is_configured()) {
|
||||||
|
return self::fake_transaction_response(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::post('/payment/reverse', [
|
||||||
|
'cardTransactionId' => $transaction_id,
|
||||||
|
'ipAddress' => $ip_address,
|
||||||
|
], with_idempotency_key: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fake_transaction_response(float $amount): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'transactionId' => random_int(900000000, 999999999),
|
||||||
|
'status' => 'APPROVED',
|
||||||
|
'amount' => $amount,
|
||||||
|
'cardToken' => 'fake_card_token_' . wp_generate_uuid4(),
|
||||||
|
'fake' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function post(string $path, array $body, bool $with_idempotency_key): array
|
||||||
|
{
|
||||||
|
$token = HelcimGatewayConfig::token();
|
||||||
|
$headers = ['api-token' => $token, 'Content-Type' => 'application/json'];
|
||||||
|
if ($with_idempotency_key) {
|
||||||
|
$headers['idempotency-key'] = wp_generate_uuid4();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = wp_remote_post(self::API_BASE . $path, [
|
||||||
|
'headers' => $headers,
|
||||||
|
'body' => wp_json_encode($body),
|
||||||
|
'timeout' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
throw new HelcimApiException('Helcim API request failed: ' . $response->get_error_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = wp_remote_retrieve_response_code($response);
|
||||||
|
$decoded = json_decode(wp_remote_retrieve_body($response), true);
|
||||||
|
|
||||||
|
if ($code < 200 || $code >= 300 || !is_array($decoded) || !empty($decoded['errors'])) {
|
||||||
|
$message = is_array($decoded) && !empty($decoded['errors'])
|
||||||
|
? implode('; ', (array) $decoded['errors'])
|
||||||
|
: "unexpected HTTP {$code}";
|
||||||
|
throw new HelcimApiException("Helcim API error on {$path}: {$message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Payments;
|
||||||
|
|
||||||
|
use Bookstore\Core\Order\OrderStatuses;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom WC_Payment_Gateway against HelcimPay.js directly (id
|
||||||
|
* `bsc_helcim`) — replaces the off-the-shelf helcim-commerce-for-
|
||||||
|
* woocommerce plugin's immediate-charge flow. See design doc Appendix
|
||||||
|
* B's revised decision and Payments\HelcimClient's docblock (fake mode).
|
||||||
|
*
|
||||||
|
* process_payment() does NOT charge the card. By the time it runs, the
|
||||||
|
* customer has already completed a preauth via the Blocks checkout JS
|
||||||
|
* (assets/js/helcim-checkout-block.js) — Payments\CheckoutController's
|
||||||
|
* /confirm endpoint already validated it and stashed it in
|
||||||
|
* Payments\CheckoutSession. This method's job is: pick up that pending
|
||||||
|
* preauth, run the (moved-here) fraud check, and either reverse the hold
|
||||||
|
* and route to review, or mark the order Authorized and let
|
||||||
|
* Order\SupplierOrderJob take it from there — capture only happens
|
||||||
|
* after a successful supplier order.
|
||||||
|
*/
|
||||||
|
class HelcimGateway extends \WC_Payment_Gateway
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->id = 'bsc_helcim';
|
||||||
|
$this->method_title = 'Helcim (preauth/capture)';
|
||||||
|
$this->method_description = 'Card is held (preauth) at checkout and only captured once the supplier order succeeds — see design doc Appendix B.';
|
||||||
|
$this->has_fields = false;
|
||||||
|
$this->supports = ['products'];
|
||||||
|
|
||||||
|
$this->init_form_fields();
|
||||||
|
$this->init_settings();
|
||||||
|
|
||||||
|
$this->title = $this->get_option('title', 'Credit Card');
|
||||||
|
$this->description = $this->get_option('description', '');
|
||||||
|
$this->enabled = $this->get_option('enabled', 'no');
|
||||||
|
|
||||||
|
add_action('woocommerce_update_options_payment_gateways_' . $this->id, [$this, 'process_admin_options']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function init_form_fields(): void
|
||||||
|
{
|
||||||
|
$this->form_fields = [
|
||||||
|
'enabled' => [
|
||||||
|
'title' => 'Enable/Disable',
|
||||||
|
'type' => 'checkbox',
|
||||||
|
'label' => 'Enable this payment method',
|
||||||
|
'default' => 'no',
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'title' => 'Title',
|
||||||
|
'type' => 'text',
|
||||||
|
'default' => 'Credit Card',
|
||||||
|
],
|
||||||
|
'description' => [
|
||||||
|
'title' => 'Description',
|
||||||
|
'type' => 'textarea',
|
||||||
|
'default' => '',
|
||||||
|
],
|
||||||
|
'environment' => [
|
||||||
|
'title' => 'Environment',
|
||||||
|
'type' => 'select',
|
||||||
|
'options' => ['sandbox' => 'Sandbox', 'live' => 'Live'],
|
||||||
|
'default' => 'sandbox',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process_payment($order_id): array
|
||||||
|
{
|
||||||
|
$order = wc_get_order($order_id);
|
||||||
|
$pending = CheckoutSession::pending_preauth();
|
||||||
|
|
||||||
|
if (!$order || !$pending) {
|
||||||
|
wc_add_notice('Your payment session expired — please try again.', 'error');
|
||||||
|
return ['result' => 'failure'];
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckoutSession::clear_pending_preauth();
|
||||||
|
|
||||||
|
$order->update_meta_data('_bsc_preauth_transaction_id', $pending['preauth_transaction_id']);
|
||||||
|
$order->update_meta_data('_bsc_preauth_card_token', $pending['card_token']);
|
||||||
|
$order->update_meta_data('_bsc_preauth_amount', $pending['amount']);
|
||||||
|
$order->save();
|
||||||
|
|
||||||
|
$fraud = FraudCheck::run($order);
|
||||||
|
if (!$fraud['pass']) {
|
||||||
|
$this->reverse_preauth($pending['preauth_transaction_id'], "Fraud check failed: {$fraud['reason']}");
|
||||||
|
$order->update_status(OrderStatuses::EXCEPTION, "Fraud check failed: {$fraud['reason']}");
|
||||||
|
wc_add_notice('Your order could not be processed and has been placed under review. Your card was not charged.', 'error');
|
||||||
|
return ['result' => 'failure'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->update_status(OrderStatuses::AUTHORIZED, 'Card authorized (preauth held) — not yet charged.');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'result' => 'success',
|
||||||
|
'redirect' => $this->get_return_url($order),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reverse_preauth(int $preauth_transaction_id, string $reason): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
HelcimClient::reverse($preauth_transaction_id, self::client_ip());
|
||||||
|
} catch (HelcimApiException $e) {
|
||||||
|
// Nothing was charged either way; a failed reverse call just
|
||||||
|
// means the hold expires on its own within Helcim's 7-day
|
||||||
|
// window instead of being released immediately — logged so
|
||||||
|
// it's not silently invisible, matching "every failure path
|
||||||
|
// must end in a visible review state."
|
||||||
|
error_log("bsc_helcim: reverse failed for transaction {$preauth_transaction_id} ({$reason}): " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function client_ip(): string
|
||||||
|
{
|
||||||
|
if (class_exists(\WC_Geolocation::class)) {
|
||||||
|
return \WC_Geolocation::get_ip_address();
|
||||||
|
}
|
||||||
|
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-19
@@ -5,30 +5,36 @@ namespace Bookstore\Core\Payments;
|
|||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeds the Helcim gateway's WooCommerce settings from the environment.
|
* Replaces Payments\HelcimConfig (which seeded settings for the
|
||||||
* Gateway id `helcimjs`, settings option `woocommerce_helcimjs_settings`,
|
* off-the-shelf helcim-commerce-for-woocommerce plugin — removed, see
|
||||||
* field keys `enabled`/`title`/`description`/`environment`/
|
* design doc Appendix B's revised decision: that plugin only supports
|
||||||
* `sandbox_api_token`/`live_api_token`/`enable_saved_cards` — confirmed by
|
* immediate-charge "Purchase" transactions, not the preauth/capture flow
|
||||||
* installing helcim-commerce-for-woocommerce v5.0.3 in dev and reading its
|
* this now uses, so a custom gateway (Payments\HelcimGateway) replaces
|
||||||
* includes/Gateway.php + includes/Options.php directly, not guessed. No
|
* it rather than extending it). Gateway id `bsc_helcim`, settings option
|
||||||
* separate "account ID" field exists on this plugin — HELCIM_ACCOUNT_ID
|
* `woocommerce_bsc_helcim_settings` — our own, not the old plugin's.
|
||||||
* (still scaffolded in .env/docker-compose.yml) is unused here.
|
|
||||||
*
|
*
|
||||||
* _FILE env-var reading mirrors Integration\HardcoverAdapter::token()
|
* _FILE env-var reading mirrors Integration\HardcoverAdapter::token()
|
||||||
* exactly — same reasoning: a plain ${VAR} is visible via `docker
|
* exactly, same reasoning: a plain ${VAR} is visible via `docker
|
||||||
* inspect`/`/proc/<pid>/environ`, a 644 file inside a 700 secrets/ dir
|
* inspect`/`/proc/<pid>/environ`, a 644 file inside a 700 secrets/ dir
|
||||||
* isn't.
|
* isn't.
|
||||||
*/
|
*/
|
||||||
class HelcimConfig
|
class HelcimGatewayConfig
|
||||||
{
|
{
|
||||||
private const OPTION_NAME = 'woocommerce_helcimjs_settings';
|
public const OPTION_NAME = 'woocommerce_bsc_helcim_settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* False today (no HELCIM_API_TOKEN in any .env yet) — this is
|
||||||
|
* exactly the condition Payments\HelcimClient checks to decide
|
||||||
|
* whether to talk to the real Helcim API or return fake-mode
|
||||||
|
* responses. Flips to true, with zero code changes anywhere else,
|
||||||
|
* the moment a real sandbox token is added.
|
||||||
|
*/
|
||||||
public static function is_configured(): bool
|
public static function is_configured(): bool
|
||||||
{
|
{
|
||||||
return self::token() !== null;
|
return self::token() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function token(): ?string
|
public static function token(): ?string
|
||||||
{
|
{
|
||||||
$file = getenv('HELCIM_API_TOKEN_FILE');
|
$file = getenv('HELCIM_API_TOKEN_FILE');
|
||||||
if (!$file || !is_readable($file)) {
|
if (!$file || !is_readable($file)) {
|
||||||
@@ -40,18 +46,13 @@ class HelcimConfig
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills in only what's still unset — never overwrites a value a human
|
* Fills in only what's still unset — never overwrites a value a human
|
||||||
* already changed by hand in wp-admin (e.g. a custom title/description,
|
* already changed by hand in wp-admin. Only ever seeds the sandbox
|
||||||
* or a deliberate switch to 'live'). Only ever seeds the SANDBOX
|
|
||||||
* token: this repo has no production Helcim credentials, and going
|
* token: this repo has no production Helcim credentials, and going
|
||||||
* live is a deliberate separate decision a human makes on the
|
* live is a deliberate separate decision a human makes on the
|
||||||
* settings screen, not something an env var should flip silently.
|
* settings screen, not something an env var should flip silently.
|
||||||
*/
|
*/
|
||||||
public static function ensure_configured_from_env(): void
|
public static function ensure_configured_from_env(): void
|
||||||
{
|
{
|
||||||
if (!class_exists(\Helcim\Gateway::class)) {
|
|
||||||
return; // plugin not installed/active
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = self::token();
|
$token = self::token();
|
||||||
if ($token === null) {
|
if ($token === null) {
|
||||||
return;
|
return;
|
||||||
@@ -67,7 +68,6 @@ class HelcimConfig
|
|||||||
'title' => 'Credit Card',
|
'title' => 'Credit Card',
|
||||||
'description' => '',
|
'description' => '',
|
||||||
'environment' => 'sandbox',
|
'environment' => 'sandbox',
|
||||||
'enable_saved_cards' => 'no',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (empty($settings['sandbox_api_token'])) {
|
if (empty($settings['sandbox_api_token'])) {
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"redefinable-internals": ["getenv", "is_readable", "file_get_contents"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Tests\Unit\Payments;
|
||||||
|
|
||||||
|
use Bookstore\Core\Payments\HelcimApiException;
|
||||||
|
use Bookstore\Core\Payments\HelcimClient;
|
||||||
|
use Bookstore\Core\Tests\TestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HelcimGatewayConfig::token() reads getenv('HELCIM_API_TOKEN_FILE') —
|
||||||
|
* mocking that directly is what switches HelcimClient between fake mode
|
||||||
|
* (no token: every call returns a synthetic approved response, no
|
||||||
|
* network call) and real mode (a token: real wp_remote_post calls with
|
||||||
|
* the shape confirmed against Helcim's current developer docs).
|
||||||
|
*/
|
||||||
|
class HelcimClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_fake_mode_never_calls_the_real_api(): void
|
||||||
|
{
|
||||||
|
Functions\when('getenv')->justReturn(false);
|
||||||
|
Functions\when('wp_generate_uuid4')->justReturn('fake-uuid');
|
||||||
|
Functions\expect('wp_remote_post')->never();
|
||||||
|
|
||||||
|
$init = HelcimClient::initialize_checkout(42.00, 'USD', 'preauth');
|
||||||
|
$this->assertTrue($init['fake']);
|
||||||
|
$this->assertNotEmpty($init['checkoutToken']);
|
||||||
|
$this->assertNotEmpty($init['secretToken']);
|
||||||
|
|
||||||
|
$capture = HelcimClient::capture(123456, 42.00, '127.0.0.1');
|
||||||
|
$this->assertSame('APPROVED', $capture['status']);
|
||||||
|
$this->assertIsInt($capture['transactionId']);
|
||||||
|
$this->assertNotEmpty($capture['cardToken']);
|
||||||
|
|
||||||
|
$reverse = HelcimClient::reverse(123456, '127.0.0.1');
|
||||||
|
$this->assertSame('APPROVED', $reverse['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_real_mode_initialize_sends_the_documented_request_shape(): void
|
||||||
|
{
|
||||||
|
Functions\when('getenv')->justReturn('/fake/path/to/token');
|
||||||
|
Functions\when('is_readable')->justReturn(true);
|
||||||
|
Functions\when('file_get_contents')->justReturn('real-token-abc');
|
||||||
|
Functions\when('wp_json_encode')->alias('json_encode');
|
||||||
|
Functions\when('is_wp_error')->justReturn(false);
|
||||||
|
Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
|
||||||
|
Functions\when('wp_remote_retrieve_body')->justReturn(json_encode([
|
||||||
|
'checkoutToken' => 'real-checkout-token',
|
||||||
|
'secretToken' => 'real-secret-token',
|
||||||
|
]));
|
||||||
|
|
||||||
|
Functions\expect('wp_remote_post')
|
||||||
|
->once()
|
||||||
|
->withArgs(function ($url, $args) {
|
||||||
|
$this->assertSame('https://api.helcim.com/v2/helcim-pay/initialize', $url);
|
||||||
|
$this->assertSame('real-token-abc', $args['headers']['api-token']);
|
||||||
|
$this->assertArrayNotHasKey('idempotency-key', $args['headers']);
|
||||||
|
$body = json_decode($args['body'], true);
|
||||||
|
$this->assertSame('preauth', $body['paymentType']);
|
||||||
|
$this->assertEquals(42.0, $body['amount']);
|
||||||
|
$this->assertSame('USD', $body['currency']);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
->andReturn(['response' => ['code' => 200]]);
|
||||||
|
|
||||||
|
$result = HelcimClient::initialize_checkout(42.00, 'USD', 'preauth');
|
||||||
|
$this->assertSame('real-checkout-token', $result['checkoutToken']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_real_mode_capture_includes_an_idempotency_key(): void
|
||||||
|
{
|
||||||
|
Functions\when('getenv')->justReturn('/fake/path/to/token');
|
||||||
|
Functions\when('is_readable')->justReturn(true);
|
||||||
|
Functions\when('file_get_contents')->justReturn('real-token-abc');
|
||||||
|
Functions\when('wp_json_encode')->alias('json_encode');
|
||||||
|
Functions\when('wp_generate_uuid4')->justReturn('idem-key-123');
|
||||||
|
Functions\when('is_wp_error')->justReturn(false);
|
||||||
|
Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
|
||||||
|
Functions\when('wp_remote_retrieve_body')->justReturn(json_encode(['transactionId' => 999, 'status' => 'APPROVED']));
|
||||||
|
|
||||||
|
Functions\expect('wp_remote_post')
|
||||||
|
->once()
|
||||||
|
->withArgs(function ($url, $args) {
|
||||||
|
$this->assertSame('https://api.helcim.com/v2/payment/capture', $url);
|
||||||
|
$this->assertSame('idem-key-123', $args['headers']['idempotency-key']);
|
||||||
|
$body = json_decode($args['body'], true);
|
||||||
|
$this->assertSame(555, $body['preAuthTransactionId']);
|
||||||
|
$this->assertEquals(42.0, $body['amount']);
|
||||||
|
$this->assertSame('127.0.0.1', $body['ipAddress']);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
->andReturn(['response' => ['code' => 200]]);
|
||||||
|
|
||||||
|
$result = HelcimClient::capture(555, 42.00, '127.0.0.1');
|
||||||
|
$this->assertSame(999, $result['transactionId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_real_mode_throws_on_an_error_response(): void
|
||||||
|
{
|
||||||
|
Functions\when('getenv')->justReturn('/fake/path/to/token');
|
||||||
|
Functions\when('is_readable')->justReturn(true);
|
||||||
|
Functions\when('file_get_contents')->justReturn('real-token-abc');
|
||||||
|
Functions\when('wp_json_encode')->alias('json_encode');
|
||||||
|
Functions\when('wp_generate_uuid4')->justReturn('idem-key-123');
|
||||||
|
Functions\when('is_wp_error')->justReturn(false);
|
||||||
|
Functions\when('wp_remote_retrieve_response_code')->justReturn(422);
|
||||||
|
Functions\when('wp_remote_retrieve_body')->justReturn(json_encode(['errors' => ['card declined']]));
|
||||||
|
Functions\when('wp_remote_post')->justReturn(['response' => ['code' => 422]]);
|
||||||
|
|
||||||
|
$this->expectException(HelcimApiException::class);
|
||||||
|
$this->expectExceptionMessageMatches('/card declined/');
|
||||||
|
|
||||||
|
HelcimClient::capture(555, 42.00, '127.0.0.1');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Tests\Unit\Payments;
|
||||||
|
|
||||||
|
use Bookstore\Core\Order\OrderStatuses;
|
||||||
|
use Bookstore\Core\Payments\HelcimGateway;
|
||||||
|
use Bookstore\Core\Tests\TestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* process_payment()'s branching: pick up the preauth CheckoutController
|
||||||
|
* already stashed (Payments\CheckoutSession, backed by get/set/delete_
|
||||||
|
* transient — mocked at that WP-function layer, not by mocking
|
||||||
|
* CheckoutSession itself, matching this suite's established pattern),
|
||||||
|
* run the (moved-here) fraud check, and either authorize the order or
|
||||||
|
* reverse the hold and route to review. HelcimClient runs in fake mode
|
||||||
|
* here (no token env) so no real network call is involved either way.
|
||||||
|
*/
|
||||||
|
class HelcimGatewayTest extends TestCase
|
||||||
|
{
|
||||||
|
private function stub_pending_preauth(?array $data): void
|
||||||
|
{
|
||||||
|
Functions\when('get_transient')->justReturn($data);
|
||||||
|
Functions\when('delete_transient')->justReturn(true);
|
||||||
|
Functions\when('getenv')->justReturn(false); // HelcimClient stays in fake mode
|
||||||
|
Functions\when('wp_generate_uuid4')->justReturn('fake-uuid');
|
||||||
|
Functions\when('WC')->justReturn((object) ['session' => null]); // CheckoutSession::identity() falls back to session_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mock_order(): \WC_Order
|
||||||
|
{
|
||||||
|
$order = Mockery::mock(\WC_Order::class);
|
||||||
|
$order->shouldReceive('update_meta_data')->withAnyArgs();
|
||||||
|
$order->shouldReceive('save');
|
||||||
|
$order->shouldReceive('add_order_note')->withAnyArgs();
|
||||||
|
$order->shouldReceive('get_meta')->withAnyArgs()->andReturn('');
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_pending_preauth_fails_without_touching_the_order(): void
|
||||||
|
{
|
||||||
|
$this->stub_pending_preauth(null);
|
||||||
|
Functions\when('wc_get_order')->justReturn(Mockery::mock(\WC_Order::class));
|
||||||
|
Functions\expect('wc_add_notice')->once();
|
||||||
|
|
||||||
|
$gateway = new HelcimGateway();
|
||||||
|
$result = $gateway->process_payment(123);
|
||||||
|
|
||||||
|
$this->assertSame('failure', $result['result']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fraud_pass_authorizes_the_order(): void
|
||||||
|
{
|
||||||
|
$this->stub_pending_preauth(['preauth_transaction_id' => 555, 'card_token' => 'tok_1', 'amount' => 42.00]);
|
||||||
|
Functions\when('apply_filters')->justReturn(['pass' => true, 'reason' => 'no-op: fraud plugin not installed']);
|
||||||
|
|
||||||
|
$order = $this->mock_order();
|
||||||
|
$order->shouldReceive('update_status')->once()->with(OrderStatuses::AUTHORIZED, Mockery::type('string'));
|
||||||
|
Functions\when('wc_get_order')->justReturn($order);
|
||||||
|
|
||||||
|
$gateway = new HelcimGateway();
|
||||||
|
$result = $gateway->process_payment(123);
|
||||||
|
|
||||||
|
$this->assertSame('success', $result['result']);
|
||||||
|
$this->assertArrayHasKey('redirect', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fraud_fail_reverses_the_hold_and_routes_to_exception(): void
|
||||||
|
{
|
||||||
|
$this->stub_pending_preauth(['preauth_transaction_id' => 555, 'card_token' => 'tok_1', 'amount' => 42.00]);
|
||||||
|
Functions\when('apply_filters')->justReturn(['pass' => false, 'reason' => 'high risk score']);
|
||||||
|
Functions\expect('wc_add_notice')->once();
|
||||||
|
|
||||||
|
$order = $this->mock_order();
|
||||||
|
$order->shouldReceive('update_status')->once()->with(OrderStatuses::EXCEPTION, Mockery::type('string'));
|
||||||
|
Functions\when('wc_get_order')->justReturn($order);
|
||||||
|
|
||||||
|
$gateway = new HelcimGateway();
|
||||||
|
$result = $gateway->process_payment(123);
|
||||||
|
|
||||||
|
$this->assertSame('failure', $result['result']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,63 @@ if (!class_exists(\WC_Cart::class)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payments\HelcimGateway extends this. Minimal stand-in for the real
|
||||||
|
// WC_Payment_Gateway's settings-API plumbing (init_settings()/
|
||||||
|
// get_option()) so the constructor doesn't fatal in a unit test — real
|
||||||
|
// WooCommerce's version does much more (admin fields rendering, etc.),
|
||||||
|
// none of which the gateway's process_payment() branching depends on.
|
||||||
|
if (!class_exists(\WC_Payment_Gateway::class)) {
|
||||||
|
class WC_Payment_Gateway
|
||||||
|
{
|
||||||
|
public $id;
|
||||||
|
public $method_title;
|
||||||
|
public $method_description;
|
||||||
|
public $has_fields;
|
||||||
|
public $supports = [];
|
||||||
|
public $title;
|
||||||
|
public $description;
|
||||||
|
public $enabled;
|
||||||
|
public $form_fields = [];
|
||||||
|
public $settings = [];
|
||||||
|
|
||||||
|
public function init_settings(): void
|
||||||
|
{
|
||||||
|
$this->settings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_option($key, $default = '')
|
||||||
|
{
|
||||||
|
return $this->settings[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_return_url($order)
|
||||||
|
{
|
||||||
|
return 'https://example.test/order-received/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process_admin_options(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists(\WP_Error::class)) {
|
||||||
|
class WP_Error
|
||||||
|
{
|
||||||
|
private string $message;
|
||||||
|
|
||||||
|
public function __construct($code = '', $message = '')
|
||||||
|
{
|
||||||
|
$this->message = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_error_message(): string
|
||||||
|
{
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Membership\Tier calls $wpdb->get_row(..., ARRAY_A) — these are real
|
// Membership\Tier calls $wpdb->get_row(..., ARRAY_A) — these are real
|
||||||
// WordPress core constants (wp-includes/wp-db.php) that a full WP
|
// WordPress core constants (wp-includes/wp-db.php) that a full WP
|
||||||
// bootstrap would define; this one deliberately doesn't load WordPress,
|
// bootstrap would define; this one deliberately doesn't load WordPress,
|
||||||
|
|||||||
Reference in new issue
Block a user