From 41e86b8f46991c66fc60c2e12534d19a7315c8b6 Mon Sep 17 00:00:00 2001 From: jamesw383 Date: Mon, 6 Jul 2026 09:39:14 +0400 Subject: [PATCH] Release Medusa MakePay plugin 0.2.0 --- README.md | 11 +- package-lock.json | 4 +- package.json | 10 +- src/declarations.d.ts | 11 + .../makepay/services/makepay-provider.ts | 19 ++ src/providers/makepay/types.ts | 3 + src/providers/makepay/utils.ts | 263 +++++++++++++----- tests/run.mjs | 64 ++++- 8 files changed, 310 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index bd8b417..c8fd057 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MakePay for Medusa [![npm version](https://img.shields.io/npm/v/@makecrypto/medusa-plugin-makepay.svg)](https://www.npmjs.com/package/@makecrypto/medusa-plugin-makepay) -[![CI](https://github.com/makecryptoio/medusa-plugin-makepay/actions/workflows/ci.yml/badge.svg)](https://github.com/makecryptoio/medusa-plugin-makepay/actions/workflows/ci.yml) +[![CI](https://github.com/makepay-io/medusa-plugin-makepay/actions/workflows/ci.yml/badge.svg)](https://github.com/makepay-io/medusa-plugin-makepay/actions/workflows/ci.yml)

MakePay icon @@ -11,6 +11,10 @@ Official MakePay payment provider for Medusa v2. The provider creates hosted MakePay checkout links for Medusa payment sessions and reconciles payment status through signed MakePay webhooks. +Need MakePay credentials? Register your merchant account at +[makepay.io](https://makepay.io) before configuring this plugin. Your merchant +account provides the API key and webhook secret used below. + ## Install ```bash @@ -73,6 +77,7 @@ webhook secret as `MAKEPAY_WEBHOOK_SECRET`. | `returnUrl` | no | Fallback return URL for MakePay checkout. | | `successUrl` | no | Success return URL for MakePay checkout. | | `failureUrl` | no | Failure return URL for MakePay checkout. | +| `webhookToleranceSeconds` | no | Signature timestamp tolerance for MakePay webhooks. | ## Storefront flow @@ -86,6 +91,10 @@ paymentSession.data.next_action Redirect shoppers to that URL, then use MakePay webhooks to update the Medusa payment session when the payment is completed, canceled, or failed. +If Medusa updates an unpaid payment session because the cart amount or fiat +currency changed, the provider archives the old MakePay link and creates a new +hosted checkout link for the updated session. + ## Development ```bash diff --git a/package-lock.json b/package-lock.json index b0b8d8a..f8796bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@makecrypto/medusa-plugin-makepay", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makecrypto/medusa-plugin-makepay", - "version": "0.1.2", + "version": "0.2.0", "license": "MIT", "dependencies": { "@makecrypto/makepay": "^0.3.0" diff --git a/package.json b/package.json index 3ae1984..7fda5aa 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "@makecrypto/medusa-plugin-makepay", - "version": "0.1.2", - "description": "Official MakePay payment provider for Medusa v2 hosted checkout.", + "version": "0.2.0", + "description": "Official MakePay payment provider for Medusa v2 hosted checkout. Register a merchant account at https://makepay.io.", "license": "MIT", "author": "MakePay", - "homepage": "https://github.com/makecryptoio/medusa-plugin-makepay#readme", + "homepage": "https://makepay.io", "bugs": { - "url": "https://github.com/makecryptoio/medusa-plugin-makepay/issues" + "url": "https://github.com/makepay-io/medusa-plugin-makepay/issues" }, "type": "module", "sideEffects": false, @@ -37,7 +37,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/makecryptoio/medusa-plugin-makepay.git" + "url": "git+https://github.com/makepay-io/medusa-plugin-makepay.git" }, "keywords": [ "medusa-plugin", diff --git a/src/declarations.d.ts b/src/declarations.d.ts index fd00f35..63e335a 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -21,10 +21,15 @@ declare module "@makecrypto/makepay" { uid?: string; publicUrl?: string; checkoutUrl?: string; + public_url?: string; + checkout_url?: string; url?: string; status?: string; amount?: string | number | null; + amountUsd?: string | number | null; currency?: string | null; + fiatCurrency?: string | null; + fiat_currency?: string | null; metadata?: Record; payload?: Record; [key: string]: unknown; @@ -33,6 +38,12 @@ declare module "@makecrypto/makepay" { export type MakePayPaymentLinkResponse = { paymentLink?: MakePayPaymentLink; payment_link?: MakePayPaymentLink; + data?: { + paymentLink?: MakePayPaymentLink; + payment_link?: MakePayPaymentLink; + link?: MakePayPaymentLink; + }; + link?: MakePayPaymentLink; [key: string]: unknown; }; diff --git a/src/providers/makepay/services/makepay-provider.ts b/src/providers/makepay/services/makepay-provider.ts index 7106ce7..bfbbb06 100644 --- a/src/providers/makepay/services/makepay-provider.ts +++ b/src/providers/makepay/services/makepay-provider.ts @@ -54,6 +54,7 @@ import { mapMakePayWebhookToPaymentAction, normalizeAmountValue, normalizeProviderOptions, + shouldRefreshPaymentLinkForUpdate, validateMakePayProviderOptions, } from "../utils.js"; @@ -119,7 +120,9 @@ class MakePayProviderService extends AbstractPaymentProvider; + } return { data: providerData, diff --git a/src/providers/makepay/types.ts b/src/providers/makepay/types.ts index d0ebb7a..dcdf582 100644 --- a/src/providers/makepay/types.ts +++ b/src/providers/makepay/types.ts @@ -41,6 +41,9 @@ export type NormalizedMakePayProviderOptions = Required< export type MakePayProviderData = Record & { id?: string; + amount?: string | number; + fiat_currency?: string; + fiatCurrency?: string; payment_link_uid?: string; public_url?: string; checkout_url?: string; diff --git a/src/providers/makepay/utils.ts b/src/providers/makepay/utils.ts index 7673c4a..f8e4012 100644 --- a/src/providers/makepay/utils.ts +++ b/src/providers/makepay/utils.ts @@ -76,6 +76,18 @@ const WEBHOOK_CANCELED_EVENTS = new Set([ "payment_cancelled_by_payer", ]); +const WEBHOOK_RECORD_KEYS = [ + "data", + "payload", + "event", + "session", + "latestSession", + "payment", + "paymentLink", + "payment_link", + "link", +] as const; + export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -155,16 +167,24 @@ export function getNestedRecord( export function getPaymentLinkFromResponse( response: MakePayPaymentLinkResponse | Record, ): MakePayPaymentLink { - if (isRecord(response.paymentLink)) { - return response.paymentLink as MakePayPaymentLink; - } + const data = getNestedRecord(response, "data"); - if (isRecord(response.payment_link)) { - return response.payment_link as MakePayPaymentLink; - } + for (const source of [response, data]) { + if (!source) { + continue; + } - if (isRecord(response.link)) { - return response.link as MakePayPaymentLink; + if (isRecord(source.paymentLink)) { + return source.paymentLink as MakePayPaymentLink; + } + + if (isRecord(source.payment_link)) { + return source.payment_link as MakePayPaymentLink; + } + + if (isRecord(source.link)) { + return source.link as MakePayPaymentLink; + } } return {}; @@ -175,12 +195,16 @@ export function getPaymentLinkUid(data: unknown): string | undefined { return undefined; } - const link = getNestedRecord(data, "paymentLink"); + const link = + getNestedRecord(data, "paymentLink") ?? + getNestedRecord(data, "payment_link") ?? + getNestedRecord(data, "link"); return ( getText(data.payment_link_uid) ?? getText(data.paymentLinkUid) ?? getText(data.payment_link_id) ?? + getText(data.uid) ?? getText(data.id) ?? getText(link?.uid) ?? getText(link?.id) @@ -191,93 +215,102 @@ export function getPaymentLinkUrl(link: MakePayPaymentLink): string | undefined return ( getText(link.publicUrl) ?? getText(link.checkoutUrl) ?? + getText(link.public_url) ?? + getText(link.checkout_url) ?? getText(link.url) ); } -export function getSessionIdFromData(data: unknown): string | undefined { +export function getPaymentLinkAmount( + data: unknown, +): string | number | undefined { if (!isRecord(data)) { return undefined; } - const metadata = getNestedRecord(data, "metadata"); + const link = + getNestedRecord(data, "paymentLink") ?? + getNestedRecord(data, "payment_link") ?? + getNestedRecord(data, "link"); return ( - getText(data.session_id) ?? - getText(data.sessionId) ?? - getText(metadata?.session_id) ?? - getText(metadata?.medusaSessionId) + getNumberOrText(data.amount) ?? + getNumberOrText(data.payment_amount) ?? + getNumberOrText(link?.amount) ?? + getNumberOrText(link?.amountUsd) ); } -export function getSessionIdFromWebhook(event: unknown): string | undefined { - if (!isRecord(event)) { +export function getPaymentLinkFiatCurrency(data: unknown): string | undefined { + if (!isRecord(data)) { return undefined; } - const paymentLink = getNestedRecord(event, "paymentLink"); - const payment = getNestedRecord(event, "payment"); - const data = getNestedRecord(event, "data"); - const linkPayload = paymentLink ? getNestedRecord(paymentLink, "payload") : undefined; - const linkMetadata = paymentLink ? getNestedRecord(paymentLink, "metadata") : undefined; - const payloadMetadata = linkPayload - ? getNestedRecord(linkPayload, "metadata") - : undefined; - const paymentMetadata = payment ? getNestedRecord(payment, "metadata") : undefined; + const link = + getNestedRecord(data, "paymentLink") ?? + getNestedRecord(data, "payment_link") ?? + getNestedRecord(data, "link"); return ( - getText(data?.session_id) ?? - getText(data?.medusaSessionId) ?? - getText(linkMetadata?.session_id) ?? - getText(linkMetadata?.medusaSessionId) ?? - getText(payloadMetadata?.session_id) ?? - getText(payloadMetadata?.medusaSessionId) ?? - getText(paymentMetadata?.session_id) ?? - getText(paymentMetadata?.medusaSessionId) + getText(data.fiat_currency) ?? + getText(data.fiatCurrency) ?? + getText(link?.fiatCurrency) ?? + getText(link?.fiat_currency) ); } -export function getAmountFromWebhook(event: unknown): string | number | undefined { - if (!isRecord(event)) { +export function getSessionIdFromData(data: unknown): string | undefined { + if (!isRecord(data)) { return undefined; } - const session = getNestedRecord(event, "session"); - const paymentLink = getNestedRecord(event, "paymentLink"); - const payment = getNestedRecord(event, "payment"); - const data = getNestedRecord(event, "data"); + const metadata = getNestedRecord(data, "metadata"); return ( - getNumberOrText(session?.invoiceAmount) ?? - getNumberOrText(session?.expectedBuyAmount) ?? - getNumberOrText(payment?.amount) ?? - getNumberOrText(payment?.fiatAmount) ?? - getNumberOrText(paymentLink?.amount) ?? - getNumberOrText(data?.amount) + getText(data.session_id) ?? + getText(data.sessionId) ?? + getText(metadata?.session_id) ?? + getText(metadata?.medusaSessionId) ); } -export function collectStatusValues(input: unknown): string[] { - if (!isRecord(input)) { - return []; +export function getSessionIdFromWebhook(event: unknown): string | undefined { + for (const record of collectWebhookRecords(event, { includeMetadata: true })) { + const sessionId = + getText(record.session_id) ?? + getText(record.sessionId) ?? + getText(record.medusaSessionId); + + if (sessionId) { + return sessionId; + } } - const values: string[] = []; - const nestedKeys = ["paymentLink", "payment", "session", "data"]; + return undefined; +} - for (const key of ["status", "paymentStatus", "state", "type"]) { - const value = getText(input[key]); - if (value) { - values.push(value.toLowerCase()); +export function getAmountFromWebhook(event: unknown): string | number | undefined { + for (const record of collectWebhookRecords(event)) { + const amount = + getNumberOrText(record.invoiceAmount) ?? + getNumberOrText(record.expectedBuyAmount) ?? + getNumberOrText(record.fiatAmount) ?? + getNumberOrText(record.amount) ?? + getNumberOrText(record.amountUsd) ?? + getNumberOrText(record.totalAmount); + + if (amount !== undefined) { + return amount; } } - for (const key of nestedKeys) { - const record = getNestedRecord(input, key); - if (!record) { - continue; - } + return undefined; +} +export function collectStatusValues(input: unknown): string[] { + const values: string[] = []; + + for (const record of collectWebhookRecords(input)) { for (const statusKey of ["status", "paymentStatus", "state", "type"]) { const value = getText(record[statusKey]); if (value) { @@ -286,13 +319,46 @@ export function collectStatusValues(input: unknown): string[] { } } - const event = getNestedRecord(input, "event"); - const eventType = getText(event?.type); - if (eventType) { - values.push(eventType.toLowerCase()); + return values; +} + +export function collectWebhookRecords( + input: unknown, + options: { includeMetadata?: boolean } = {}, +): Record[] { + if (!isRecord(input)) { + return []; } - return values; + const records: Record[] = []; + const seen = new Set>(); + const keys = options.includeMetadata + ? [...WEBHOOK_RECORD_KEYS, "metadata"] + : WEBHOOK_RECORD_KEYS; + + const visit = (record: Record, depth: number): void => { + if (seen.has(record)) { + return; + } + + seen.add(record); + records.push(record); + + if (depth <= 0) { + return; + } + + for (const key of keys) { + const nested = getNestedRecord(record, key); + if (nested) { + visit(nested, depth - 1); + } + } + }; + + visit(input, 4); + + return records; } export function mapMakePayStateToPaymentSessionStatus( @@ -364,20 +430,87 @@ export function mapMakePayWebhookToPaymentAction( return "not_supported"; } +export function shouldRefreshPaymentLinkForUpdate(input: { + currentData: unknown; + nextAmount: BigNumberInput; + nextCurrencyCode: string; +}): boolean { + const status = mapMakePayStateToPaymentSessionStatus(input.currentData); + + if (status === "captured" || status === "authorized") { + return false; + } + + if (status === "canceled" || status === "error") { + return true; + } + + const currentAmount = getPaymentLinkAmount(input.currentData); + const currentCurrency = getPaymentLinkFiatCurrency(input.currentData); + + if ( + currentAmount !== undefined && + !arePaymentAmountsEqual(currentAmount, normalizeAmountValue(input.nextAmount)) + ) { + return true; + } + + return ( + currentCurrency !== undefined && + currentCurrency.toUpperCase() !== input.nextCurrencyCode.toUpperCase() + ); +} + +export function arePaymentAmountsEqual( + left: string | number, + right: string | number, +): boolean { + const leftText = String(left).trim(); + const rightText = String(right).trim(); + + if (leftText === rightText) { + return true; + } + + const leftNumber = Number(leftText); + const rightNumber = Number(rightText); + + return ( + Number.isFinite(leftNumber) && + Number.isFinite(rightNumber) && + leftNumber === rightNumber + ); +} + export function buildProviderData(input: { existing?: Record; paymentLink: MakePayPaymentLink; sessionId?: string; status?: MakePayPaymentSessionStatus; + amount?: string | number; + fiatCurrency?: string; }): MakePayProviderData { const uid = getText(input.paymentLink.uid) ?? getText(input.paymentLink.id); const url = getPaymentLinkUrl(input.paymentLink); const status = input.status ?? mapMakePayStateToPaymentSessionStatus(input.paymentLink); + const amount = + input.amount ?? + getNumberOrText(input.paymentLink.amount) ?? + getNumberOrText(input.existing?.amount); + const fiatCurrency = + input.fiatCurrency ?? + getText(input.paymentLink.fiatCurrency) ?? + getText(input.paymentLink.fiat_currency) ?? + getText(input.existing?.fiat_currency) ?? + getText(input.existing?.fiatCurrency); return { ...(input.existing ?? {}), id: uid, + amount, + fiat_currency: fiatCurrency, + fiatCurrency, payment_link_uid: uid, paymentLinkUid: uid, public_url: url, diff --git a/tests/run.mjs b/tests/run.mjs index 1c694be..977118f 100644 --- a/tests/run.mjs +++ b/tests/run.mjs @@ -88,6 +88,7 @@ export function ModuleProvider(moduleName, config) { import { createHmac, timingSafeEqual } from "node:crypto" export const makePayClientCalls = [] +let makePayPaymentLinkCounter = 0 export class MakePayClient { constructor(options) { @@ -97,6 +98,8 @@ export class MakePayClient { async createPaymentLink(payload, options = {}) { makePayClientCalls.push({ type: "createPaymentLink", payload, options, clientOptions: this.options }) + const uid = makePayPaymentLinkCounter === 0 ? "pay_123" : "pay_" + String(123 + makePayPaymentLinkCounter) + makePayPaymentLinkCounter += 1 if (this.options.fetch) { await this.options.fetch(new Request( @@ -120,10 +123,11 @@ export class MakePayClient { return { paymentLink: { - uid: "pay_123", - publicUrl: "https://makepay.io/payment/pay_123", + uid, + publicUrl: "https://makepay.io/payment/" + uid, status: "active", amount: payload.amount, + fiatCurrency: payload.fiatCurrency, metadata: payload.metadata, }, } @@ -342,6 +346,35 @@ async function main() { assert.equal(status.status, expected); } + globalThis.__makepayGetPaymentLinkResponse = undefined; + const updated = await provider.updatePayment({ + amount: "19.99", + context: { + customer: { + email: "buyer@example.com", + id: "cus_123", + }, + idempotency_key: "idem_456", + }, + currency_code: "usd", + data: initiated.data, + }); + assert.equal(updated.data.payment_link_uid, "pay_124"); + assert.equal(updated.data.amount, "19.99"); + assert.equal(updated.data.fiat_currency, "USD"); + assert.deepEqual(updated.data.next_action, { + type: "redirect", + url: "https://makepay.io/payment/pay_124", + }); + assert.ok( + makePayClientCalls.some( + (call) => + call.type === "updatePaymentLink" && + call.uid === "pay_123" && + call.updates.status === "archived", + ), + ); + const archived = await provider.cancelPayment({ data: { payment_link_uid: "pay_cancel", @@ -398,6 +431,33 @@ async function main() { assert.equal(webhook.data.session_id, "ps_123"); assert.equal(webhook.data.amount, "12.34"); + const nestedRawWebhook = JSON.stringify({ + data: { + paymentLink: { + amount: "20.00", + metadata: { + session_id: "ps_nested", + }, + uid: "pay_nested", + }, + session: { + invoiceAmount: "20.00", + status: "complete", + }, + }, + type: "makepay.payment.status_changed", + }); + const nestedWebhook = await provider.getWebhookActionAndData({ + data: JSON.parse(nestedRawWebhook), + headers: { + "x-makepay-signature": signWebhook(nestedRawWebhook, "whsec"), + }, + rawData: nestedRawWebhook, + }); + assert.equal(nestedWebhook.action, "captured"); + assert.equal(nestedWebhook.data.session_id, "ps_nested"); + assert.equal(nestedWebhook.data.amount, "20.00"); + await assert.rejects( () => provider.getWebhookActionAndData({