CutLuy
Accept Bakong KHQR scan-to-pay payments via CutLuy
medusa-payment-cutluy
A Medusa Payment Module Provider for CutLuy — Bakong KHQR scan-to-pay payments.
CutLuy is an asynchronous payment method: the customer scans a KHQR code (or opens a hosted checkout page) and pays from their banking app. The plugin is a standalone Medusa payment provider (like Copy to clipboardmedusa-payment-stripe) and installable in any Medusa v2 application.
Features
- ✅ Create a CutLuy KHQR payment per payment session
- ✅ Exposes Copy to clipboard
qr_stringand Copy to clipboardcheckout_urlto the storefront through the payment session Copy to clipboarddata - ✅ Asynchronous authorization (Copy to clipboard
pending_authorization) → order created as "awaiting" - ✅ Webhook handling via Medusa's built-in listener with HMAC-SHA256 signature verification (over the raw body) and session/amount verification
- ✅ Scheduled sweep job that detects CutLuy payments expired/failed while their order is still Copy to clipboard
awaiting(dropped webhook events) and flags them for operators - ✅ Copy to clipboard
payment.completedwebhook marks the session captured and completes the cart/order - ✅ Poll-based status (Copy to clipboard
getPaymentStatus) and retrieval (Copy to clipboardretrievePayment) - ✅ USD-only enforcement (CutLuy only charges USD)
- ❌ Capture / cancel / delete / update / refund are not part of CutLuy's public v1 API — implemented as safe no-ops or explicit errors
How it works
12345678910111213sequenceDiagramparticipant S as Storefrontparticipant M as Medusa Backendparticipant C as CutLuyS->>M: initiate payment session (provider_id = cutluy)M->>C: POST /v1/payments (amount in USD, metadata.session_id)C-->>M: payment { id, qr_string, checkout_url }M-->>S: payment session data (qr_string / checkout_url)S->>S: render QR or redirect to checkout_urlS->>M: place order → authorizePayment → pending_authorizationC->>M: webhook payment.completed → /hooks/payment/cutluy_cutluyM->>M: verify X-CutLuy-Signature → mark captured → complete cart/order
Because Copy to clipboardauthorizePayment returns Copy to clipboardpending_authorization, the order is created with an awaiting payment status. When CutLuy fires Copy to clipboardpayment.completed, Medusa's Copy to clipboardprocessPaymentWorkflow re-invokes Copy to clipboardauthorizePayment — the provider re-checks the CutLuy payment status and returns Copy to clipboardcaptured once it's paid — which creates the Payment record, captures it, and flips the order to paid/captured. If the QR expires or the payment fails, Copy to clipboardpayment.expired / Copy to clipboardpayment.failed are mapped to a Copy to clipboardFAILED action — which Medusa 2.18's webhook processor ignores, so the order remains awaiting and no capture occurs. The storefront must surface CutLuy's own payment status (poll Copy to clipboardgetPaymentStatus), and operators see a warning log per event.
Async flow requirement: Copy to clipboardauthorizePaymentpolls Copy to clipboardGET /v1/payments/:idon every call and only returns Copy to clipboardpending_authorizationwhile the payment is still pending. This is what lets the webhook-driven autocapture flow create and capture the Payment.
Requirements
- Medusa v2.17.0 or later (uses the async payment methods support)
- Node.js 20+
- A CutLuy account with an API key and a configured webhook endpoint
Testing
A complete end-to-end test harness lives in the Copy to clipboardmedusa-test-app/ directory (sibling of this repo — not part of the package):
- Copy to clipboard
docker-compose.yml— Postgres + Redis for the Medusa app - Copy to clipboard
medusa-app/— a Copy to clipboardcreate-medusa-appbackend with the plugin registered - Copy to clipboard
cutluy-mock/server.mjs— a mock of the CutLuy API (Copy to clipboardhttp://localhost:8080/v1), so no real credentials are needed - Copy to clipboard
e2e-test.mjs— drives the full flow via the API: cart → payment session (Copy to clipboardpp_cutluy_cutluy) → verify Copy to clipboardqr_string/Copy to clipboardcheckout_url→ complete cart (order "awaiting") → simulate customer paying at the mock → deliver a signed Copy to clipboardpayment.completedwebhook → assert the order becomes Copy to clipboardcaptured
When you later point the provider at the real CutLuy API (Copy to clipboardCUTLUY_API_URL + real Copy to clipboardck_... key), the same script works unchanged.
Dependency & security posture: the published package ships zero runtime dependencies — it only declares the Copy to clipboard@medusajs/frameworkpeer (>=2.17.0) that any Medusa app already provides. Scanner findings (CVEs, telemetry, minified files) that appear for this package come from Medusa core's own dependency tree (e.g. Copy to clipboard@medusajs/telemetry, which is opt-out via Copy to clipboardMEDUSA_TELEMETRY_DISABLED=true), and are identical for every Medusa plugin — they resolve upstream when Medusa updates its dependencies.
1. Install
Install locally for development (yalc)
From this plugin's directory (pnpm is the package manager for this repo):
12pnpm installpnpm medusa plugin:publish # pushes to the LOCAL yalc registry (dev only — this is not npm)
Then, in your Medusa application:
1npx medusa plugin:add medusa-payment-cutluy
While developing, run Copy to clipboardpnpm medusa plugin:develop in this plugin's directory to watch changes and auto-update the app.
Install from npm
1npm install medusa-payment-cutluy
2. Configure
In Copy to clipboardmedusa-config.ts of your Medusa application, register the provider in the Payment Module's Copy to clipboardproviders array:
1234567891011121314151617181920import { defineConfig } from "@medusajs/framework/utils"module.exports = defineConfig({// ...other configmodules: [{resolve: "@medusajs/medusa/payment",options: {providers: [{// provider installed from the local registry or npmresolve: "medusa-payment-cutluy/providers/cutluy",id: "cutluy",options: {apiKey: process.env.CUTLUY_API_KEY,webhookSecret: process.env.CUTLUY_WEBHOOK_SECRET,// apiUrl: "https://cutluy.com/v1", // optional override// timeoutMs: 15000, // optional},},
Add the environment variables to your application's Copy to clipboard.env:
123# apps/backend/.envCUTLUY_API_KEY=ck_live_...CUTLUY_WEBHOOK_SECRET=whsec_...
The provider's identifier is Copy to clipboardpp_cutluy_cutluy. Enable it in a region from the Medusa Admin (Settings → Regions → Payment Providers).Options
Option Required Description Copy to clipboardapiKey ✅ CutLuy secret API key (Copy to clipboardck_live_... / Copy to clipboardck_test_...) Copy to clipboardwebhookSecret ⚠️ Signing secret used to verify Copy to clipboardX-CutLuy-Signature. Without it webhooks are rejected. Copy to clipboardapiUrl Override the API base URL (default Copy to clipboardhttps://cutluy.com/v1) Copy to clipboardtimeoutMs HTTP request timeout (default Copy to clipboard15000)
3. Configure the webhook in CutLuy
- Make sure your CutLuy store has a payment link configured (payment creation returns Copy to clipboard
404 payment_link_not_foundotherwise). - In the CutLuy dashboard, go to Webhooks.
- Add an endpoint pointing at Medusa's built-in payment webhook listener: (Copy to clipboard
cutluyis the provider's Copy to clipboardidentifier, repeated for the provider Copy to clipboardid.)
1https://<your-medusa-backend>/hooks/payment/cutluy_cutluy
- Copy the endpoint's signing secret into Copy to clipboard
CUTLUY_WEBHOOK_SECRET.
The provider verifies the Copy to clipboardX-CutLuy-Signature header (HMAC-SHA256 of Copy to clipboard<t>.<rawBody>) before trusting any event. Medusa's built-in listener acks the request with 200 immediately and processes the event asynchronously (~5s delay, up to 3 internal attempts). Invalid or missing signatures are logged and dropped — the request is still acked, so CutLuy does not retry after a 2xx (non-2xx or timeout responses are retried with exponential backoff, up to 8 times); monitor your backend logs for signature failures. Use the dashboard's Send test or resend a delivery to exercise your endpoint.
Before completing, the provider also verifies the webhook's payment against its payment session: the session must exist, the payment must be USD, and the webhook amount must match the session amount cent-exact. On a mismatch or unknown session the event is logged and ignored (the order stays Copy to clipboardawaiting). The payload is HMAC-authenticated, so this guards against CutLuy-side drift, not forgery.
Payment sweep job
Because Medusa 2.18's webhook processor ignores Copy to clipboardpayment.expired / Copy to clipboardpayment.failed events, an order whose QR expired or whose payment failed would stay Copy to clipboardawaiting forever with no signal. The plugin ships a scheduled job, Copy to clipboardpoll-cutluy-payments, that closes the loop:
- Runs every 15 minutes (node-schedule cron Copy to clipboard
*/15 * * * *). - Lists the provider's payment sessions older than 15 minutes that are still Copy to clipboard
pending/ Copy to clipboardpending_authorization, polls CutLuy for each, and for payments that are Copy to clipboardexpiredor Copy to clipboardfailedat CutLuy:- logs a warning with the session id, CutLuy payment id, amount, and currency, and
- marks the session Copy to clipboard
error(best-effort) so it stops being silently pending.
- Sessions are only touched once (they leave the pending set), so re-runs are idempotent.
To load the job, the plugin must be listed in the app's Copy to clipboardplugins array (the provider itself is registered under Copy to clipboardmodules):
1plugins: ["medusa-payment-cutluy"],
The staleness window is tunable via Copy to clipboardCUTLUY_SWEEP_STALE_AFTER_MS (default Copy to clipboard900000).
4. Storefront integration
The payment session Copy to clipboarddata contains everything the storefront needs:
123456789{"id": "PUETcMUOKStjZsCb6zAl8kg9fMRGM85x","status": "pending","amount": "1.50","currency": "USD","qr_string": "00020101021229...", // render as a QR code"checkout_url": "https://cutluy.com/pay/PUETcMUOKStjZsCb6zAl8kg9fMRGM85x","expires_at": "2026-07-09T12:05:00.000Z"}
Choose one of:
- Redirect the customer to Copy to clipboard
checkout_url(hosted, branded page with countdown and live status), or - Render Copy to clipboard
qr_stringas a QR code in your own UI (e.g. with a Copy to clipboardqrcodelibrary) and poll the cart/order status.
After the customer pays, the Copy to clipboardpayment.completed webhook completes the order automatically — no storefront polling required.
CutLuy's hosted checkout redirects back to your configured success/failure URLs after a terminal payment, appending Copy to clipboard?status=success|failed&payment_id=…&reference_id=…. Since Medusa drops Copy to clipboardpayment.expired / Copy to clipboardpayment.failed webhooks, the Copy to clipboardfailed redirect (or polling Copy to clipboardgetPaymentStatus) is how the storefront learns the order failed — the order itself stays Copy to clipboardawaiting until the sweep job or an operator acts.
5. Development
1234pnpm install # install dependenciespnpm test # run unit tests (vitest)pnpm build # medusa plugin:build → outputs to .medusa/serverpnpm dev # watch + push to the local yalc registry for the test app
6. Testing in a full Medusa app (Docker)
This repo ships only the plugin. To test it end-to-end, run a Medusa app in Docker and install the plugin into it. Follow the official guide — Install Medusa with Docker — then:
- Clone the DTC Starter repo and set up Copy to clipboard
docker-compose.yml, Copy to clipboardDockerfile, Copy to clipboardstart.shas described in the guide. - Install the plugin locally via yalc (Copy to clipboard
npx medusa plugin:publishhere, then Copy to clipboardnpx yalc add medusa-payment-cutluyin the app), or mount this plugin's folder and install it with Copy to clipboardmedusa plugin:add. - Register the provider in Copy to clipboard
apps/backend/medusa-config.ts(see Configure) and add the Copy to clipboardCUTLUY_*env vars to Copy to clipboardapps/backend/.env. - Copy to clipboard
docker compose up --build -d, create an admin user, and enable the CutLuy payment provider in a region. - Expose the backend to the internet (e.g. with a tunnel) and set the webhook URL in the CutLuy dashboard.
API mapping
Medusa provider method CutLuy API call Copy to clipboardinitiatePayment Copy to clipboardPOST /v1/payments Copy to clipboardgetPaymentStatus Copy to clipboardGET /v1/payments/:id Copy to clipboardretrievePayment Copy to clipboardGET /v1/payments/:id Copy to clipboardgetWebhookActionAndData webhook events (signature verified) Copy to clipboardcapturePayment / Copy to clipboardcancelPayment / Copy to clipboarddeletePayment / Copy to clipboardupdatePayment no-op (not in CutLuy v1 API) Copy to clipboardrefundPayment throws — not supported by CutLuy yet
Status mapping: Copy to clipboardpending/Copy to clipboardscanned → pending · Copy to clipboardpaid → captured · Copy to clipboardexpired/Copy to clipboardfailed → error/failed.
License
MIT

