From bba1dd5cba894005205180f9346886c022c728c8 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:22:36 -0400 Subject: [PATCH] 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. --- connectors/medusa/INSTALL.md | 94 ++++++++++ connectors/medusa/README.md | 90 +++++++++ connectors/medusa/package.json | 41 +++++ connectors/medusa/src/index.ts | 9 + connectors/medusa/src/service.ts | 302 +++++++++++++++++++++++++++++++ connectors/medusa/src/types.ts | 40 ++++ connectors/medusa/tsconfig.json | 17 ++ crates/gp-core/src/store.rs | 6 +- 8 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 connectors/medusa/INSTALL.md create mode 100644 connectors/medusa/README.md create mode 100644 connectors/medusa/package.json create mode 100644 connectors/medusa/src/index.ts create mode 100644 connectors/medusa/src/service.ts create mode 100644 connectors/medusa/src/types.ts create mode 100644 connectors/medusa/tsconfig.json diff --git a/connectors/medusa/INSTALL.md b/connectors/medusa/INSTALL.md new file mode 100644 index 0000000..55e2ce2 --- /dev/null +++ b/connectors/medusa/INSTALL.md @@ -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= +GOBLINPAY_WEBHOOK_SECRET= +``` + +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 +`_`, 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=` 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/` 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. diff --git a/connectors/medusa/README.md b/connectors/medusa/README.md new file mode 100644 index 0000000..e21f85c --- /dev/null +++ b/connectors/medusa/README.md @@ -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/` 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=` 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. diff --git a/connectors/medusa/package.json b/connectors/medusa/package.json new file mode 100644 index 0000000..b1f6f74 --- /dev/null +++ b/connectors/medusa/package.json @@ -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" + } +} diff --git a/connectors/medusa/src/index.ts b/connectors/medusa/src/index.ts new file mode 100644 index 0000000..9410cfe --- /dev/null +++ b/connectors/medusa/src/index.ts @@ -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], +}) diff --git a/connectors/medusa/src/service.ts b/connectors/medusa/src/service.ts new file mode 100644 index 0000000..951e2ec --- /dev/null +++ b/connectors/medusa/src/service.ts @@ -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 { + static identifier = "goblinpay" + + protected readonly options_: GoblinPayOptions + protected readonly logger_: Logger + protected readonly paymentService_: IPaymentModuleService + + constructor(container: Record, 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( + method: "GET" | "POST", + path: string, + body?: unknown + ): Promise { + 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 { + 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("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 | undefined + ): Promise { + 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( + "GET", + `/invoice/${encodeURIComponent(stored.invoice_id)}` + ) + } + + async authorizePayment( + input: AuthorizePaymentInput + ): Promise { + const invoice = await this.fetchInvoice(input.data) + return { + status: GoblinPayProviderService.mapStatus(invoice.status), + data: { ...input.data, goblinpay: invoice }, + } + } + + async getPaymentStatus( + input: GetPaymentStatusInput + ): Promise { + const invoice = await this.fetchInvoice(input.data) + return { + status: GoblinPayProviderService.mapStatus(invoice.status), + data: { ...input.data, goblinpay: invoice }, + } + } + + async capturePayment( + input: CapturePaymentInput + ): Promise { + // 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 { + // Nothing to cancel server-side; an unpaid GoblinPay invoice simply expires. + return { data: input.data ?? {} } + } + + async deletePayment( + input: DeletePaymentInput + ): Promise { + return { data: input.data ?? {} } + } + + async refundPayment( + _input: RefundPaymentInput + ): Promise { + // 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 { + const invoice = await this.fetchInvoice(input.data) + return { data: { ...input.data, goblinpay: invoice } } + } + + async updatePayment( + input: UpdatePaymentInput + ): Promise { + 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= + */ + 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 { + 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 { + 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 diff --git a/connectors/medusa/src/types.ts b/connectors/medusa/src/types.ts new file mode 100644 index 0000000..c05f7e8 --- /dev/null +++ b/connectors/medusa/src/types.ts @@ -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 +} diff --git a/connectors/medusa/tsconfig.json b/connectors/medusa/tsconfig.json new file mode 100644 index 0000000..9683e58 --- /dev/null +++ b/connectors/medusa/tsconfig.json @@ -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"] +} diff --git a/crates/gp-core/src/store.rs b/crates/gp-core/src/store.rs index dc83999..28a58f0 100644 --- a/crates/gp-core/src/store.rs +++ b/crates/gp-core/src/store.rs @@ -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