M9: Medusa v2 payment provider connector

A minimal receive-only GoblinPay payment provider for Medusa v2 under connectors/medusa (service + module registration + types, README + INSTALL), modeled on connectors/woocommerce and the medusa-plugin-btcpay reference: create-invoice on initiate, an HMAC-verified webhook flips the payment to captured, status polling as the webhook-miss fallback. Refunds throw (receive-only, manual). Also refresh the store.rs docstring now that WooCommerce and Medusa have shipped.
This commit is contained in:
2ro
2026-07-03 03:22:36 -04:00
parent c32ddfa9ff
commit bba1dd5cba
8 changed files with 596 additions and 3 deletions
+94
View File
@@ -0,0 +1,94 @@
# Installing GoblinPay for Medusa
This is a Medusa v2 payment-module provider. There are two ways to add it.
## 1. Add the provider to your Medusa app
### Option A: copy the source (simplest)
Copy this `medusa` directory into your Medusa app as a module, for example
`src/modules/goblinpay`, keeping the `src/` files (`index.ts`, `service.ts`,
`types.ts`). Medusa compiles it with the rest of your app.
### Option B: install as a package
Publish or vendor `medusa-payment-goblinpay` and add it to your app's
dependencies. Build it first with `npm run build` (emits `dist/`).
## 2. Register it in `medusa-config.ts`
Add GoblinPay to the payment module's `providers`. Use `id: "goblinpay"` so the
webhook route is predictable (see step 4):
```ts
module.exports = defineConfig({
// ...
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
// Option A: the path to the copied module.
resolve: "./src/modules/goblinpay",
// Option B: the package name, "medusa-payment-goblinpay".
id: "goblinpay",
options: {
baseUrl: process.env.GOBLINPAY_URL,
apiToken: process.env.GOBLINPAY_API_TOKEN,
webhookSecret: process.env.GOBLINPAY_WEBHOOK_SECRET,
matchMode: "derived",
},
},
],
},
},
],
})
```
## 3. Set the environment
In your Medusa app's `.env`:
```
GOBLINPAY_URL=https://pay.example
GOBLINPAY_API_TOKEN=<the same value as GP_API_TOKEN on the server>
GOBLINPAY_WEBHOOK_SECRET=<the same value as GP_WEBHOOK_SECRET on the server>
```
Then enable the `goblinpay` provider in the region(s) that should offer Grin,
via the Medusa admin (Settings, then Regions, then Payment Providers).
## 4. Register the webhook in GoblinPay
Point your GoblinPay server at the Medusa payment webhook route. The route id is
`<provider id>_<identifier>`, both `goblinpay`, so set these on the GoblinPay
side:
- `GP_WEBHOOK_URL` = `https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay`
- `GP_WEBHOOK_SECRET` = the same secret you set as `webhookSecret`.
- `GP_API_TOKEN` = the same token you set as `apiToken`.
GoblinPay signs each delivery with `X-GoblinPay-Signature: sha256=<hmac>` over
the raw body and sends an idempotency key in `X-GoblinPay-Delivery`. The provider
verifies the signature (constant-time) and flips the payment to captured.
Make sure the Medusa host is reachable from the GoblinPay host. If a webhook is
ever missed, Medusa's `getPaymentStatus` polls
`GET {baseUrl}/invoice/{invoice_id}` (with the bearer token) as a fallback.
## 5. Test
Place a test order, choose Grin (GoblinPay), and confirm:
- The storefront shows the GoblinPay QR / redirects to the `/pay/<token>` page
(the checkout details are on the payment session's `data.goblinpay`).
- Paying from a Goblin Wallet moves the order's payment to captured once
GoblinPay delivers the `payment.received` webhook.
## Refund caveat
Refunds are not automated. GoblinPay is receive-only and never sends Grin, so
any refund is a manual Grin send performed by the merchant from a wallet under
their control. `refundPayment` throws to make this explicit.
+90
View File
@@ -0,0 +1,90 @@
# GoblinPay for Medusa
Accept Grin (GRIN / MimbleWimble) payments in a Medusa v2 store through a
self-hosted GoblinPay server. The customer pays from their Goblin Wallet by
scanning an `nprofile` QR code. The payment travels as a gift-wrapped slatepack
over Nostr (optionally over the Nym mixnet). GoblinPay receives it, returns the
reply slatepack to the payer, watches the chain to confirm, and notifies Medusa
through a signed webhook.
This provider is a thin client. All of the Grin and Nostr work happens in
GoblinPay; Medusa only talks HTTP to your GoblinPay instance. No BTCPay, no node
exposed to the store, no wallet RPC.
## What it does
- Registers a `goblinpay` payment provider in the Medusa v2 payment module.
- On checkout, calls GoblinPay to create an invoice for the payment session and
stores the checkout details (`pay_url`, `nprofile`, `qr_svg`) on the session
so your storefront can render the QR or redirect to GoblinPay's hosted
`/pay/<token>` page.
- Captures the payment when GoblinPay reports it, via a signed webhook. If a
webhook is missed, `authorizePayment` and `getPaymentStatus` poll GoblinPay
for the invoice status as a fallback.
## Requirements
- Medusa v2 (built against `@medusajs/framework` 2.12; 2.x expected to work).
- Node 20 or newer.
- A running GoblinPay server reachable from the Medusa host.
## Options
Set these per-provider in `medusa-config.ts` (see INSTALL.md):
- `baseUrl`: base URL of your GoblinPay server, no trailing slash, for example
`https://pay.example`.
- `apiToken`: the GoblinPay create-invoice bearer token (`GP_API_TOKEN` on the
server).
- `webhookSecret`: the shared HMAC secret (`GP_WEBHOOK_SECRET` on the server).
- `matchMode` (optional): how GoblinPay ties an incoming payment to the order.
`derived` (per-invoice identity, recommended) gives each order its own QR and
is the most reliable. `memo` and `amount` are also available. Omit to use the
server default.
- `expirySecs` (optional): invoice expiry in seconds from creation.
## Webhook
GoblinPay reports payments to the Medusa payment module's built-in webhook
route. Point your GoblinPay server's `GP_WEBHOOK_URL` at:
```
https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay
```
The provider verifies the `X-GoblinPay-Signature: sha256=<hmac>` header against
the exact raw body (constant-time) before acting.
## Status mapping
| GoblinPay | Medusa payment session |
|---|---|
| invoice `open` | `pending` |
| invoice `paid` | `captured` |
| invoice `expired` | `canceled` |
| webhook `payment.received` | captured (SUCCESSFUL) |
| webhook `payment.confirmed` | captured (SUCCESSFUL, idempotent) |
For a receive-only till, a received payment (the reply slatepack is back and the
funds are in the merchant wallet) is treated as paid, the same as the
WooCommerce connector. The later on-chain confirmation is an idempotent no-op.
## Refunds
Refunds are not automated. GoblinPay is receive-only: it never sends Grin. A
refund is therefore a manual, out-of-band Grin send by the merchant from a
wallet under their control. `refundPayment` throws to make this explicit, the
same caveat the Grin BTCPay connector carries.
## Security notes
- The webhook is authenticated by an HMAC-SHA256 signature over the exact raw
request body, compared in constant time. A bad or missing signature is
rejected and the payment is not flipped.
- The capture amount is read from the Medusa payment session (its own
store-currency amount), not from untrusted webhook fields.
- Secrets live in the provider options / environment, never in code.
## Credit
Built by Claude (Anthropic) for the Goblin project.
+41
View File
@@ -0,0 +1,41 @@
{
"name": "medusa-payment-goblinpay",
"version": "1.0.0",
"description": "GoblinPay payment provider for Medusa v2: accept Grin (GRIN / MimbleWimble) payments through a self-hosted, receive-only GoblinPay server.",
"license": "Apache-2.0",
"keywords": [
"medusa",
"medusa-v2",
"medusa-plugin",
"medusa-plugin-payment",
"payment",
"grin",
"mimblewimble",
"goblinpay"
],
"repository": {
"type": "git",
"url": "https://git.us-ea.st/GRIN/GoblinPay"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"src",
"README.md",
"INSTALL.md"
],
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@medusajs/framework": "^2.0.0"
},
"devDependencies": {
"@medusajs/framework": "^2.12.0",
"@types/node": "^20.0.0",
"typescript": "^5.4.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import GoblinPayProviderService from "./service"
// Register GoblinPay as a Medusa v2 payment-module provider. Referenced from
// medusa-config.ts under the payment module's `providers` (see INSTALL.md).
export default ModuleProvider(Modules.PAYMENT, {
services: [GoblinPayProviderService],
})
+302
View File
@@ -0,0 +1,302 @@
/**
* GoblinPay payment provider for Medusa v2 (tested against @medusajs 2.12).
*
* Modeled on connectors/woocommerce and on the reference
* github.com/SGFGOV/medusa-payment-plugins (packages/medusa-plugin-btcpay).
*
* Flow: `initiatePayment` creates a GoblinPay invoice for the order and stashes
* the checkout details (pay_url, nprofile, qr_svg) on the session so the
* storefront can render or redirect. The customer pays from their Goblin Wallet;
* GoblinPay receives it, returns the reply slatepack, watches the chain, and
* POSTs a signed webhook. `getWebhookActionAndData` verifies the HMAC and flips
* the Medusa payment to captured. Status polling (`authorizePayment`,
* `getPaymentStatus`) is the webhook-miss fallback.
*
* Refunds are NOT automated: GoblinPay is receive-only (it never sends Grin), so
* `refundPayment` throws. A refund is a manual, out-of-band Grin send by the
* merchant. See README.md.
*/
import crypto from "node:crypto"
import {
AbstractPaymentProvider,
ContainerRegistrationKeys,
MedusaError,
Modules,
PaymentActions,
} from "@medusajs/framework/utils"
import type {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
IPaymentModuleService,
Logger,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/framework/types"
import type { GoblinPayInvoice, GoblinPayOptions } from "./types"
class GoblinPayProviderService extends AbstractPaymentProvider<GoblinPayOptions> {
static identifier = "goblinpay"
protected readonly options_: GoblinPayOptions
protected readonly logger_: Logger
protected readonly paymentService_: IPaymentModuleService
constructor(container: Record<string, unknown>, options: GoblinPayOptions) {
super(container as never, options)
this.options_ = options
this.logger_ = container[ContainerRegistrationKeys.LOGGER] as Logger
this.paymentService_ = container[Modules.PAYMENT] as IPaymentModuleService
if (!options?.baseUrl || !options?.apiToken || !options?.webhookSecret) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"GoblinPay provider requires baseUrl, apiToken, and webhookSecret options"
)
}
}
private get base(): string {
return this.options_.baseUrl.replace(/\/+$/, "")
}
/** Call the GoblinPay REST API with the bearer token. */
private async request<T>(
method: "GET" | "POST",
path: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.options_.apiToken}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
const json = text ? JSON.parse(text) : {}
if (!res.ok) {
const err =
(json && (json.error as string)) || `GoblinPay HTTP ${res.status}`
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, err)
}
return json as T
}
/** Map a GoblinPay invoice status to a Medusa payment session status. */
private static mapStatus(
status: string
): "captured" | "canceled" | "pending" {
switch (status) {
case "paid":
return "captured"
case "expired":
return "canceled"
default:
return "pending"
}
}
async initiatePayment(
input: InitiatePaymentInput
): Promise<InitiatePaymentOutput> {
const sessionId = input.context?.idempotency_key
if (!sessionId) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Idempotency key (payment session id) is required to initiate payment"
)
}
// GoblinPay prices the fiat order into Grin via its own oracle. The order's
// session id is the order_ref, so the signed webhook echoes it back to us.
const invoice = await this.request<GoblinPayInvoice>("POST", "/invoice", {
order_ref: sessionId,
amount_fiat: input.amount.toString(),
currency: input.currency_code,
memo: `Medusa order ${sessionId}`,
...(this.options_.matchMode ? { match_mode: this.options_.matchMode } : {}),
...(this.options_.expirySecs ? { expiry_secs: this.options_.expirySecs } : {}),
})
return {
id: sessionId,
data: { ...input.data, goblinpay: invoice },
}
}
/** Re-read the current invoice from GoblinPay using the stored invoice_id. */
private async fetchInvoice(
data: Record<string, unknown> | undefined
): Promise<GoblinPayInvoice> {
const stored = (data?.goblinpay ?? {}) as GoblinPayInvoice
if (!stored.invoice_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"No GoblinPay invoice_id on the payment session"
)
}
return this.request<GoblinPayInvoice>(
"GET",
`/invoice/${encodeURIComponent(stored.invoice_id)}`
)
}
async authorizePayment(
input: AuthorizePaymentInput
): Promise<AuthorizePaymentOutput> {
const invoice = await this.fetchInvoice(input.data)
return {
status: GoblinPayProviderService.mapStatus(invoice.status),
data: { ...input.data, goblinpay: invoice },
}
}
async getPaymentStatus(
input: GetPaymentStatusInput
): Promise<GetPaymentStatusOutput> {
const invoice = await this.fetchInvoice(input.data)
return {
status: GoblinPayProviderService.mapStatus(invoice.status),
data: { ...input.data, goblinpay: invoice },
}
}
async capturePayment(
input: CapturePaymentInput
): Promise<CapturePaymentOutput> {
// GoblinPay is receive-only: once the payment is received the funds are
// already in the merchant wallet, so capture is a no-op acknowledgement.
return { data: input.data ?? {} }
}
async cancelPayment(
input: CancelPaymentInput
): Promise<CancelPaymentOutput> {
// Nothing to cancel server-side; an unpaid GoblinPay invoice simply expires.
return { data: input.data ?? {} }
}
async deletePayment(
input: DeletePaymentInput
): Promise<DeletePaymentOutput> {
return { data: input.data ?? {} }
}
async refundPayment(
_input: RefundPaymentInput
): Promise<RefundPaymentOutput> {
// Receive-only: GoblinPay never sends Grin, so refunds cannot be automated.
// A refund is a manual, out-of-band Grin send by the merchant.
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"GoblinPay is receive-only; refunds must be issued manually by the merchant (out-of-band Grin send)."
)
}
async retrievePayment(
input: RetrievePaymentInput
): Promise<RetrievePaymentOutput> {
const invoice = await this.fetchInvoice(input.data)
return { data: { ...input.data, goblinpay: invoice } }
}
async updatePayment(
input: UpdatePaymentInput
): Promise<UpdatePaymentOutput> {
return { data: input.data ?? {} }
}
/**
* Verify the HMAC-SHA256 over the EXACT raw body, constant-time. Mirrors the
* WooCommerce connector and GoblinPay's webhook contract:
* X-GoblinPay-Signature: sha256=<hex(HMAC-SHA256(secret, raw_body))>
*/
private verifySignature(payload: ProviderWebhookPayload["payload"]): boolean {
const raw = payload.rawData
if (!raw) {
return false
}
const provided = (payload.headers?.["x-goblinpay-signature"] as string) ?? ""
const expected =
"sha256=" +
crypto
.createHmac("sha256", this.options_.webhookSecret)
.update(raw as string | Buffer)
.digest("hex")
const a = Buffer.from(provided, "utf8")
const b = Buffer.from(expected, "utf8")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
async getWebhookActionAndData(
payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
if (!this.verifySignature(payload)) {
this.logger_.warn("goblinpay: webhook signature mismatch")
return { action: PaymentActions.FAILED }
}
const data = (payload.data ?? {}) as {
event_type?: string
order_ref?: string
}
const sessionId = data.order_ref
if (!sessionId) {
return { action: PaymentActions.NOT_SUPPORTED }
}
// payment.received (funds in hand, S2 returned) and payment.confirmed
// (on-chain) both mean paid for a receive-only till: flip to captured. The
// capture is idempotent, so a later confirmation after a received event is a
// no-op. Capture the session's own (store-currency) amount.
if (
data.event_type === "payment.received" ||
data.event_type === "payment.confirmed"
) {
const amount = await this.sessionAmount(sessionId)
return {
action: PaymentActions.SUCCESSFUL,
data: { session_id: sessionId, amount },
}
}
return { action: PaymentActions.NOT_SUPPORTED }
}
/** The payment session's authorized amount, for the webhook capture. */
private async sessionAmount(sessionId: string): Promise<number> {
try {
const session = await this.paymentService_.retrievePaymentSession(sessionId)
return Number(session.amount)
} catch (e) {
this.logger_.warn(
`goblinpay: could not read session ${sessionId} amount: ${
(e as Error).message
}`
)
return 0
}
}
}
export default GoblinPayProviderService
+40
View File
@@ -0,0 +1,40 @@
/**
* Options + wire types for the GoblinPay Medusa v2 payment provider.
*
* GoblinPay is a receive-only Grin payment server. This provider is a thin
* client: it calls GoblinPay's REST API to create an invoice and reads back the
* checkout details, then flips the Medusa payment on a signed webhook. All Grin
* and Nostr work happens in GoblinPay; Medusa only speaks HTTP to it.
*/
/** Provider options, set per-provider in `medusa-config.ts`. */
export interface GoblinPayOptions {
/** Base URL of your GoblinPay server, no trailing slash (e.g. https://pay.example). */
baseUrl: string
/** Bearer token for the create-invoice API (`GP_API_TOKEN` on the server). */
apiToken: string
/** Shared HMAC secret for webhook verification (`GP_WEBHOOK_SECRET`). */
webhookSecret: string
/**
* How GoblinPay matches an incoming payment to this order. `derived`
* (per-invoice identity, recommended) gives each order its own QR. Omit to
* use the server default.
*/
matchMode?: "memo" | "derived" | "amount"
/** Optional invoice expiry in seconds from creation. */
expirySecs?: number
}
/** The subset of GoblinPay's `/invoice` response this provider stores/uses. */
export interface GoblinPayInvoice {
invoice_id: string
token?: string
pay_url: string
nprofile?: string
npub?: string
qr_svg?: string
amount?: string
/** GoblinPay invoice lifecycle: `open` | `paid` | `expired`. */
status: string
order_ref?: string
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2021"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["dist", "node_modules"]
}
+3 -3
View File
@@ -1,8 +1,8 @@
//! The store-connector seam.
//!
//! Every store integration (the built-in generic REST connector, the
//! WooCommerce and Medusa plugins that arrive in a later milestone, and the
//! future pop-up Nostr store) drives GoblinPay through one uniform contract:
//! Every store integration (the built-in generic REST connector, the shipped
//! WooCommerce and Medusa plugins under `connectors/`, and the future pop-up
//! Nostr store) drives GoblinPay through one uniform contract:
//! a create-invoice request in, a hosted checkout + signed webhook out. This
//! trait keeps that mapping in one place so the core never grows per-store
//! branches: a connector only decides how a store's order becomes invoice