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

The safety layer around Vendure checkout and payments: Stripe manual-capture holds that actually place the order, bank transfer with auto-expiry, failed-payment and orphaned-charge visibility with nightly Stripe reconciliation, session-bound order lookup, checkout rate limits and a funnel — in one admin dashboard.

1,308 npm downloads last month2,177 downloads in 2026Submitted to the Vendure plugin directoryVendure >=3.5 <4, verified on 3.7.3MySQL · MariaDB · PostgreSQLAGPL-3.0 source + paid licence key

Install on Vendure: add the package with npm, yarn or pnpm, then register CheckoutGuardPlugin in vendure-config.ts. All steps

npm

npm install @huloglobal/vendure-plugin-checkout-guard

yarn

yarn add @huloglobal/vendure-plugin-checkout-guard

pnpm

pnpm add @huloglobal/vendure-plugin-checkout-guard

Register it in vendure-config.ts

// vendure-config.ts
import { CheckoutGuardPlugin } from '@huloglobal/vendure-plugin-checkout-guard';

export const config: VendureConfig = {
  plugins: [
    CheckoutGuardPlugin.init({
      publicBaseUrl: 'https://shop.example.com',
      licenceKey: process.env.HULO_LICENCE_KEY_CHECKOUT_GUARD,
    }),
    // ... your other plugins
  ],
};

What it does

Built for production from day one.

Vendure's Stripe integration settles a payment when the intent succeeds — and nothing else. Put a manual-capture hold on a large order and the customer sees a confirmation page while the order never places and the authorisation silently expires. Enable bank transfer and there is no expiry, no reminder, no reconciliation. A card declines and no one ever knows. Checkout Guard closes those gaps server-side: an Authorized payment for every hold with capture, cancel and a safety capture before Stripe's seven-day window closes; a bank-transfer method that carries the account details to the storefront, expires unpaid orders and tells you when money is due; a payment-event log that records webhook failures, storefront declines, orphaned charges and amount drift, with a nightly Stripe reconciliation; an order-lookup strategy bound to the session that placed the order; a trusted client-IP contract for your server-side proxy; rate limits on the checkout mutations that get brute-forced; and funnel events so you can finally see where a checkout is lost.

Manual-capture holds that place the order

A stripe-hold payment handler plus a signed webhook for payment_intent.amount_capturable_updated: the order becomes PaymentAuthorized the moment the hold is placed. Capture or cancel from the dashboard or the Vendure order page; a safety capture runs before the authorisation expires; alerts fan out to your ops channels.

Bank transfer done properly

Account details travel in the payment's public metadata so the storefront and confirmation page render them without hard-coding; an eligibility checker (min/max, guests, customer groups); unpaid orders expire automatically after N days with a reminder event before that; a "received" button settles the order and releases fulfilment.

Failed payments you can see

Webhook declines, storefront-reported declines, orphaned charges and settled amounts that differ from the order total are written to a payment-event log with the IP — so fraud tooling can count them and you can spot a broken checkout before customers email you.

Nightly Stripe reconciliation

Every succeeded or held PaymentIntent from the last days is matched to a Vendure payment; anything unmatched is flagged and alerted, per channel, with the Stripe key each channel already has.

Session-bound order lookup

Vendure lets anyone with an order code read a fresh order for two hours. Checkout Guard binds anonymous access to the session that placed it — the confirmation page still works, strangers with a code do not.

Trusted client IP

A shared-secret header contract for server-side proxies (Qwik server$, Next.js route handlers) so the order IP that feeds fraud scoring cannot be spoofed by a browser.

Checkout rate limits

Token-bucket limits on applyCouponCode, addPaymentToOrder, createStripePaymentIntent and transitionOrderToState, keyed by client IP, returning a clean 429 — stops coupon brute force and intent spam without touching nginx.

Checkout funnel

Step, attempt, failure and coupon-rejection events from the storefront, summarised per step with drop-off percentages, so single-URL checkouts stop being invisible to analytics.

Ops alerts

Slack, Discord, Microsoft Teams, Telegram, a signed webhook and email — every hold, expiry, orphan and drift can ping the channel your team actually reads.

Admin dashboard

Overview KPIs, Holds, Bank transfers, Payment events, Funnel and Settings tabs, plus the Licence & billing card. Light and dark themes.

MySQL, MariaDB & PostgreSQL

The plugin follows whatever database your Vendure dbConnectionOptions use — no configuration. Verified against PostgreSQL 17; MySQL/MariaDB installs are unchanged.

Buy & activate from the admin

Start the 14-day free trial, subscribe or buy lifetime from the plugin's admin page — checkout opens in a new tab and the key installs itself within a minute, renewals included. The same card shows your plan, first-charge date and a Manage billing link to the Stripe portal (update card, cancel, switch plan). Already have a key? Paste it into the same banner. No .env edit, no redeploy; environment keys still take precedence for infrastructure-as-code setups.

Weekly report, set-up checklist & dashboard widget

Every Monday the plugin emails whoever runs the store a short report of what it did that week — real numbers from your own database, week-over-week, with the next set-up step to take. Change the recipient, send a test or switch it off from the plugin's Settings tab (needs the host's SMTP_* settings). The same numbers sit in a Vendure dashboard widget (Dashboard → Add widget) and the plugin page opens with a set-up checklist that hides itself once everything is done.

One-click in-app updates

When a new version ships, an update banner shows current → latest with a What's-new link to the changelog. "Update now" installs the registry-verified release via your project's own package manager (yarn/npm/pnpm auto-detected) and gracefully restarts under pm2/systemd. Disable with HULO_SELF_UPDATE=off.

Install

Three steps, five minutes.

Add the package

Or run the one-line installer that does steps 1–3 for you:

curl -sSL https://huloglobal.com/vendure-plugins/checkout-guard/install.sh | bash

Prefer to do it by hand?

npm

npm install @huloglobal/vendure-plugin-checkout-guard

yarn

yarn add @huloglobal/vendure-plugin-checkout-guard

pnpm

pnpm add @huloglobal/vendure-plugin-checkout-guard

Register it

In your vendure-config.ts:

// vendure-config.ts import { CheckoutGuardPlugin } from '@huloglobal/vendure-plugin-checkout-guard'; export const config: VendureConfig = { plugins: [ CheckoutGuardPlugin.init({ publicBaseUrl: 'https://shop.example.com', licenceKey: process.env.HULO_LICENCE_KEY_CHECKOUT_GUARD, }), // ... your other plugins ], };

Compile the admin UI

Add the extension to your compileUiExtensions call so the plugin's page appears in the admin:

// compile-admin-ui.ts import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import { CheckoutGuardPlugin } from '@huloglobal/vendure-plugin-checkout-guard'; compileUiExtensions({ outputPath: path.join(__dirname, 'admin-ui'), extensions: [CheckoutGuardPlugin.uiExtensions], });
No migration to run. The plugin creates its tables on first boot and upgrades them in place on later versions.

That's it. Restart Vendure and the plugin's page is in the admin with every feature on for 14 days — no card, no key. To keep it all on afterwards, open the page and click Subscribe (first charge when the 14 days end) — the key installs itself, no .env edit, no redeploy.

Free tier vs licensed

Try the whole thing, keep the core for free.

Install without a key and everything in the free tier works indefinitely. The 14-day trial switches the licensed features on with your real traffic; a licence keeps them on.

Free tier (after the 14 days)

  • Session-bound order lookup and trusted client IP
  • Rate limits and funnel events
  • Bank-transfer handler and eligibility checker
  • The dashboard

First 14 days + licensed

  • Stripe manual-capture hold handling
  • Bank-transfer auto-expiry and reminders
  • Failed-payment recording and nightly reconciliation
  • Amount-drift guard and ops alerts
Start 14-day free trial →

HTTP endpoints

Every route exposed.

POST/checkout-guard/stripe-webhookStripe: signed webhook for holds + failures
GET/checkout-guard/holdsAdmin: Authorized Stripe holds
POST/checkout-guard/holds/:id/captureAdmin: capture a hold (settles the payment)
POST/checkout-guard/holds/:id/cancelAdmin: release a hold
GET/checkout-guard/bank-transfersAdmin: bank transfers awaiting / expired / settled
POST/checkout-guard/bank-transfers/:id/receivedAdmin: mark a transfer received
POST/checkout-guard/client-declinePublic: storefront-reported card decline (rate limited)
POST/checkout-guard/funnelPublic: checkout funnel event (rate limited)
GET/checkout-guard/eventsAdmin: payment-event log
GET/checkout-guard/summaryAdmin: dashboard KPIs
GET/checkout-guard/funnel/summaryAdmin: per-step funnel with drop-off
GET/checkout-guard/metaAdmin: licence + evaluation + update status
POST/checkout-guard/licence/activateAdmin: activate a licence key from the admin UI
POST/checkout-guard/update/runAdmin: one-click in-app update + graceful restart
GET/checkout-guard/digest/settingsAdmin: weekly report recipient, last/next send
POST/checkout-guard/digest/send-testAdmin: email this week's report to you now
GET/checkout-guard/setupAdmin: set-up checklist (what is still to do)
GET/checkout-guard/widgetAdmin: headline figures for the dashboard widget

FAQ

Common questions.

How do I get a licence key?

Buy one on the web with Stripe Checkout: monthly licence for Checkout Guard, annual licence for Checkout Guard (two months free) or lifetime licence for Checkout Guard. You'll receive the JWT key by email. Paste it into the plugin's admin settings (Activate) — no redeploy — or set it as HULO_LICENCE_KEY_CHECKOUT_GUARD in your .env if you prefer config-as-code; the env key wins when both are present.

Does it work without a key?

Yes. Every install runs with all premium features on for its first 14 days — no card, no licence key, nothing to configure — so you can judge it on your real traffic. After that it keeps working in the free tier. To keep them, click Subscribe on the plugin's admin page: the first charge is taken when the 14 days end (or 49 hours after checkout, if that is later), you can cancel any time before then, and the licence installs itself.

Which databases are supported?

MySQL, MariaDB and PostgreSQL (verified against PostgreSQL 17). The plugin follows your Vendure dbConnectionOptions automatically — there is nothing to configure.

How do updates work?

The plugin checks the npm registry daily. When a newer version exists, the admin dashboard shows an update banner with a What's-new link to the changelog and an "Update now" button that installs the registry-verified release via your own package manager and gracefully restarts under your process supervisor. Prefer manual control? Copy the install command instead, or set HULO_SELF_UPDATE=off.

Where is data stored?

In your Vendure database: the plugin keeps its data in its own tables there, created on boot. Hulo Global never receives your customer data: the plugin sends our licence server only the licence check-ins and usage counts described under “Do the plugins call home?”. Holds and the nightly reconciliation call Stripe with your own key, and alerts go only to the channels you set up.

Will it survive a Vendure upgrade?

It supports Vendure >=3.5.0 <4.0.0 (its peer dependency), and current releases are built and tested against Vendure 3.7.3 before they are published. A boot-time compatibility check emits a non-fatal warning if @vendure/core is outside that range, so upgrades to a future 3.x are safe to try. The 4.0 line will be tested and re-declared once its changelog lands.

Changelog

What's new.

Latest release: v0.3.0, 6 November 2026. Full changelog for Checkout Guard for Vendure

Show changes

v0.3.0NewLatest

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

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

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

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

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.5NewSecurity
  • 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.
  • Security 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.4New

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

Fixed

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

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

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

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.

Ready to ship?

Install in five minutes, run the trial on real traffic, keep it if it earns its place.