Hulo Global home
For Vendure Vendure edition: for Vendure 3.5+ shops, installed with npm. Also available for WordPress: Checkout Guard for WordPress
For Vendure

Checkout Guard for Vendure: changelog

Release notes for @huloglobal/vendure-plugin-checkout-guard. Latest release: v0.3.0 — 6 November 2026.

Show changes

v0.3.0

NewLatest
Added
  • Stripe bank transfers (opt-in: bankTransfer: { stripe: { enabled: true } }). Bank-transfer orders in GBP get a Stripe virtual account (sort code 12-34-56 and account number) and Stripe's reference instead of your account details, and settle by themselves: Stripe matches the incoming Faster Payment or BACS credit, payment_intent.succeeded reaches /checkout-guard/stripe-webhook, and the order's Authorized bank-transfer payment is settled under a row lock in the order's own channel, so the order moves to PaymentSettled as if an admin had clicked Mark received. The details travel in metadata.public under the existing field names (accountName, sortCode, accountNumber, reference, payBy, instructions), plus provider: 'stripe' and hostedInstructionsUrl, so storefronts and e-mails need no change to show them; the PaymentIntent id stays in the admin-only metadata. The Stripe secret key is the channel's Stripe payment method apiKey, as for holds, preferring the method that belongs to that channel alone (the default channel also sees other channels' methods); the choice is logged once per channel.
  • Whose Stripe customer. Stripe applies a customer's cash balance to a new PaymentIntent at once, so an existing Stripe customer is reused only when the checkout is signed in as the order's (registered) customer, and only the one this plugin created for that customer and user (cg_vendure_customer_id and cg_vendure_user_id metadata). Every other checkout, guests included, gets a Stripe customer of its own for that order, so typing someone's e-mail address can never spend their balance; a balance applied to such an order anyway is refused (static details, alert).
  • Sweep fallback for missed webhooks: every ten minutes (worker, under the bank-transfer sweep lock, never longer than three minutes a run) the plugin asks Stripe about each open Stripe transfer of an order still awaiting payment, newest first and at most once per ten minutes per payment, and settles those that have succeeded. A part-paid transfer stays open, the log shows the amount_remaining, and metadata.public.amountMinor is lowered to what is still due. Before the expiry sweep expires a Stripe transfer it checks with Stripe: a paid one is settled instead, one Stripe cannot be asked about is left for the next pass (alerted once it is a day overdue), and one Stripe says is not on that account is expired as usual.
  • Cancelling an unpaid Stripe transfer (expiry, Checkout Guard → Bank transfers, Vendure's Cancel payment, and Vendure's Cancel order, which leaves payments alone, so the plugin cancels the open transfer of a cancelled order itself) cancels its PaymentIntent at Stripe, best effort. A part payment the cancel returns to the customer's cash balance is alerted on every path.
  • Settling by hand (Mark received, Vendure's Settle payment) cancels the PaymentIntent too, unless Stripe has the money, in which case it counts as Stripe's settlement; a later Stripe receipt for a payment settled by hand is alerted as a possible double payment.
  • customer_cash_balance_transaction.created events: money left in the cash balance of a Stripe customer this plugin created (a transfer after the order was cancelled or expired, an overpayment) is alerted, once per amount, for a refund.
  • currencies (default ['GBP']), euCountry for EUR (eu_bank_transfer, IBAN and BIC; BE, DE, FR, IE or NL) and instructions options; provider and paymentIntentId on the Bank transfers list rows; ops events bank.stripe_fallback, bank.stripe_cancel_failed, bank.stripe_unsettled, bank.stripe_partially_funded and bank.stripe_check_failed, delivered on every tier.
Notes
  • Checkout never fails because of Stripe: with no key, an API error, no answer within 15 seconds, a refused currency or a reply without instructions, the order gets the payment method's own details as before, a warning is logged and bank.stripe_fallback is raised. When the PaymentIntent request itself got no answer, the plugin looks for that order's intent 30 seconds and 5 minutes later and cancels it if no payment carries it.
  • A second addPaymentToOrder for the same checkout (a double click) is refused while the first one is creating the intent: the handler locks the order row first. Two payments carrying one intent are never both settled.
  • The Stripe calls (normally two, at most 15 seconds) run inside Vendure's addPaymentToOrder transaction, as any payment handler's do.
  • Hosts that enable it must set StripePlugin.init({ skipPaymentIntentsWithoutExpectedMetadata: true }): these PaymentIntents carry only cg_* metadata, and Vendure's own Stripe webhook otherwise throws on them and Stripe retries it for days. Keep bank transfers out of the card flow (excluded_payment_method_types: ['customer_balance'] in paymentIntentCreateParams), and add payment_intent.succeeded, payment_intent.partially_funded and customer_cash_balance_transaction.created to every endpoint that targets /checkout-guard/stripe-webhook. In this mode an event its channel's secret cannot verify is checked against the other configured secrets (one URL, several Stripe accounts), and while premium is locked the hold module acknowledges events about payments that are not holds.
  • Off by default. Without bankTransfer.stripe.enabled the handler, the sweeps and the webhook behave exactly as in 0.2.9, the new ten-minute job does nothing, and every webhook event without cg_bank_transfer metadata is handled as before.

v0.2.9

New
Fixed
  • Ops e-mails failed when the mail server was slow to greet. The SMTP transport for ops e-mails allowed 5 seconds to connect and 5 seconds for the server's greeting. A mail server may hold its greeting while it checks the sending host's reverse DNS (Gmail has taken just over 5 seconds), so alert e-mails and the weekly report failed with "Greeting never received". Connecting and the greeting now get 30 seconds each, and a whole send may take 60 seconds instead of 12. The socket idle timeout is 45 seconds instead of 10. It also runs while the server has not greeted yet, so it is longer than the greeting timeout, and a server that never greets is still reported as "Greeting never received" rather than a bare "Timeout". Chat and webhook alerts were not affected.

v0.2.8

New
Package metadata
  • The license field is now AGPL-3.0-only, matching LICENSE, which grants the GNU Affero General Public License version 3 (it said AGPL-3.0-or-later). Published npm builds remain under the separate commercial licence described in LICENSE. No code changes.

v0.2.7

New
Fixed
  • PostgreSQL. The plugin now requires licence SDK ^0.15.4. With 0.15.3 or older on Postgres, the first weekly report was skipped (it started a week late), and the retention job logged 2 rows removed per table whatever it removed. MySQL and MariaDB were not affected.
  • PostgreSQL: a licence key activated in the admin is now stored. The plugin's licence store left out the conflict column that the Postgres upsert needs, so saving a key failed. Pasting a key into the admin applied it, answered with a server error and did not store it, so the plugin was unlicensed again after a restart. A key bought from the admin, which installs itself, was not stored either. MySQL and MariaDB were not affected.
Package metadata
  • homepage is now the product page, https://huloglobal.com/vendure-plugins/checkout-guard/, instead of the GitHub repository.
  • A shorter description that npm shows in full (npm cuts descriptions at 255 characters); keywords add rate-limiting and card-testing.

v0.2.6

New
Documentation
  • The README now shows the plugin logo, hosted on huloglobal.com so it displays on npm and GitHub alike, linking to the product page. No code changes.

v0.2.5

New
Security
  • Rate limits and the card-testing gate could be sidestepped through the GraphQL document. A request was charged one token per limited mutation name, however many times the document called it, so a: applyCouponCode(…) b: applyCouponCode(…) … tried dozens of coupon codes (or payment calls) for the price of one, and a batched body did the same. Every aliased call and every batched operation now spends its own token; a single request with more calls than the bucket holds is refused outright.
  • Automatic Persisted Queries bypassed the limits entirely. Apollo serves a registered document from its hash alone ({ extensions: { persistedQuery } }, no query), and the middleware had nothing to inspect. The middleware now learns each hash from the request that registers it (the hash must match the document) and charges replays like the document itself; a hash it has never seen is answered with Apollo's own PersistedQueryNotFound, so APQ clients simply resend the document. No configuration change.
Changed
  • An operation named after the field it calls (mutation applyCouponCode { applyCouponCode(…) }) is counted once, not twice.

v0.2.4

New
Changed
  • Every install now gets a 14-day evaluation with all features on and no card (granted by the HULO licence server). The licence banner's buttons read Subscribe: during the evaluation the first charge falls on the day it ends, so there is no second free trial.

v0.2.3

New
Fixed
  • Test expectations for the 0.2.2 channel change (0.2.2 is deprecated on npm; same runtime code).

v0.2.2

New
Fixed
  • An order's channel was resolved as the lowest channel id it belongs to. Every order also belongs to the default channel, so on multi-channel stores client-side declines and bank-transfer rows were attributed to the default channel. The highest channel id (the storefront's own) is used now.

v0.2.1

New
Added
  • Card-testing gate (free tier, on by default). Carders run stolen cards through the cheapest product until one works; Radar declines them but every attempt still mints a PaymentIntent and shares the rate-limit buckets with real shoppers. The gate reads the plugin's own failed/client-declined records: once an IP has maxFailedPerIp (4) failures in windowMinutes (60) the payment mutations (createStripePaymentIntent, addPaymentToOrder) answer 429 card_testing_blocked for blockMinutes (720), and CheckoutGuardService.cardTestingVerdict({ orderCode, ip }) lets the host's paymentIntentCreateParams hook refuse a new intent once an order has maxFailedPerOrder (3) failures. Ops event payment.card_testing (once per IP per block), counter card_testing_block, stat cardTestingBlocked. Configure or disable with the cardTesting option.
Fixed
  • The mutation rate limiter only recognised name( — argument-less mutations such as Vendure's createStripePaymentIntent were never limited. Names now match as whole words, with string literals blanked first so a name inside an argument value still does not count.
  • countRecentFailuresForOrder() added next to countRecentFailures().

v0.2.0

New

Merchant engagement release. Two small tables (cg_counter, and the shared hulo_plugin_prefs) are created on boot; nothing to migrate. Requires @huloglobal/vendure-licence-sdk 0.15.

Added
  • Weekly report. Monday 08:00 (server-local) e-mail from the worker with the week's holds placed / captured / released, held value, bank transfers confirmed, rate-limited requests, denied order lookups and orphaned charges — each with the previous week for comparison — plus the next open set-up step and, on trial/free, what a licence adds. Sent through the ops SMTP settings (SMTP_*), falling back to the SDK env transport. Recipient defaults to ops.adminEmail, then the licence customer, then the trial reminder address. GET|POST /checkout-guard/digest/settings, POST /digest/send-test, GET /digest/preview, and a Weekly report card in the Settings tab (toggle, recipient, last/next, Send me a test, Preview).
  • Set-up checklist on the Overview tab (GET /checkout-guard/setup): Stripe webhook secret, stripe-hold method, bank-transfer method, trusted client IP (secret header or trustedProxies), ops channel — real checks, with links to the fix. Hideable once complete; reappears if a check regresses. Replaces the static checklist in Settings.
  • Dashboard widget (registerDashboardWidget, Dashboard → Add widget → Checkout Guard): three 7-day headline figures, the licence notice and a link to the page. GET /checkout-guard/widget.
  • Usage counters. cg_counter (day, name, n) records Shop API rate-limit rejections and session-bound orderByCode refusals (fire-and-forget, buffered, one upsert per counter every two seconds). New Overview tiles Held value (30d), Rate-limit rejections (7d) and Denied order lookups (7d); summary gains a usage block.
  • Usage stats to the licence server. holdsPlaced, holdsCaptured, holdsCancelled, heldValuePence, bankTransfersConfirmed, rateLimitRejections, orderAccessDenied, orphansFound (last 30 days, numbers only) ride on the daily heartbeat and evaluation pings — licensed installs keep reporting after purchase.
  • Licence banner. meta now carries notice (trial / key / evaluation lifecycle sentence, shown as an amber strip), premiumFeatures, recentStats ("So far: £… held on customers' cards · …") and buyUrl. The trial banner gains "Email me before it ends" (wired to the existing eval/remind-me; the address is remembered as the report's fallback recipient). Every 402 and licence_required / premium-feature error in the admin now reads "… needs a licence — start the free trial or activate a key at the top of this page."
Changed
  • 402 bodies from the hold capture/cancel endpoints include buyUrl.
  • Retention cron also prunes cg_counter after 400 days.
  • ReconciliationService and ObservabilityService accept the new CounterService; OpsAlertService exposes sendMail(), hasSmtp() and adminEmail().
  • Heartbeat reports the key in force. The daily ping now reads the active licence through licenceKeyProvider, so a key activated from the admin, installed by the purchase claim or restored at boot is reported under its own jti instead of "unlicensed" (the licence server dropped the usage stats for those installs before).
  • Licence SDK dependency raised to ^0.15.1.

v0.1.4

Follow-up to the 0.1.3 audit. No schema changes; nothing to migrate.

Changed
  • Client-IP trust model. trustedClientIp.trustedProxies (IPv4/IPv6 addresses or CIDRs) names the proxies in front of the API. x-forwarded-for is now only consulted when the TCP peer is one of them, and the client address is the right-most entry that is not itself a trusted proxy — a browser can no longer inject an address into the rate limiter, funnel or decline log. Without trustedProxies the header is ignored and req.ip (your apiOptions.trustProxy setting) or the socket is used. trustCloudflareHeader now defaults to false unless trustedProxies is set or it is enabled explicitly; with trustedProxies it is only read from a trusted peer. The secret-header path (x-real-client-ip + x-checkout-guard-proxy) is unchanged and still wins. Invalid trustedProxies entries fail at boot. The effective values are shown in the Settings tab. Hosts that relied on the first x-forwarded-for entry without trustProxy should set trustedProxies.
  • Auto-capture off the row lock. A below-threshold hold is now captured after the recording transaction commits (same result, same alerts); the Stripe capture call no longer runs while SELECT … FOR UPDATE is held on the order row. If the capture fails the hold stays Authorized and a hold.capture_failed alert is raised instead of rolling the hold back.
  • Live intent check before recording a hold. Before amount_capturable_updated adds a payment, the PaymentIntent is retrieved from Stripe (outside the transaction). requires_capture proceeds as before; succeeded is recorded and settled immediately (captured in the dashboard before the event was delivered); canceled is logged as a hold_expired payment event with a hold.expired alert and no payment is added; any other status is ignored. A redelivered event is de-duplicated before the Stripe call. When the channel has no Stripe key or Stripe is unreachable the signed event payload is used as before.
  • Admin UI. Effective settings are cached after the first load (only Refresh re-fetches them) and every HTTP/modal subscription is torn down when the page is left.
Added
  • Unit tests for StripeHoldService (webhook flow, dedupe, channel/order errors, transition failure, premium lock, capture/cancel state mapping, safety capture), BankTransferService (channel-scoped listing, mark received / cancel incl. stale-under-lock, sweep expire + remind, stranded repair) and ReconciliationService.runOnce (orphan rows, second-run dedupe, single alert, persisted last run) on lightweight fakes — no database.
  • Postgres corpus test (src/__tests__/pg-corpus.test.ts, runs when HULO_PG_URL is set): extracts every SQL template literal, translates it through the dialect adapter and executes it against PostgreSQL 17 with quoted-camelCase stand-ins for the Vendure tables. 37/37 statements pass.
  • parseCidr, isTrustedProxy, clientIpFromForwardedFor, ipToBigInt are exported for hosts that want the same matcher.

v0.1.3

Reliability and performance pass — no new features. One small table (checkout_guard_state) and two best-effort indexes on payment are created on boot; nothing to migrate.

Fixed
  • Bank transfer settle/cancel race. "Mark as received" (server) and the expiry sweep (worker) now settle or cancel under a SELECT … FOR UPDATE with the payment state re-read inside the transaction, so a paid order can no longer be cancelled by an overlapping sweep. The sweep also takes a cross-process lock, records expiredAt before it cancels, and the stranded-order repair only touches orders the sweep itself expired.
  • Stripe hold capture/cancel race. The hourly safety capture, a payment_intent.succeeded webhook and an admin click can overlap: capture and release now lock the payment row and re-check its state; a hold that is already Settled/Cancelled reports success to non-admin callers instead of raising a capture_failed alert.
  • Postgres. Every raw query that touches a Vendure table quotes its camelCase columns (transactionId, createdAt, orderId, channelId, currencyCode, emailAddress, customerId), which Postgres otherwise folds to lowercase. Bank-transfer listing and sweep, orphan and hold KPIs, findOrderByCode and reconciliation's known-intent set were silently empty on Postgres hosts before.
  • Ops alerts never hold a Stripe webhook. Alerts from the hold path are fire-and-forget; one pooled SMTP transport with 5 s connect / 10 s socket timeouts and a 12 s send cap replaces a fresh transport per e-mail; hold.webhook_error is delivered even when premium is locked and is also logged at error level; chat webhooks now treat non-2xx as a failure.
  • Rate limiter. GraphQL comments are stripped before the mutation matcher runs (applyCouponCode # x\n( was invisible to it); IPv6 clients are bucketed per /64.
  • Channel scoping. Admins working in a non-default channel only see and act on that channel's bank transfers, payment events, KPIs and funnel.
  • Amount drift is evaluated only once the order has left the payment phase, so multi-payment checkouts are not reported as underpaid.
  • Permissions. Self-update, licence activate and deactivate require SuperAdmin (they were reachable with UpdateOrder). paymentId route params are validated. Purchase-link failures no longer echo upstream error text; the evaluation reminder validates the e-mail and times out after 8 s. webhookSecretConfigured also honours the STRIPE_CG_WEBHOOK_SECRET* environment variables.
  • Tracking rows are inserted with INSERT IGNORE (no duplicate-key 500 after a concurrent settle).
Changed
  • Funnel summary is aggregated in SQL (GROUP BY step with COUNT(DISTINCT …)) instead of loading every beacon in the window into Node; summariseFunnelCounts exposes the same pure summary from counts.
  • Retention cron (worker, 03:20): funnel beacons 90 days, failed and client_declined events 180 days, all other payment events 400 days. Both tables grew without bound before.
  • Reconciliation takes a cross-process lock (admin "Run now" vs the 04:10 worker run), persists its last result in checkout_guard_state so the dashboard shows it whichever process ran it, reads known intents in one query and de-duplicates orphans in batches of 500 instead of one SELECT per intent.
  • Best-effort indexes idx_cg_payment_txn (transactionId) and idx_cg_payment_state_method (state, method) on Vendure's payment table (skipped silently where the host cannot create them).
  • Payment-method lookups are cached for 30 s (bank-transfer codes, Stripe keys) and handler-code LIKE patterns are escaped rather than stripped.
  • Admin UI: OnPush change detection, trackBy on every table, cached currency formatters, and the overview fetches 8 recent events instead of 200.

v0.1.2

Changed
  • Vendure plugin directory readiness. The plugin class, every service and the bank-transfer events carry @category JSDoc tags (Plugin / Services / Events); the runtime compatibility declaration now matches the tested range >=3.5.0 <4.0.0 instead of ^3.0.0.
  • README: npm, download, Vendure and database badges, a four-step Quick start at the top (including the admin-UI compile step) and a Compatibility section. No functional changes.

v0.1.1

Fixed
  • Admin capture / release of a hold on a non-default channel used the admin request's channel, where the channel's stripe-hold method does not exist (error.payment-method-not-found). Both now act in the order's own channel, like the cron and webhook paths.
  • Webhook responses: an unexpected error while recording a hold, or a locked premium tier, now answers 5xx so Stripe retries the delivery instead of the hold being lost; an ops alert (at most hourly) explains the locked case.
  • Duplicate webhook deliveries for one PaymentIntent are serialised (in-process mutex plus a row lock on the order), so two Authorized payments can no longer be attached to one intent.
  • Ops fan-out (Slack / webhooks / SMTP) no longer runs inside the webhook's database transaction.
  • Capture / cancel idempotency keys carry a minute bucket, so a failed capture is not replayed as the same error for 24 hours.
  • Safety capture releases (never charges) a hold whose order was cancelled.
  • Bank-transfer sweep finishes cancelling orders that a failed cancelOrder left in PaymentAuthorized with a cancelled payment (repaired in the sweep result).
  • Admin UI: the Bank transfers, Holds and Funnel tabs read the fields the server actually sends.
  • Overview KPIs count holds and transfers by the handler behind each channel's payment method, not by a guessed method code.
  • Shop API rate limiter: multipart (file-upload) GraphQL requests are limited too.
  • trustedClientIp.trustCloudflareHeader (default true) lets deployments reachable without Cloudflare ignore cf-connecting-ip.

v0.1.0

Added
  • Stripe holds. A stripe-hold payment handler and a signed webhook (POST /checkout-guard/stripe-webhook) for payment_intent.amount_capturable_updated, so a manual-capture PaymentIntent becomes an Authorized Vendure payment and the order is placed (PaymentAuthorized). Capture and cancel from the admin (/checkout-guard/holds), a safety capture before Stripe's seven-day authorisation window closes, payment_intent.payment_failed recorded as a payment event. Per-channel webhook secrets via STRIPE_CG_WEBHOOK_SECRET_<CHANNELCODE>.
  • Bank transfer. A bank-transfer payment handler whose account details travel in the payment's public metadata (storefronts render them, nothing hard-coded), an eligibility checker (min / max amount, guests, customer groups), auto-expiry of unpaid orders after N days with a reminder event before that, and admin received / cancel actions.
  • Guards. SessionBoundOrderByCodeAccessStrategy (anonymous orderByCode only from the session that placed the order, via the cgSessionHash Order custom field), a trusted client-IP middleware (x-real-client-ip honoured only with the x-checkout-guard-proxy secret header) with getClientIp(), and token-bucket rate limits on Shop API mutations (applyCouponCode, addPaymentToOrder, createStripePaymentIntent, transitionOrderToState).
  • Observability. A payment-event log (webhook failures, storefront-reported declines, orphaned charges, amount drift, expired holds and transfers), POST /checkout-guard/client-decline, nightly Stripe ↔ Vendure reconciliation per channel, an amount-drift guard on settlement, checkout funnel events (POST /checkout-guard/funnel) with a per-step summary, and ops alerts to Slack, Discord, Teams, Telegram, a signed webhook and email.
  • Admin dashboard at Sales → Checkout Guard: Overview, Holds, Bank transfers, Payment events, Funnel, Settings, plus the Licence & billing card (14-day card-backed trial, buy from the admin, Stripe billing portal).
  • Free tier: session-bound order lookup, trusted client IP, rate limits, funnel events, the bank-transfer handler and checker, dashboard. Premium: Stripe holds, bank-transfer expiry and reminders, failed-payment recording, reconciliation, drift guard, ops alerts.