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:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
//! The store-connector seam.
|
//! The store-connector seam.
|
||||||
//!
|
//!
|
||||||
//! Every store integration (the built-in generic REST connector, the
|
//! Every store integration (the built-in generic REST connector, the shipped
|
||||||
//! WooCommerce and Medusa plugins that arrive in a later milestone, and the
|
//! WooCommerce and Medusa plugins under `connectors/`, and the future pop-up
|
||||||
//! future pop-up Nostr store) drives GoblinPay through one uniform contract:
|
//! Nostr store) drives GoblinPay through one uniform contract:
|
||||||
//! a create-invoice request in, a hosted checkout + signed webhook out. This
|
//! 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
|
//! 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
|
//! branches: a connector only decides how a store's order becomes invoice
|
||||||
|
|||||||
Reference in New Issue
Block a user