Manual bank transfer with payment-proof upload and admin verification for Medusa v2 — by Haus Kayu
Manual bank transfer with payment-proof upload and admin verification for Medusa v2.
Terima transfer bank dengan upload bukti dan verifikasi di Admin Medusa. Dibuat dan dipakai sendiri oleh Haus Kayu.
Status: 0.1.0, ready for a first release; tested on Medusa 2.19 and 2.22. Coming soon to hauskayu.com.
| Payment page (storefront) | Verification in the Admin |
|---|---|
Bank accounts managed in the Admin (Settings → Manual transfer): bank name, account holder, account number, SWIFT/BIC, bank address, currency, regions, active/inactive, order. Several accounts can be active; the first active one matching the cart's currency and region is used.
Placing the order authorizes the payment: Medusa creates the order and reserves stock, the payment waits for the buyer's transfer ().
Unique payment reference per order, e.g. , guaranteed unique by a database index, with a check character that catches typos.
Payment history on the order page (Admin widget), including a warning when the account chosen at checkout was deactivated before the order was placed and another account was given to the buyer.
Payment deadline in business days (default 3), counted in your time zone and skipping your holidays.
Capture only through admin confirmation: Medusa's own "Capture" button (Payments section of the order) is not used for bank transfers and is refused. An admin confirms the transfer (amount received, date, bank reference) after checking the bank statement, and only then is the payment captured.
The account details are copied into each order when it is placed, so later edits only apply to new orders.
Payment-proof upload: JPG, PNG or PDF up to 5 MB, recognised by the file's content (not its name). Logged-in customers upload from their order; guests use a secret upload token. Proofs are stored privately in the database and admins open them through 5-minute signed links. After a rejection the buyer can upload again. A file already sent for another order is flagged. Uploads are rate limited per IP and per order.
Verification queue (sidebar → Manual transfers): tabs per status (needs verification, awaiting payment, underpaid, rejected, paid, expired, canceled, all) with counts, search by reference, e-mail or .
Actions on the order page (widget): Confirm paid (amount received, date, bank reference, note; a red warning and an explicit "the store absorbs the difference" tick when less than the amount due arrived), Underpaid (records a partial transfer and shows what is left to pay; the buyer can upload a proof for the rest) and Reject for a pending proof (reason required, shown to the buyer). Every action is in the payment history with the admin's name, time and note. Each form carries the warning check the bank statement, never rely on the proof alone.
Statuses: awaiting payment, awaiting verification, paid, underpaid, rejected, expired (and canceled).
Reminder 24 hours before the deadline (once per payment) and auto-cancel when the deadline passes: the order is canceled with Medusa's own workflow, so the stock reservation is released. Payments with a proof waiting for review or with part of the money received are left for an admin. Both run every 15 minutes in the worker.
Proof retention (optional): delete proof files some days after the order was paid or canceled, keeping the records.
Late badge and admin digest: a proof waiting for verification more than 1 business day, or an underpayment without follow-up for more than 3 business days, shows a red "Late" badge in the queue (per row and per tab) and is listed once in a digest e-mail to the admins. Both thresholds are options.
E-mails for every status change, to the buyer and to the admins, through Medusa's Notification Module (any e-mail provider): order placed with the payment instructions and the pro-forma invoice attached, proof received, proof rejected (with the reason), underpaid (what is left), paid, reminder, expired, canceled. Default templates in English and Indonesian; replace any of them with your own HTML files.
Pro-forma invoice PDF: store name, logo and address, order number, reference, items and totals, bank details and deadline. Attached to the order e-mail, downloadable by the buyer and from the order page; the layout can be replaced. Set in an embedded Noto Sans (open license, Latin subset), so names like "José Müller-Ñúñez" keep their accents; uses your store's font instead.
The e-mails and the invoice carry your store's identity only (Settings → Manual transfer → Store details).
Storefront pages for the Medusa DTC Starter and the Medusa Next.js Starter (in the npm package): payment instructions with copy buttons, exact amount, reference, countdown, the "fees: OUR" note and the invoice download, plus the proof upload with its status. English and Indonesian. A starter-independent core (React + Fetch) makes other storefronts easy. See Storefront.
:
Then run , enable the provider on your regions, and add your bank account in Settings → Manual transfer. For e-mails, configure Medusa's Notification Module with a provider on the channel (see below).
The plugin sends through Medusa's Notification Module, so any provider on the channel works (SendGrid, Resend, SES, SMTP plugins, …). Without a Notification Module nothing is sent and nothing fails.
| Change | Buyer e-mail (template) | Admin e-mail |
|---|---|---|
| Order placed | : instructions, exact amount, reference, deadline, invoice PDF attached | |
| Proof uploaded | , with the bank-statement warning | |
| Proof rejected | , with the reason and the instructions | |
| Underpaid | , with what is left | |
| Paid | ||
| Deadline near | – | |
| Expired | ||
| Canceled | ||
| Waiting too long for an admin | – | (one digest) |
Every notification has rendered by the plugin, (or your ), all values in , and an idempotency key, so a retried event never sends the same e-mail twice. Attachments are base64.
Store identity. The e-mails and the invoice show the store's name (Settings → Store) and the store metadata , , , and . Edit them in Settings → Manual transfer → Store details; the Preview menu there opens every e-mail and the invoice rendered with a made-up order.
Your own templates. Put HTML files in : or (names in the table above, plus with for the frame). The subject is the file's . A file containing is sent as it is, without the layout. Syntax: (escaped), (raw), (if / for each), (if not). Values: , , , , , , , , , , , , , , , and (the default wording).
Admin recipients. Set to the addresses that should get the admin e-mails (new order, proof uploaded, paid, digest, …). Without it they go to every Admin user: the plugin logs a warning once, and Settings → Manual transfer → E-mail notifications lists who receives them right now, plus the the buyer e-mails link to.
Invoice font. The default layout embeds Noto Sans (SIL Open Font License, subset to the Latin scripts, see ), so accented names and addresses come out intact; only the glyphs used end up in the PDF. For your brand font, or for other scripts, set to a TTF/OTF file (relative to the app's working directory) or ; bold falls back to regular, mono to the bundled one. An unreadable file logs a warning and the bundled fonts are used. Characters a font has no glyph for lose their accent or become "?".
Your own invoice. is a function, or a module path whose default export is one: with the labels, store, items, totals, account and the loaded order in and pdf-lib in . embeds the same fonts as the default layout.
Testing without a real provider. The package includes a notification provider that writes every e-mail to a folder (, , and the attachments) instead of sending it. For development only:
The npm package holds the buyer's pages for both Medusa starters, in English and Indonesian:
| Starter | Install |
|---|---|
| Medusa DTC Starter (current) | |
| Medusa Next.js Starter (deprecated upstream) |
Each installer copies the core ( of the package: React components, Fetch API helpers, labels, no dependency on a starter) to , adds the example's thin, starter-specific layer (page, two route handlers, the place-order button) and makes four small, checked edits (payment method name, place-order button, order-page link, no "paid at" for unpaid transfers). Placing an order leads to : status, exact amount, bank details and reference with copy buttons, deadline with countdown, the "choose OUR" note, the invoice download and the proof upload. Guests get there with the upload token, logged-in customers with their session. Set to so the e-mails link to the same page.
Another storefront? Use the core directly: src/storefront/README.md. Details per starter: examples/dtc-starter/README.md, examples/nextjs-starter/README.md.
Uploads, status reads and token exchanges are limited per IP. Behind a storefront every request comes from the storefront's server, so the examples send the buyer's IP in together with a shared secret in . The backend uses that header only when the secret matches; otherwise it counts the IP of the connection. is never read: Medusa sets Express' to 1, so anyone reaching the backend directly could pick their own IP with it.
(or the plugin option ). The storefront takes the buyer's IP from , then the first entry, so the proxy in front of the storefront must set them (Vercel does; nginx: ). Without the secret all storefront traffic shares one IP budget: raise then. A reverse proxy in front of Medusa can send the two headers itself, too.
Guest upload tokens and the signed proof links are HMACs. In production set a secret of its own, so rotating the JWT secret does not break the links in e-mails already sent:
The plugin option wins over , which wins over (the fallback, fine for development). Changing the secret invalidates every upload token and proof link issued before.
Uploads, rejections, confirmations, underpayments, reminders and the expiry job lock the payment they change. The plugin uses Medusa's Locking Module from the container, which Medusa loads by default. Its default provider is in-memory, which only protects one process: when you run a separate server and worker (or several instances), use a shared provider, e.g.
( works too.) Only if an app disabled the Locking Module altogether does the plugin fall back to a lock kept in its own process, and it logs a warning once.
Proofs are kept forever by default. With , a daily job (03:30) deletes the file contents of proofs whose payment was captured (paid) or canceled more than N days ago. The proof record stays: file name, size, SHA-256, who uploaded it, review and history, so duplicate detection keeps working. The Admin shows "file deleted" instead of the View button, and old signed links answer .
| Method | Path | |
|---|---|---|
| GET / POST | list / create accounts | |
| GET / POST / DELETE | read / update / delete an account | |
| GET | the order's manual-transfer payment, history and proofs | |
| GET | the pro-forma invoice PDF () | |
| GET | the verification queue: , with and per status, per row | |
| GET | an e-mail (template name) or , rendered with a made-up order | |
| POST | confirm a transfer and capture the payment | |
| POST | record a partial transfer (status ) | |
| GET | for the Admin pages | |
| GET | a signed link to view a proof, valid 5 minutes | |
| POST | reject a pending proof, required |
takes . Partial transfers recorded with count towards the amount due; it is refused when less than that arrived in total, unless is . takes the same fields without and is refused when the transfer covers the whole amount (confirm instead).
Always check the bank statement in your banking app before confirming. Never rely on a proof image alone.
All need the publishable API key. The buyer is either the logged-in customer who placed the order (session or bearer) or anyone holding the order's upload token ( header, or ).
| Method | Path | |
|---|---|---|
| GET | instructions (reference, amount, account, deadline), status, / , , proofs | |
| GET | the pro-forma invoice PDF () | |
| POST | multipart: (JPG/PNG/PDF ≤ 5 MB), optional | |
| GET | right after checkout (24 h): trade the completed cart id for |
Anyone else gets . Too many requests get with . Upload tokens are HMAC-derived from the order id, so the e-mails can always rebuild the link ( in ); they only work while the payment accepts proofs.
The per-IP limit is kept in memory per server process; the per-order limit is counted in the database. Which IP counts: see The buyer's IP behind a storefront. Run several server instances? Use a shared locking provider (e.g. ), since proof uploads, rejections and confirmations lock the payment.
(and the payment session's data) holds , (the copied account details), , , (, , , , , , ), , , , (partial transfers), , and, once confirmed, . The provider rebuilds it from these keys at every step, so data a storefront sends when creating the session is ignored.
Events for notifications: and (), and ( of the payment), (), () and (). The plugin's own subscriber turns them (and / ) into the e-mails above.
holds the E2E suite, the test app and the storefront run (: both examples installed in pinned starter checkouts, checkout to "paid" in Chromium): see integration-tests/README.md and CONTRIBUTING.md. renders every e-mail and the invoice with a made-up order, without Medusa. Changes: CHANGELOG.md. Security reports: SECURITY.md. More screenshots: docs/screenshots.
Rekening bank dikelola dari Admin (Settings → Manual transfer): nama bank, nama penerima, nomor rekening, SWIFT/BIC, alamat bank, mata uang, region, aktif/nonaktif, urutan. Rekening aktif pertama yang cocok dengan mata uang dan region keranjang yang dipakai.
Order dibuat saat checkout: pembayaran di-authorize sehingga order tercipta dan stok ditahan, status menunggu pembayaran ().
Kode referensi unik per order, mis. , dijamin unik oleh index database, dengan karakter cek untuk menangkap salah ketik.
Riwayat pembayaran di halaman order (widget Admin), termasuk peringatan kalau rekening pilihan saat checkout dinonaktifkan sebelum order dibuat dan pembeli diberi rekening lain.
Batas waktu pembayaran dalam hari kerja (default 3), dihitung di zona waktu toko dan melewati hari libur.
Capture hanya lewat konfirmasi admin: tombol "Capture" bawaan Medusa ditolak. Admin mengonfirmasi transfer (nominal diterima, tanggal, ref bank) setelah mengecek mutasi, baru pembayaran di-capture.
Detail rekening disalin ke setiap order, jadi perubahan rekening hanya berlaku untuk order baru.
Upload bukti transfer: JPG, PNG, atau PDF maksimal 5 MB, dicek dari isi file (bukan namanya). Pelanggan yang login mengunggah dari halaman order; tamu memakai token rahasia. Bukti disimpan privat di database dan admin melihatnya lewat link bertanda tangan yang berlaku 5 menit. Setelah ditolak, pembeli bisa upload ulang. File yang sama yang pernah dikirim untuk order lain ditandai. Upload dibatasi per IP dan per order.
Antrean verifikasi (sidebar → Transfer manual): tab per status (perlu verifikasi, menunggu pembayaran, kurang bayar, ditolak, lunas, kedaluwarsa, dibatalkan, semua) dengan jumlahnya, cari lewat referensi, email, atau .
Aksi di halaman order (widget): Konfirmasi lunas (nominal diterima, tanggal, ref bank, catatan; peringatan merah dan centang "toko menanggung selisih" kalau kurang dari tagihan), Kurang bayar (mencatat transfer sebagian dan menghitung sisa; pembeli bisa upload bukti untuk sisanya), dan Tolak bukti (alasan wajib, dilihat pembeli). Semua aksi tercatat di riwayat dengan nama admin, waktu, dan catatan, dan setiap form membawa peringatan cek mutasi di aplikasi bank, jangan hanya percaya bukti.
Tombol "Capture" bawaan Medusa tidak dipakai untuk transfer bank dan akan ditolak; capture terjadi lewat Konfirmasi lunas.
Pengingat 24 jam sebelum batas waktu dan auto-cancel saat kedaluwarsa: order dibatalkan dengan workflow Medusa sehingga stok kembali. Pembayaran yang buktinya menunggu dicek atau yang sudah dibayar sebagian dibiarkan untuk admin. Keduanya jalan tiap 15 menit di worker.
Masa simpan bukti (opsional, ): isi file bukti dihapus N hari setelah order lunas atau dibatalkan; data buktinya (nama, ukuran, hash, riwayat) tetap disimpan. Default = simpan selamanya.
Badge "Terlambat" dan email ringkasan ke admin: bukti yang menunggu verifikasi lebih dari 1 hari kerja, atau kurang bayar tanpa tindak lanjut lebih dari 3 hari kerja, diberi badge merah di antrean (per baris dan per tab) dan dikirim sekali dalam email ringkasan ke admin. Ambangnya bisa diatur ().
Email untuk setiap perubahan status, ke pembeli dan admin, lewat Notification Module Medusa (provider email apa pun): order dibuat (instruksi + invoice PDF terlampir), bukti diterima, bukti ditolak (dengan alasan), kurang bayar (sisa tagihan), lunas, pengingat, kedaluwarsa, dibatalkan. Template default bahasa Inggris dan Indonesia (, atau order), bisa diganti dengan file HTML sendiri ().
Invoice PDF pro-forma: nama, logo, alamat toko, nomor order, referensi, barang dan total, detail rekening, batas waktu. Terlampir di email order, bisa diunduh pembeli dan dari halaman order; layout-nya bisa diganti (). Font Noto Sans (lisensi terbuka, subset Latin) disematkan sehingga nama seperti "José Müller-Ñúñez" tetap utuh; untuk memakai font toko sendiri.
Penerima email admin: isi (disarankan). Kalau kosong, semua user Admin menerimanya; plugin menulis peringatan di log dan Settings → Manual transfer → Notifikasi email menampilkan siapa saja penerimanya saat ini serta yang dipakai di email pembeli.
Halaman storefront untuk Medusa DTC Starter dan Medusa Next.js Starter (ikut paket npm): halaman instruksi (detail rekening dengan tombol salin, nominal persis, kode referensi, hitung mundur, catatan biaya OUR, unduh invoice) dan upload bukti dengan statusnya, teks EN + ID. Intinya (: komponen React, fungsi API, label) tidak bergantung starter. Pasang dengan (atau ), lalu isi dengan . Detail: examples/dtc-starter/README.md.
Email dan invoice hanya memakai identitas toko Anda (Settings → Manual transfer → Detail toko). Menu Pratinjau di sana membuka setiap email dan invoice dengan order contoh.
Tes email tanpa provider asli: provider menulis setiap email ke folder (HTML, teks, JSON, lampiran) alih-alih mengirimnya. Hanya untuk development.
Secret produksi: buat dengan lalu isi di . Tanpa itu plugin memakai (cukup untuk development). Mengganti secret membatalkan semua token upload dan link bukti yang sudah terkirim.
IP pembeli di balik storefront: batas per IP memakai header dari storefront hanya kalau cocok dengan (atau opsi ); selain itu IP koneksi. tidak pernah dibaca. Isi secret yang sama di server storefront (jangan ), dan pastikan proxy di depan storefront mengisi .
Lock: plugin memakai Locking Module Medusa dari container. Provider default-nya in-memory (hanya satu proses); kalau server dan worker dipisah, pakai atau (contoh di bagian English). Hanya kalau Locking Module dimatikan, plugin memakai lock di prosesnya sendiri dan menulis peringatan.
Roadmap: adapter penyimpanan bukti ke bucket privat (S3, Cloudflare R2, …) dengan signed URL, belum dikerjakan.
Konfigurasi lengkap ada di bagian English di atas.
Selalu cek mutasi di aplikasi bank sebelum konfirmasi, jangan hanya percaya bukti transfer.
Haus Kayu menjual perkakas dan furnitur jati dari Jepara ke pembeli di AS. Untuk custom furniture dan order besar, pembeli lebih nyaman transfer langsung ke rekening USD kami daripada membayar dengan kartu.
Haus Kayu: teak tools and furniture from Jepara, Indonesia.
MIT © Haus Kayu
npm install medusa-payment-manual-transfer1module.exports = defineConfig({2 // …3 plugins: [4 {5 resolve: "medusa-payment-manual-transfer",6 options: {7 // secret: "…", // signs guest upload tokens and proof links; default MANUAL_TRANSFER_SECRET, then http.jwtSecret8 // trustedProxySecret: "…", // the storefront sends the buyer's IP with it; default MANUAL_TRANSFER_PROXY_SECRET9 showCredit: true, // the small plugin credit under the plugin's Admin pages10 proofRetentionDays: null, // e.g. 365: delete proof files that long after the order was paid or canceled11 reminderHours: 24, // reminder event this many hours before the deadline12 autoCancelExpired: true, // cancel orders whose deadline passed without payment13 adminReminders: {14 verificationBusinessDays: 1, // "Late" when a proof waits longer than this15 underpaidBusinessDays: 3, // "Late" when an underpayment has no follow-up for longer than this16 email: true, // digest e-mail to the admins (the badge is always shown)17 },18 notifications: {19 adminEmails: ["orders@your-store.com"], // recommended; unset = every Admin user (logged, shown in Settings)20 locale: "en", // buyer e-mails: "en" or "id"; an order's metadata.locale wins21 // adminLocale: "id",22 paymentUrl: "https://your-store.com/transfer/{order_id}?token={token}", // the example storefront's page; no link without it23 adminUrl: "https://your-store.com/app", // links in the admin e-mails24 // templatesDir: "./emails", // your own templates, see below25 // templateIds: { order_placed: "d-123" }, // provider-side templates (e.g. SendGrid)26 // buyer: { payment_reminder: false }, // switch single e-mails off27 // admin: { paid: false, overdue: true },28 attachInvoice: true,29 },30 invoice: {31 // note: "Bank fees are paid by the buyer.",32 // fontPath: "./fonts/Brand.ttf", // your TTF/OTF, or { regular, bold, mono }; default: bundled Noto Sans33 // render: "./src/invoice.js", // your own PDF layout, see below34 },35 proofUploads: {36 perIp: 10, // upload attempts per IP per window37 perIpWindowMinutes: 15,38 readsPerIpPerMinute: 120,39 perOrderPerDay: 5, // proofs one order may receive in 24 hours40 tokenExchangesPerIp: 10, // cart id → upload token exchanges per IP per window (own budget)41 tokenExchangesWindowMinutes: 60,42 },43 },44 },45 ],46 modules: [47 {48 resolve: "@medusajs/medusa/payment",49 options: {50 providers: [51 {52 resolve: "medusa-payment-manual-transfer/providers/manual-transfer",53 id: "manual-transfer",54 options: {55 referencePrefix: "MT", // 1–6 of A–Z/0–956 deadlineBusinessDays: 3, // 1–6057 timezone: "Asia/Jakarta", // IANA zone business days are counted in (default UTC)58 holidays: ["2026-12-25"], // YYYY-MM-DD, skipped when counting59 },60 },61 ],62 },63 },64 ],65})1{2 resolve: "@medusajs/medusa/notification",3 options: {4 providers: [5 {6 resolve: "medusa-payment-manual-transfer/providers/email-preview",7 id: "email-preview",8 options: { channels: ["email"], dir: "./email-previews" },9 },10 ],11 },12},1openssl rand -hex 322# Medusa .env # storefront .env (server-side only, never NEXT_PUBLIC_*)3MANUAL_TRANSFER_PROXY_SECRET=<value> MANUAL_TRANSFER_PROXY_SECRET=<same value>1openssl rand -hex 322# .env3MANUAL_TRANSFER_SECRET=<the 64 hex characters>1modules: [2 {3 resolve: "@medusajs/medusa/locking",4 options: {5 providers: [6 { resolve: "@medusajs/medusa/locking-redis", id: "locking-redis", is_default: true, options: { redisUrl: process.env.REDIS_URL } },7 ],8 },9 },10]