medusa-payment-cutluy
v0.2.4
Published
Medusa Payment Module Provider for CutLuy — Bakong KHQR scan-to-pay (asynchronous payment method).
Maintainers
Readme
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 medusa-payment-stripe) and installable in any Medusa v2 application.
Features
- ✅ Create a CutLuy KHQR payment per payment session
- ✅ Exposes
qr_stringandcheckout_urlto the storefront through the payment sessiondata - ✅ Asynchronous authorization (
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
awaiting(dropped webhook events) and flags them for operators - ✅
payment.completedwebhook marks the session captured and completes the cart/order - ✅ Poll-based status (
getPaymentStatus) and retrieval (retrievePayment) - ✅ 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
sequenceDiagram
participant S as Storefront
participant M as Medusa Backend
participant C as CutLuy
S->>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_url
S->>M: place order → authorizePayment → pending_authorization
C->>M: webhook payment.completed → /hooks/payment/cutluy_cutluy
M->>M: verify X-CutLuy-Signature → mark captured → complete cart/orderBecause authorizePayment returns pending_authorization, the order is created with an awaiting payment status. When CutLuy fires payment.completed, Medusa's processPaymentWorkflow re-invokes authorizePayment — the provider re-checks the CutLuy payment status and returns captured 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, payment.expired / payment.failed are mapped to a FAILED 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 getPaymentStatus), and operators see a warning log per event.
Async flow requirement:
authorizePaymentpollsGET /v1/payments/:idon every call and only returnspending_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 medusa-test-app/ directory (sibling of this repo — not part of the package):
docker-compose.yml— Postgres + Redis for the Medusa appmedusa-app/— acreate-medusa-appbackend with the plugin registeredcutluy-mock/server.mjs— a mock of the CutLuy API (http://localhost:8080/v1), so no real credentials are needede2e-test.mjs— drives the full flow via the API: cart → payment session (pp_cutluy_cutluy) → verifyqr_string/checkout_url→ complete cart (order "awaiting") → simulate customer paying at the mock → deliver a signedpayment.completedwebhook → assert the order becomescaptured
When you later point the provider at the real CutLuy API (CUTLUY_API_URL + real ck_... key), the same script works unchanged.
Dependency & security posture: the published package ships zero runtime dependencies — it only declares the
@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.@medusajs/telemetry, which is opt-out viaMEDUSA_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):
pnpm install
pnpm medusa plugin:publish # pushes to the LOCAL yalc registry (dev only — this is not npm)Then, in your Medusa application:
npx medusa plugin:add medusa-payment-cutluyWhile developing, run pnpm medusa plugin:develop in this plugin's directory to watch changes and auto-update the app.
Install from npm
npm install medusa-payment-cutluy2. Configure
In medusa-config.ts of your Medusa application, register the provider in the Payment Module's providers array:
import { defineConfig } from "@medusajs/framework/utils"
module.exports = defineConfig({
// ...other config
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
// provider installed from the local registry or npm
resolve: "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 .env:
# apps/backend/.env
CUTLUY_API_KEY=ck_live_...
CUTLUY_WEBHOOK_SECRET=whsec_...The provider's identifier is
pp_cutluy_cutluy. Enable it in a region from the Medusa Admin (Settings → Regions → Payment Providers).
Options
| Option | Required | Description |
| --------------- | :------: | --------------------------------------------------------------------------- |
| apiKey | ✅ | CutLuy secret API key (ck_live_... / ck_test_...) |
| webhookSecret | ⚠️ | Signing secret used to verify X-CutLuy-Signature. Without it webhooks are rejected. |
| apiUrl | | Override the API base URL (default https://cutluy.com/v1) |
| timeoutMs | | HTTP request timeout (default 15000) |
3. Configure the webhook in CutLuy
Make sure your CutLuy store has a payment link configured (payment creation returns
404 payment_link_not_foundotherwise).In the CutLuy dashboard, go to Webhooks.
Add an endpoint pointing at Medusa's built-in payment webhook listener:
https://<your-medusa-backend>/hooks/payment/cutluy_cutluy(
cutluyis the provider'sidentifier, repeated for the providerid.)Copy the endpoint's signing secret into
CUTLUY_WEBHOOK_SECRET.
The provider verifies the X-CutLuy-Signature header (HMAC-SHA256 of <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 awaiting). 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 payment.expired / payment.failed events, an order whose QR expired or whose payment failed would stay awaiting forever with no signal. The plugin ships a scheduled job, poll-cutluy-payments, that closes the loop:
- Runs every 15 minutes (node-schedule cron
*/15 * * * *). - Lists the provider's payment sessions older than 15 minutes that are still
pending/pending_authorization, polls CutLuy for each, and for payments that areexpiredorfailedat CutLuy:- logs a warning with the session id, CutLuy payment id, amount, and currency, and
- marks the session
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 plugins array (the provider itself is registered under modules):
plugins: ["medusa-payment-cutluy"],The staleness window is tunable via CUTLUY_SWEEP_STALE_AFTER_MS (default 900000).
4. Storefront integration
The payment session data contains everything the storefront needs:
{
"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
checkout_url(hosted, branded page with countdown and live status), or - Render
qr_stringas a QR code in your own UI (e.g. with aqrcodelibrary) and poll the cart/order status.
After the customer pays, the payment.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 ?status=success|failed&payment_id=…&reference_id=…. Since Medusa drops payment.expired / payment.failed webhooks, the failed redirect (or polling getPaymentStatus) is how the storefront learns the order failed — the order itself stays awaiting until the sweep job or an operator acts.
5. Development
pnpm install # install dependencies
pnpm test # run unit tests (vitest)
pnpm build # medusa plugin:build → outputs to .medusa/server
pnpm dev # watch + push to the local yalc registry for the test app6. 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
docker-compose.yml,Dockerfile,start.shas described in the guide. - Install the plugin locally via yalc (
npx medusa plugin:publishhere, thennpx yalc add medusa-payment-cutluyin the app), or mount this plugin's folder and install it withmedusa plugin:add. - Register the provider in
apps/backend/medusa-config.ts(see Configure) and add theCUTLUY_*env vars toapps/backend/.env. 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 |
| ---------------------------- | ---------------------------------------- |
| initiatePayment | POST /v1/payments |
| getPaymentStatus | GET /v1/payments/:id |
| retrievePayment | GET /v1/payments/:id |
| getWebhookActionAndData | webhook events (signature verified) |
| capturePayment / cancelPayment / deletePayment / updatePayment | no-op (not in CutLuy v1 API) |
| refundPayment | throws — not supported by CutLuy yet |
Status mapping: pending/scanned → pending · paid → captured · expired/failed → error/failed.
License
MIT
