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
[](https://www.npmjs.com/package/@makecrypto/medusa-plugin-makepay)
-[](https://github.com/makecryptoio/medusa-plugin-makepay/actions/workflows/ci.yml)
+[](https://github.com/makepay-io/medusa-plugin-makepay/actions/workflows/ci.yml)
@@ -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({