medusa-paystack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/index.js +22 -0
- package/.medusa/server/src/admin/index.mjs +23 -0
- package/.medusa/server/src/providers/paystack/index.js +6 -0
- package/.medusa/server/src/providers/paystack/lib.js +51 -0
- package/.medusa/server/src/providers/paystack/service.js +160 -0
- package/AGENTS.md +147 -0
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/package.json +52 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const widgetModule = { widgets: [] };
|
|
3
|
+
const routeModule = {
|
|
4
|
+
routes: []
|
|
5
|
+
};
|
|
6
|
+
const menuItemModule = {
|
|
7
|
+
menuItems: []
|
|
8
|
+
};
|
|
9
|
+
const formModule = { customFields: {} };
|
|
10
|
+
const displayModule = {
|
|
11
|
+
displays: {}
|
|
12
|
+
};
|
|
13
|
+
const i18nModule = { resources: {} };
|
|
14
|
+
const plugin = {
|
|
15
|
+
widgetModule,
|
|
16
|
+
routeModule,
|
|
17
|
+
menuItemModule,
|
|
18
|
+
formModule,
|
|
19
|
+
displayModule,
|
|
20
|
+
i18nModule
|
|
21
|
+
};
|
|
22
|
+
module.exports = plugin;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const widgetModule = { widgets: [] };
|
|
2
|
+
const routeModule = {
|
|
3
|
+
routes: []
|
|
4
|
+
};
|
|
5
|
+
const menuItemModule = {
|
|
6
|
+
menuItems: []
|
|
7
|
+
};
|
|
8
|
+
const formModule = { customFields: {} };
|
|
9
|
+
const displayModule = {
|
|
10
|
+
displays: {}
|
|
11
|
+
};
|
|
12
|
+
const i18nModule = { resources: {} };
|
|
13
|
+
const plugin = {
|
|
14
|
+
widgetModule,
|
|
15
|
+
routeModule,
|
|
16
|
+
menuItemModule,
|
|
17
|
+
formModule,
|
|
18
|
+
displayModule,
|
|
19
|
+
i18nModule
|
|
20
|
+
};
|
|
21
|
+
export {
|
|
22
|
+
plugin as default
|
|
23
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
|
|
2
|
+
import PaystackProviderService from "./service";
|
|
3
|
+
export default ModuleProvider(Modules.PAYMENT, {
|
|
4
|
+
services: [PaystackProviderService],
|
|
5
|
+
});
|
|
6
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi8uLi9zcmMvcHJvdmlkZXJzL3BheXN0YWNrL2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxjQUFjLEVBQUUsT0FBTyxFQUFFLE1BQU0sMkJBQTJCLENBQUM7QUFDcEUsT0FBTyx1QkFBdUIsTUFBTSxXQUFXLENBQUM7QUFFaEQsZUFBZSxjQUFjLENBQUMsT0FBTyxDQUFDLE9BQU8sRUFBRTtJQUM3QyxRQUFRLEVBQUUsQ0FBQyx1QkFBdUIsQ0FBQztDQUNwQyxDQUFDLENBQUMifQ==
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
2
|
+
export function verifyPaystackSignature(rawBody, signature, secret) {
|
|
3
|
+
const expected = createHmac("sha512", secret).update(rawBody, "utf8").digest("hex");
|
|
4
|
+
if (expected.length !== signature.length)
|
|
5
|
+
return false;
|
|
6
|
+
try {
|
|
7
|
+
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function webhookAction(eventName, eventData, signatureValid) {
|
|
14
|
+
if (!signatureValid)
|
|
15
|
+
return { action: "not_supported" };
|
|
16
|
+
if (eventName === "charge.success") {
|
|
17
|
+
const sid = eventData.metadata?.session_id;
|
|
18
|
+
if (!sid)
|
|
19
|
+
return { action: "not_supported" };
|
|
20
|
+
return {
|
|
21
|
+
action: "captured",
|
|
22
|
+
data: { session_id: sid, amount: (eventData.amount ?? 0) / 100 },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return { action: "not_supported" };
|
|
26
|
+
}
|
|
27
|
+
export function assertChargeMatches(args) {
|
|
28
|
+
if (args.status !== "success")
|
|
29
|
+
return { ok: false, reason: `status ${args.status}` };
|
|
30
|
+
if (args.currency.toUpperCase() !== args.expectedCurrency.toUpperCase())
|
|
31
|
+
return { ok: false, reason: `currency ${args.currency}` };
|
|
32
|
+
if (args.amount !== args.expectedAmount)
|
|
33
|
+
return { ok: false, reason: `amount ${args.amount} != ${args.expectedAmount}` };
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
36
|
+
export function makeReference(seed, prefix = "") {
|
|
37
|
+
return `${prefix}${seed}`;
|
|
38
|
+
}
|
|
39
|
+
export function buildInitializePayload(args) {
|
|
40
|
+
return {
|
|
41
|
+
email: args.email,
|
|
42
|
+
amount: Math.round(args.amount * 100),
|
|
43
|
+
currency: args.currencyCode.toUpperCase(),
|
|
44
|
+
reference: args.reference,
|
|
45
|
+
metadata: { session_id: args.sessionId },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function buildRefundPayload(reference, amount) {
|
|
49
|
+
return { transaction: reference, amount: Math.round(amount * 100) };
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibGliLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vLi4vc3JjL3Byb3ZpZGVycy9wYXlzdGFjay9saWIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFVBQVUsRUFBRSxlQUFlLEVBQUUsTUFBTSxRQUFRLENBQUM7QUFFckQsTUFBTSxVQUFVLHVCQUF1QixDQUNyQyxPQUFlLEVBQ2YsU0FBaUIsRUFDakIsTUFBYztJQUVkLE1BQU0sUUFBUSxHQUFHLFVBQVUsQ0FBQyxRQUFRLEVBQUUsTUFBTSxDQUFDLENBQUMsTUFBTSxDQUFDLE9BQU8sRUFBRSxNQUFNLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7SUFDcEYsSUFBSSxRQUFRLENBQUMsTUFBTSxLQUFLLFNBQVMsQ0FBQyxNQUFNO1FBQUUsT0FBTyxLQUFLLENBQUM7SUFDdkQsSUFBSSxDQUFDO1FBQ0gsT0FBTyxlQUFlLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsRUFBRSxNQUFNLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUM7SUFDeEUsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLE9BQU8sS0FBSyxDQUFDO0lBQ2YsQ0FBQztBQUNILENBQUM7QUFZRCxNQUFNLFVBQVUsYUFBYSxDQUMzQixTQUE2QixFQUM3QixTQUEyQixFQUMzQixjQUF1QjtJQUV2QixJQUFJLENBQUMsY0FBYztRQUFFLE9BQU8sRUFBRSxNQUFNLEVBQUUsZUFBZSxFQUFFLENBQUM7SUFFeEQsSUFBSSxTQUFTLEtBQUssZ0JBQWdCLEVBQUUsQ0FBQztRQUNuQyxNQUFNLEdBQUcsR0FBRyxTQUFTLENBQUMsUUFBUSxFQUFFLFVBQVUsQ0FBQztRQUMzQyxJQUFJLENBQUMsR0FBRztZQUFFLE9BQU8sRUFBRSxNQUFNLEVBQUUsZUFBZSxFQUFFLENBQUM7UUFDN0MsT0FBTztZQUNMLE1BQU0sRUFBRSxVQUFVO1lBQ2xCLElBQUksRUFBRSxFQUFFLFVBQVUsRUFBRSxHQUFHLEVBQUUsTUFBTSxFQUFFLENBQUMsU0FBUyxDQUFDLE1BQU0sSUFBSSxDQUFDLENBQUMsR0FBRyxHQUFHLEVBQUU7U0FDakUsQ0FBQztJQUNKLENBQUM7SUFFRCxPQUFPLEVBQUUsTUFBTSxFQUFFLGVBQWUsRUFBRSxDQUFDO0FBQ3JDLENBQUM7QUFFRCxNQUFNLFVBQVUsbUJBQW1CLENBQUMsSUFNbkM7SUFDQyxJQUFJLElBQUksQ0FBQyxNQUFNLEtBQUssU0FBUztRQUFFLE9BQU8sRUFBRSxFQUFFLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRSxVQUFVLElBQUksQ0FBQyxNQUFNLEVBQUUsRUFBRSxDQUFDO0lBQ3JGLElBQUksSUFBSSxDQUFDLFFBQVEsQ0FBQyxXQUFXLEVBQUUsS0FBSyxJQUFJLENBQUMsZ0JBQWdCLENBQUMsV0FBVyxFQUFFO1FBQ3JFLE9BQU8sRUFBRSxFQUFFLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRSxZQUFZLElBQUksQ0FBQyxRQUFRLEVBQUUsRUFBRSxDQUFDO0lBQzVELElBQUksSUFBSSxDQUFDLE1BQU0sS0FBSyxJQUFJLENBQUMsY0FBYztRQUNyQyxPQUFPLEVBQUUsRUFBRSxFQUFFLEtBQUssRUFBRSxNQUFNLEVBQUUsVUFBVSxJQUFJLENBQUMsTUFBTSxPQUFPLElBQUksQ0FBQyxjQUFjLEVBQUUsRUFBRSxDQUFDO0lBQ2xGLE9BQU8sRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLENBQUM7QUFDdEIsQ0FBQztBQUVELE1BQU0sVUFBVSxhQUFhLENBQUMsSUFBWSxFQUFFLE1BQU0sR0FBRyxFQUFFO0lBQ3JELE9BQU8sR0FBRyxNQUFNLEdBQUcsSUFBSSxFQUFFLENBQUM7QUFDNUIsQ0FBQztBQUVELE1BQU0sVUFBVSxzQkFBc0IsQ0FBQyxJQU10QztJQU9DLE9BQU87UUFDTCxLQUFLLEVBQUUsSUFBSSxDQUFDLEtBQUs7UUFDakIsTUFBTSxFQUFFLElBQUksQ0FBQyxLQUFLLENBQUMsSUFBSSxDQUFDLE1BQU0sR0FBRyxHQUFHLENBQUM7UUFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxZQUFZLENBQUMsV0FBVyxFQUFFO1FBQ3pDLFNBQVMsRUFBRSxJQUFJLENBQUMsU0FBUztRQUN6QixRQUFRLEVBQUUsRUFBRSxVQUFVLEVBQUUsSUFBSSxDQUFDLFNBQVMsRUFBRTtLQUN6QyxDQUFDO0FBQ0osQ0FBQztBQUVELE1BQU0sVUFBVSxrQkFBa0IsQ0FDaEMsU0FBaUIsRUFDakIsTUFBYztJQUVkLE9BQU8sRUFBRSxXQUFXLEVBQUUsU0FBUyxFQUFFLE1BQU0sRUFBRSxJQUFJLENBQUMsS0FBSyxDQUFDLE1BQU0sR0FBRyxHQUFHLENBQUMsRUFBRSxDQUFDO0FBQ3RFLENBQUMifQ==
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { AbstractPaymentProvider } from "@medusajs/framework/utils";
|
|
2
|
+
import { assertChargeMatches, buildInitializePayload, buildRefundPayload, makeReference, verifyPaystackSignature, webhookAction, } from "./lib";
|
|
3
|
+
const PAYSTACK_API = "https://api.paystack.co";
|
|
4
|
+
class PaystackProviderService extends AbstractPaymentProvider {
|
|
5
|
+
constructor(cradle, options) {
|
|
6
|
+
super(cradle, options);
|
|
7
|
+
this.options_ = options ?? {};
|
|
8
|
+
}
|
|
9
|
+
get secretKey() {
|
|
10
|
+
return this.options_?.secret_key ?? process.env.PAYSTACK_SECRET_KEY ?? "";
|
|
11
|
+
}
|
|
12
|
+
authHeader() {
|
|
13
|
+
return {
|
|
14
|
+
Authorization: `Bearer ${this.secretKey}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
async initiatePayment(input) {
|
|
19
|
+
const { amount, currency_code, context } = input;
|
|
20
|
+
const data = input.data;
|
|
21
|
+
const email = context?.customer?.email ??
|
|
22
|
+
(typeof data?.email === "string" ? data.email : undefined);
|
|
23
|
+
if (!email) {
|
|
24
|
+
throw new Error("Paystack: a customer email is required to initiate payment");
|
|
25
|
+
}
|
|
26
|
+
const seed = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
27
|
+
const reference = makeReference(seed, this.options_.reference_prefix);
|
|
28
|
+
const payload = buildInitializePayload({
|
|
29
|
+
amount: Number(amount),
|
|
30
|
+
currencyCode: currency_code,
|
|
31
|
+
email,
|
|
32
|
+
reference,
|
|
33
|
+
sessionId: data?.session_id,
|
|
34
|
+
});
|
|
35
|
+
const res = await fetch(`${PAYSTACK_API}/transaction/initialize`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: this.authHeader(),
|
|
38
|
+
body: JSON.stringify(payload),
|
|
39
|
+
});
|
|
40
|
+
const json = await res.json();
|
|
41
|
+
if (!json.status || !json.data) {
|
|
42
|
+
throw new Error(`Paystack initiate failed: ${json.message}`);
|
|
43
|
+
}
|
|
44
|
+
const { access_code, authorization_url } = json.data;
|
|
45
|
+
return {
|
|
46
|
+
id: reference,
|
|
47
|
+
data: {
|
|
48
|
+
reference,
|
|
49
|
+
access_code,
|
|
50
|
+
authorization_url,
|
|
51
|
+
amount: Number(amount),
|
|
52
|
+
currency: currency_code.toUpperCase(),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async authorizePayment(input) {
|
|
57
|
+
const data = input.data ?? {};
|
|
58
|
+
const reference = data.reference;
|
|
59
|
+
if (!reference)
|
|
60
|
+
return { status: "error", data };
|
|
61
|
+
const res = await fetch(`${PAYSTACK_API}/transaction/verify/${encodeURIComponent(reference)}`, { headers: this.authHeader() });
|
|
62
|
+
const json = await res.json();
|
|
63
|
+
if (!json.status || !json.data)
|
|
64
|
+
return { status: "error", data };
|
|
65
|
+
const { status, currency, amount } = json.data;
|
|
66
|
+
const expectedAmount = Math.round(Number(data.amount ?? 0) * 100);
|
|
67
|
+
const expectedCurrency = String(data.currency ?? "").toUpperCase();
|
|
68
|
+
const check = assertChargeMatches({
|
|
69
|
+
status,
|
|
70
|
+
currency,
|
|
71
|
+
amount,
|
|
72
|
+
expectedAmount,
|
|
73
|
+
expectedCurrency,
|
|
74
|
+
});
|
|
75
|
+
if (!check.ok) {
|
|
76
|
+
return { status: "error", data: { ...data, ...json.data, reason: check.reason } };
|
|
77
|
+
}
|
|
78
|
+
return { status: "captured", data: { ...data, ...json.data } };
|
|
79
|
+
}
|
|
80
|
+
async capturePayment(input) {
|
|
81
|
+
// Paystack auto-captures on transaction success; this is a no-op.
|
|
82
|
+
return { data: input.data };
|
|
83
|
+
}
|
|
84
|
+
async getPaymentStatus(input) {
|
|
85
|
+
const data = input.data ?? {};
|
|
86
|
+
const reference = data.reference;
|
|
87
|
+
if (!reference)
|
|
88
|
+
return { status: "pending" };
|
|
89
|
+
const res = await fetch(`${PAYSTACK_API}/transaction/verify/${encodeURIComponent(reference)}`, { headers: this.authHeader() });
|
|
90
|
+
const json = await res.json();
|
|
91
|
+
if (!json.status || !json.data)
|
|
92
|
+
return { status: "error" };
|
|
93
|
+
switch (json.data.status) {
|
|
94
|
+
case "success":
|
|
95
|
+
return { status: "captured" };
|
|
96
|
+
case "failed":
|
|
97
|
+
case "reversed":
|
|
98
|
+
return { status: "error" };
|
|
99
|
+
case "abandoned":
|
|
100
|
+
return { status: "canceled" };
|
|
101
|
+
default:
|
|
102
|
+
return { status: "pending" };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async refundPayment(input) {
|
|
106
|
+
const data = input.data ?? {};
|
|
107
|
+
const reference = data.reference;
|
|
108
|
+
const res = await fetch(`${PAYSTACK_API}/refund`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: this.authHeader(),
|
|
111
|
+
body: JSON.stringify(buildRefundPayload(reference, Number(input.amount))),
|
|
112
|
+
});
|
|
113
|
+
const json = (await res.json());
|
|
114
|
+
if (!res.ok || !json.status) {
|
|
115
|
+
throw new Error(`Paystack refund failed: ${json.message ?? `HTTP ${res.status}`}`);
|
|
116
|
+
}
|
|
117
|
+
return { data };
|
|
118
|
+
}
|
|
119
|
+
async cancelPayment(input) {
|
|
120
|
+
// Paystack has no explicit cancel API for pending transactions.
|
|
121
|
+
return { data: input.data };
|
|
122
|
+
}
|
|
123
|
+
async deletePayment(input) {
|
|
124
|
+
return { data: input.data };
|
|
125
|
+
}
|
|
126
|
+
async retrievePayment(input) {
|
|
127
|
+
const data = input.data ?? {};
|
|
128
|
+
const reference = data.reference;
|
|
129
|
+
if (!reference)
|
|
130
|
+
return { data };
|
|
131
|
+
const res = await fetch(`${PAYSTACK_API}/transaction/verify/${encodeURIComponent(reference)}`, { headers: this.authHeader() });
|
|
132
|
+
const json = await res.json();
|
|
133
|
+
if (!json.status || !json.data)
|
|
134
|
+
return { data };
|
|
135
|
+
return { data: { ...data, ...json.data } };
|
|
136
|
+
}
|
|
137
|
+
async updatePayment(input) {
|
|
138
|
+
// Re-initiate to refresh the session when amount/currency changes;
|
|
139
|
+
// forward input.data so session_id is preserved in Paystack metadata.
|
|
140
|
+
const result = await this.initiatePayment({
|
|
141
|
+
amount: input.amount,
|
|
142
|
+
currency_code: input.currency_code,
|
|
143
|
+
context: input.context,
|
|
144
|
+
data: input.data,
|
|
145
|
+
});
|
|
146
|
+
return { data: result.data };
|
|
147
|
+
}
|
|
148
|
+
async getWebhookActionAndData(payload) {
|
|
149
|
+
const { data, rawData, headers } = payload;
|
|
150
|
+
const signature = headers["x-paystack-signature"] ?? "";
|
|
151
|
+
const raw = Buffer.isBuffer(rawData) ? rawData.toString("utf8") : String(rawData);
|
|
152
|
+
const signatureValid = verifyPaystackSignature(raw, signature, this.secretKey);
|
|
153
|
+
const event = data;
|
|
154
|
+
const eventData = (event.data ?? {});
|
|
155
|
+
return webhookAction(event.event, eventData, signatureValid);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
PaystackProviderService.identifier = "paystack";
|
|
159
|
+
export default PaystackProviderService;
|
|
160
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VydmljZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uLy4uL3NyYy9wcm92aWRlcnMvcGF5c3RhY2svc2VydmljZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFzQkEsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sMkJBQTJCLENBQUM7QUFDcEUsT0FBTyxFQUNMLG1CQUFtQixFQUNuQixzQkFBc0IsRUFDdEIsa0JBQWtCLEVBQ2xCLGFBQWEsRUFHYix1QkFBdUIsRUFDdkIsYUFBYSxHQUNkLE1BQU0sT0FBTyxDQUFDO0FBT2YsTUFBTSxZQUFZLEdBQUcseUJBQXlCLENBQUM7QUFFL0MsTUFBTSx1QkFBd0IsU0FBUSx1QkFBd0M7SUFLNUUsWUFBWSxNQUErQixFQUFFLE9BQXdCO1FBQ25FLEtBQUssQ0FBQyxNQUFNLEVBQUUsT0FBTyxDQUFDLENBQUM7UUFDdkIsSUFBSSxDQUFDLFFBQVEsR0FBRyxPQUFPLElBQUksRUFBRSxDQUFDO0lBQ2hDLENBQUM7SUFFRCxJQUFZLFNBQVM7UUFDbkIsT0FBTyxJQUFJLENBQUMsUUFBUSxFQUFFLFVBQVUsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDLG1CQUFtQixJQUFJLEVBQUUsQ0FBQztJQUM1RSxDQUFDO0lBRU8sVUFBVTtRQUNoQixPQUFPO1lBQ0wsYUFBYSxFQUFFLFVBQVUsSUFBSSxDQUFDLFNBQVMsRUFBRTtZQUN6QyxjQUFjLEVBQUUsa0JBQWtCO1NBQ25DLENBQUM7SUFDSixDQUFDO0lBRUQsS0FBSyxDQUFDLGVBQWUsQ0FBQyxLQUEyQjtRQUMvQyxNQUFNLEVBQUUsTUFBTSxFQUFFLGFBQWEsRUFBRSxPQUFPLEVBQUUsR0FBRyxLQUFLLENBQUM7UUFDakQsTUFBTSxJQUFJLEdBQUcsS0FBSyxDQUFDLElBQTJDLENBQUM7UUFFL0QsTUFBTSxLQUFLLEdBQ1QsT0FBTyxFQUFFLFFBQVEsRUFBRSxLQUFLO1lBQ3hCLENBQUMsT0FBTyxJQUFJLEVBQUUsS0FBSyxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsU0FBUyxDQUFDLENBQUM7UUFDN0QsSUFBSSxDQUFDLEtBQUssRUFBRSxDQUFDO1lBQ1gsTUFBTSxJQUFJLEtBQUssQ0FBQyw0REFBNEQsQ0FBQyxDQUFDO1FBQ2hGLENBQUM7UUFFRCxNQUFNLElBQUksR0FBRyxHQUFHLElBQUksQ0FBQyxHQUFHLEVBQUUsSUFBSSxJQUFJLENBQUMsTUFBTSxFQUFFLENBQUMsUUFBUSxDQUFDLEVBQUUsQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO1FBQ3BFLE1BQU0sU0FBUyxHQUFHLGFBQWEsQ0FBQyxJQUFJLEVBQUUsSUFBSSxDQUFDLFFBQVEsQ0FBQyxnQkFBZ0IsQ0FBQyxDQUFDO1FBRXRFLE1BQU0sT0FBTyxHQUFHLHNCQUFzQixDQUFDO1lBQ3JDLE1BQU0sRUFBRSxNQUFNLENBQUMsTUFBTSxDQUFDO1lBQ3RCLFlBQVksRUFBRSxhQUFhO1lBQzNCLEtBQUs7WUFDTCxTQUFTO1lBQ1QsU0FBUyxFQUFFLElBQUksRUFBRSxVQUFnQztTQUNsRCxDQUFDLENBQUM7UUFFSCxNQUFNLEdBQUcsR0FBRyxNQUFNLEtBQUssQ0FBQyxHQUFHLFlBQVkseUJBQXlCLEVBQUU7WUFDaEUsTUFBTSxFQUFFLE1BQU07WUFDZCxPQUFPLEVBQUUsSUFBSSxDQUFDLFVBQVUsRUFBRTtZQUMxQixJQUFJLEVBQUUsSUFBSSxDQUFDLFNBQVMsQ0FBQyxPQUFPLENBQUM7U0FDOUIsQ0FBQyxDQUFDO1FBQ0gsTUFBTSxJQUFJLEdBQXlCLE1BQU0sR0FBRyxDQUFDLElBQUksRUFBRSxDQUFDO1FBRXBELElBQUksQ0FBQyxJQUFJLENBQUMsTUFBTSxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksRUFBRSxDQUFDO1lBQy9CLE1BQU0sSUFBSSxLQUFLLENBQUMsNkJBQTZCLElBQUksQ0FBQyxPQUFPLEVBQUUsQ0FBQyxDQUFDO1FBQy9ELENBQUM7UUFFRCxNQUFNLEVBQUUsV0FBVyxFQUFFLGlCQUFpQixFQUFFLEdBQUcsSUFBSSxDQUFDLElBQUksQ0FBQztRQUNyRCxPQUFPO1lBQ0wsRUFBRSxFQUFFLFNBQVM7WUFDYixJQUFJLEVBQUU7Z0JBQ0osU0FBUztnQkFDVCxXQUFXO2dCQUNYLGlCQUFpQjtnQkFDakIsTUFBTSxFQUFFLE1BQU0sQ0FBQyxNQUFNLENBQUM7Z0JBQ3RCLFFBQVEsRUFBRSxhQUFhLENBQUMsV0FBVyxFQUFFO2FBQ3RDO1NBQ0YsQ0FBQztJQUNKLENBQUM7SUFFRCxLQUFLLENBQUMsZ0JBQWdCLENBQUMsS0FBNEI7UUFDakQsTUFBTSxJQUFJLEdBQUcsS0FBSyxDQUFDLElBQUksSUFBSSxFQUFFLENBQUM7UUFDOUIsTUFBTSxTQUFTLEdBQUcsSUFBSSxDQUFDLFNBQW1CLENBQUM7UUFDM0MsSUFBSSxDQUFDLFNBQVM7WUFBRSxPQUFPLEVBQUUsTUFBTSxFQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsQ0FBQztRQUVqRCxNQUFNLEdBQUcsR0FBRyxNQUFNLEtBQUssQ0FDckIsR0FBRyxZQUFZLHVCQUF1QixrQkFBa0IsQ0FBQyxTQUFTLENBQUMsRUFBRSxFQUNyRSxFQUFFLE9BQU8sRUFBRSxJQUFJLENBQUMsVUFBVSxFQUFFLEVBQUUsQ0FDL0IsQ0FBQztRQUNGLE1BQU0sSUFBSSxHQUEyQixNQUFNLEdBQUcsQ0FBQyxJQUFJLEVBQUUsQ0FBQztRQUN0RCxJQUFJLENBQUMsSUFBSSxDQUFDLE1BQU0sSUFBSSxDQUFDLElBQUksQ0FBQyxJQUFJO1lBQUUsT0FBTyxFQUFFLE1BQU0sRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLENBQUM7UUFFakUsTUFBTSxFQUFFLE1BQU0sRUFBRSxRQUFRLEVBQUUsTUFBTSxFQUFFLEdBQUcsSUFBSSxDQUFDLElBQUksQ0FBQztRQUMvQyxNQUFNLGNBQWMsR0FBRyxJQUFJLENBQUMsS0FBSyxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsTUFBTSxJQUFJLENBQUMsQ0FBQyxHQUFHLEdBQUcsQ0FBQyxDQUFDO1FBQ2xFLE1BQU0sZ0JBQWdCLEdBQUcsTUFBTSxDQUFDLElBQUksQ0FBQyxRQUFRLElBQUksRUFBRSxDQUFDLENBQUMsV0FBVyxFQUFFLENBQUM7UUFFbkUsTUFBTSxLQUFLLEdBQUcsbUJBQW1CLENBQUM7WUFDaEMsTUFBTTtZQUNOLFFBQVE7WUFDUixNQUFNO1lBQ04sY0FBYztZQUNkLGdCQUFnQjtTQUNqQixDQUFDLENBQUM7UUFFSCxJQUFJLENBQUMsS0FBSyxDQUFDLEVBQUUsRUFBRSxDQUFDO1lBQ2QsT0FBTyxFQUFFLE1BQU0sRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLEVBQUUsR0FBRyxJQUFJLEVBQUUsR0FBRyxJQUFJLENBQUMsSUFBSSxFQUFFLE1BQU0sRUFBRSxLQUFLLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQztRQUNwRixDQUFDO1FBQ0QsT0FBTyxFQUFFLE1BQU0sRUFBRSxVQUFVLEVBQUUsSUFBSSxFQUFFLEVBQUUsR0FBRyxJQUFJLEVBQUUsR0FBRyxJQUFJLENBQUMsSUFBSSxFQUFFLEVBQUUsQ0FBQztJQUNqRSxDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWMsQ0FBQyxLQUEwQjtRQUM3QyxrRUFBa0U7UUFDbEUsT0FBTyxFQUFFLElBQUksRUFBRSxLQUFLLENBQUMsSUFBSSxFQUFFLENBQUM7SUFDOUIsQ0FBQztJQUVELEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxLQUE0QjtRQUNqRCxNQUFNLElBQUksR0FBRyxLQUFLLENBQUMsSUFBSSxJQUFJLEVBQUUsQ0FBQztRQUM5QixNQUFNLFNBQVMsR0FBRyxJQUFJLENBQUMsU0FBK0IsQ0FBQztRQUN2RCxJQUFJLENBQUMsU0FBUztZQUFFLE9BQU8sRUFBRSxNQUFNLEVBQUUsU0FBUyxFQUFFLENBQUM7UUFFN0MsTUFBTSxHQUFHLEdBQUcsTUFBTSxLQUFLLENBQ3JCLEdBQUcsWUFBWSx1QkFBdUIsa0JBQWtCLENBQUMsU0FBUyxDQUFDLEVBQUUsRUFDckUsRUFBRSxPQUFPLEVBQUUsSUFBSSxDQUFDLFVBQVUsRUFBRSxFQUFFLENBQy9CLENBQUM7UUFDRixNQUFNLElBQUksR0FBMkIsTUFBTSxHQUFHLENBQUMsSUFBSSxFQUFFLENBQUM7UUFDdEQsSUFBSSxDQUFDLElBQUksQ0FBQyxNQUFNLElBQUksQ0FBQyxJQUFJLENBQUMsSUFBSTtZQUFFLE9BQU8sRUFBRSxNQUFNLEVBQUUsT0FBTyxFQUFFLENBQUM7UUFFM0QsUUFBUSxJQUFJLENBQUMsSUFBSSxDQUFDLE1BQU0sRUFBRSxDQUFDO1lBQ3pCLEtBQUssU0FBUztnQkFDWixPQUFPLEVBQUUsTUFBTSxFQUFFLFVBQVUsRUFBRSxDQUFDO1lBQ2hDLEtBQUssUUFBUSxDQUFDO1lBQ2QsS0FBSyxVQUFVO2dCQUNiLE9BQU8sRUFBRSxNQUFNLEVBQUUsT0FBTyxFQUFFLENBQUM7WUFDN0IsS0FBSyxXQUFXO2dCQUNkLE9BQU8sRUFBRSxNQUFNLEVBQUUsVUFBVSxFQUFFLENBQUM7WUFDaEM7Z0JBQ0UsT0FBTyxFQUFFLE1BQU0sRUFBRSxTQUFTLEVBQUUsQ0FBQztRQUNqQyxDQUFDO0lBQ0gsQ0FBQztJQUVELEtBQUssQ0FBQyxhQUFhLENBQUMsS0FBeUI7UUFDM0MsTUFBTSxJQUFJLEdBQUcsS0FBSyxDQUFDLElBQUksSUFBSSxFQUFFLENBQUM7UUFDOUIsTUFBTSxTQUFTLEdBQUcsSUFBSSxDQUFDLFNBQW1CLENBQUM7UUFFM0MsTUFBTSxHQUFHLEdBQUcsTUFBTSxLQUFLLENBQUMsR0FBRyxZQUFZLFNBQVMsRUFBRTtZQUNoRCxNQUFNLEVBQUUsTUFBTTtZQUNkLE9BQU8sRUFBRSxJQUFJLENBQUMsVUFBVSxFQUFFO1lBQzFCLElBQUksRUFBRSxJQUFJLENBQUMsU0FBUyxDQUFDLGtCQUFrQixDQUFDLFNBQVMsRUFBRSxNQUFNLENBQUMsS0FBSyxDQUFDLE1BQU0sQ0FBQyxDQUFDLENBQUM7U0FDMUUsQ0FBQyxDQUFDO1FBQ0gsTUFBTSxJQUFJLEdBQUcsQ0FBQyxNQUFNLEdBQUcsQ0FBQyxJQUFJLEVBQUUsQ0FBMEMsQ0FBQztRQUV6RSxJQUFJLENBQUMsR0FBRyxDQUFDLEVBQUUsSUFBSSxDQUFDLElBQUksQ0FBQyxNQUFNLEVBQUUsQ0FBQztZQUM1QixNQUFNLElBQUksS0FBSyxDQUNiLDJCQUEyQixJQUFJLENBQUMsT0FBTyxJQUFJLFFBQVEsR0FBRyxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQ2xFLENBQUM7UUFDSixDQUFDO1FBQ0QsT0FBTyxFQUFFLElBQUksRUFBRSxDQUFDO0lBQ2xCLENBQUM7SUFFRCxLQUFLLENBQUMsYUFBYSxDQUFDLEtBQXlCO1FBQzNDLGdFQUFnRTtRQUNoRSxPQUFPLEVBQUUsSUFBSSxFQUFFLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQztJQUM5QixDQUFDO0lBRUQsS0FBSyxDQUFDLGFBQWEsQ0FBQyxLQUF5QjtRQUMzQyxPQUFPLEVBQUUsSUFBSSxFQUFFLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQztJQUM5QixDQUFDO0lBRUQsS0FBSyxDQUFDLGVBQWUsQ0FBQyxLQUEyQjtRQUMvQyxNQUFNLElBQUksR0FBRyxLQUFLLENBQUMsSUFBSSxJQUFJLEVBQUUsQ0FBQztRQUM5QixNQUFNLFNBQVMsR0FBRyxJQUFJLENBQUMsU0FBK0IsQ0FBQztRQUN2RCxJQUFJLENBQUMsU0FBUztZQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsQ0FBQztRQUVoQyxNQUFNLEdBQUcsR0FBRyxNQUFNLEtBQUssQ0FDckIsR0FBRyxZQUFZLHVCQUF1QixrQkFBa0IsQ0FBQyxTQUFTLENBQUMsRUFBRSxFQUNyRSxFQUFFLE9BQU8sRUFBRSxJQUFJLENBQUMsVUFBVSxFQUFFLEVBQUUsQ0FDL0IsQ0FBQztRQUNGLE1BQU0sSUFBSSxHQUEyQixNQUFNLEdBQUcsQ0FBQyxJQUFJLEVBQUUsQ0FBQztRQUN0RCxJQUFJLENBQUMsSUFBSSxDQUFDLE1BQU0sSUFBSSxDQUFDLElBQUksQ0FBQyxJQUFJO1lBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxDQUFDO1FBQ2hELE9BQU8sRUFBRSxJQUFJLEVBQUUsRUFBRSxHQUFHLElBQUksRUFBRSxHQUFHLElBQUksQ0FBQyxJQUFJLEVBQUUsRUFBRSxDQUFDO0lBQzdDLENBQUM7SUFFRCxLQUFLLENBQUMsYUFBYSxDQUFDLEtBQXlCO1FBQzNDLG1FQUFtRTtRQUNuRSxzRUFBc0U7UUFDdEUsTUFBTSxNQUFNLEdBQUcsTUFBTSxJQUFJLENBQUMsZUFBZSxDQUFDO1lBQ3hDLE1BQU0sRUFBRSxLQUFLLENBQUMsTUFBTTtZQUNwQixhQUFhLEVBQUUsS0FBSyxDQUFDLGFBQWE7WUFDbEMsT0FBTyxFQUFFLEtBQUssQ0FBQyxPQUFPO1lBQ3RCLElBQUksRUFBRSxLQUFLLENBQUMsSUFBSTtTQUNqQixDQUFDLENBQUM7UUFDSCxPQUFPLEVBQUUsSUFBSSxFQUFFLE1BQU0sQ0FBQyxJQUFJLEVBQUUsQ0FBQztJQUMvQixDQUFDO0lBRUQsS0FBSyxDQUFDLHVCQUF1QixDQUMzQixPQUEwQztRQUUxQyxNQUFNLEVBQUUsSUFBSSxFQUFFLE9BQU8sRUFBRSxPQUFPLEVBQUUsR0FBRyxPQUFPLENBQUM7UUFDM0MsTUFBTSxTQUFTLEdBQUksT0FBTyxDQUFDLHNCQUFzQixDQUFZLElBQUksRUFBRSxDQUFDO1FBQ3BFLE1BQU0sR0FBRyxHQUFHLE1BQU0sQ0FBQyxRQUFRLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxRQUFRLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxPQUFPLENBQUMsQ0FBQztRQUNsRixNQUFNLGNBQWMsR0FBRyx1QkFBdUIsQ0FBQyxHQUFHLEVBQUUsU0FBUyxFQUFFLElBQUksQ0FBQyxTQUFTLENBQUMsQ0FBQztRQUUvRSxNQUFNLEtBQUssR0FBRyxJQUEwRCxDQUFDO1FBQ3pFLE1BQU0sU0FBUyxHQUFHLENBQUMsS0FBSyxDQUFDLElBQUksSUFBSSxFQUFFLENBSWxDLENBQUM7UUFFRixPQUFPLGFBQWEsQ0FBQyxLQUFLLENBQUMsS0FBSyxFQUFFLFNBQVMsRUFBRSxjQUFjLENBQUMsQ0FBQztJQUMvRCxDQUFDOztBQXBNTSxrQ0FBVSxHQUFHLFVBQVUsQ0FBQztBQXVNakMsZUFBZSx1QkFBdUIsQ0FBQyJ9
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# AGENTS.md — integrating `medusa-paystack`
|
|
2
|
+
|
|
3
|
+
Audience: an AI coding agent (Claude Code, Cursor, etc.) adding Paystack payments to
|
|
4
|
+
a user's **Medusa v2** store. Treat this as a checklist. Exact values matter — copy
|
|
5
|
+
them verbatim.
|
|
6
|
+
|
|
7
|
+
## What this package is
|
|
8
|
+
|
|
9
|
+
- A **Paystack payment provider for Medusa v2** (targets `@medusajs/medusa` **2.16.x**).
|
|
10
|
+
- It is a **payment module provider**, resolved at `medusa-paystack/providers/paystack`.
|
|
11
|
+
Provider `identifier` = `paystack`.
|
|
12
|
+
- It charges in the **cart's own `currency_code`** — no FX/conversion. The merchant's
|
|
13
|
+
Paystack account must be enabled for those currencies (Paystack supports GHS, NGN,
|
|
14
|
+
ZAR, USD, KES).
|
|
15
|
+
|
|
16
|
+
## The one thing agents get wrong
|
|
17
|
+
|
|
18
|
+
This plugin is the **server side only**. It renders **no checkout UI** — no Medusa
|
|
19
|
+
payment provider does (the official Stripe plugin is the same). If you only register
|
|
20
|
+
the provider, the customer **cannot pay**. You MUST also implement the storefront
|
|
21
|
+
step (Section 4), or checkout is broken. Do not report the task complete after
|
|
22
|
+
Section 2.
|
|
23
|
+
|
|
24
|
+
## Gather first
|
|
25
|
+
|
|
26
|
+
- `PAYSTACK_SECRET_KEY` (backend) and the Paystack **public** key (storefront).
|
|
27
|
+
Test keys look like `sk_test_…` / `pk_test_…`.
|
|
28
|
+
- Confirm the backend is on Medusa **2.16.x**: `npm ls @medusajs/medusa`.
|
|
29
|
+
- Identify the storefront app (Next.js Medusa starter, custom, etc.) and whether it
|
|
30
|
+
uses `@medusajs/js-sdk`.
|
|
31
|
+
|
|
32
|
+
## 1. Install (in the Medusa backend)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install medusa-paystack
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 2. Register the provider — `medusa-config.ts`
|
|
39
|
+
|
|
40
|
+
Add it to the **payment module's `providers` array**. Do NOT put it in the top-level
|
|
41
|
+
`plugins` array, and do NOT register it as a standalone module.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
{
|
|
45
|
+
resolve: "@medusajs/medusa/payment",
|
|
46
|
+
options: {
|
|
47
|
+
providers: [
|
|
48
|
+
{
|
|
49
|
+
resolve: "medusa-paystack/providers/paystack",
|
|
50
|
+
id: "paystack",
|
|
51
|
+
options: {
|
|
52
|
+
secret_key: process.env.PAYSTACK_SECRET_KEY,
|
|
53
|
+
// reference_prefix: "MY-", // optional, default ""
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
If a `@medusajs/medusa/payment` block already exists, **append** to its `providers`
|
|
62
|
+
array — do not create a second payment module.
|
|
63
|
+
|
|
64
|
+
The provider's runtime id becomes `pp_<identifier>_<id>` = **`pp_paystack_paystack`**.
|
|
65
|
+
The storefront and webhook depend on this exact string. If you choose a different
|
|
66
|
+
`id`, update Sections 4 and 5 to match (`pp_paystack_<id>` and
|
|
67
|
+
`/hooks/payment/paystack_<id>`).
|
|
68
|
+
|
|
69
|
+
## 3. Environment (backend `.env`)
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
PAYSTACK_SECRET_KEY=sk_test_xxx
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 4. Storefront — REQUIRED, do not skip
|
|
76
|
+
|
|
77
|
+
Without this, the provider is registered but no payment can be taken.
|
|
78
|
+
|
|
79
|
+
- Install the popup lib in the **storefront** app: `npm install @paystack/inline-js`.
|
|
80
|
+
- Expose the **public** key to the browser, e.g.
|
|
81
|
+
`NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_xxx`.
|
|
82
|
+
- Implement a pay button with this flow (using `@medusajs/js-sdk`):
|
|
83
|
+
1. `sdk.store.payment.initiatePaymentSession(cart, { provider_id: "pp_paystack_paystack", data: { email } })`
|
|
84
|
+
— **email is required**; Paystack rejects a session without one.
|
|
85
|
+
2. Read `access_code` from the session whose `provider_id === "pp_paystack_paystack"`.
|
|
86
|
+
3. `new PaystackPop().resumeTransaction(access_code, { onSuccess, onCancel, onError })`
|
|
87
|
+
(from `@paystack/inline-js`). Alternatively redirect the browser to the
|
|
88
|
+
session's `authorization_url`.
|
|
89
|
+
4. On success: `await sdk.store.cart.complete(cartId)` → creates the order.
|
|
90
|
+
|
|
91
|
+
Copy the full, working component from this package's **README → "Storefront
|
|
92
|
+
integration"** and adapt the env-var names to the project. The README also documents
|
|
93
|
+
the redirect-based alternative.
|
|
94
|
+
|
|
95
|
+
## 5. Webhook
|
|
96
|
+
|
|
97
|
+
In the Paystack dashboard, set the webhook URL to:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
https://<backend-host>/hooks/payment/paystack_paystack
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The path segment is `<identifier>_<id>` = `paystack_paystack`. The provider verifies
|
|
104
|
+
`x-paystack-signature` (HMAC-SHA512 over the raw body with the secret key) and
|
|
105
|
+
captures the session on `charge.success`.
|
|
106
|
+
|
|
107
|
+
## 6. Enable for regions
|
|
108
|
+
|
|
109
|
+
Add **Paystack** as a payment provider to each region that should accept it: Admin →
|
|
110
|
+
**Settings → Regions**, or via the Admin API.
|
|
111
|
+
|
|
112
|
+
## 7. Verify before reporting done
|
|
113
|
+
|
|
114
|
+
- Backend boots with no errors (`npx medusa develop` or the project's dev command).
|
|
115
|
+
- Admin shows Paystack as an available provider on a region.
|
|
116
|
+
- A test checkout with Paystack **test** keys: pay button → Paystack popup → success
|
|
117
|
+
→ order created in Admin.
|
|
118
|
+
- Webhook: replay a `charge.success` from the Paystack dashboard and confirm the
|
|
119
|
+
payment session captures.
|
|
120
|
+
|
|
121
|
+
## Do / Don't
|
|
122
|
+
|
|
123
|
+
- DO pass the customer email into `initiatePaymentSession` `data`.
|
|
124
|
+
- DO use the exact `provider_id` `pp_paystack_paystack` in the storefront and the
|
|
125
|
+
matching `/hooks/payment/paystack_paystack` webhook path.
|
|
126
|
+
- DON'T add `medusa-paystack` to the top-level `plugins` array — it's a payment
|
|
127
|
+
provider, registered under the payment module's `providers`.
|
|
128
|
+
- DON'T pre-convert amounts to subunits — Medusa passes **major-unit** amounts and
|
|
129
|
+
the provider multiplies by 100 internally.
|
|
130
|
+
- DON'T hardcode a currency — the provider uses the cart's `currency_code`.
|
|
131
|
+
- DON'T treat the client `onSuccess` as proof of payment — the server re-verifies
|
|
132
|
+
status, amount, and currency in `authorizePayment` during `cart.complete`.
|
|
133
|
+
|
|
134
|
+
## Options reference
|
|
135
|
+
|
|
136
|
+
| Option | Required | Default | Notes |
|
|
137
|
+
| --- | --- | --- | --- |
|
|
138
|
+
| `secret_key` | yes | `process.env.PAYSTACK_SECRET_KEY` | server API calls + webhook signature verification |
|
|
139
|
+
| `reference_prefix` | no | `""` | prepended to generated transaction references |
|
|
140
|
+
|
|
141
|
+
## Provider methods (reference)
|
|
142
|
+
|
|
143
|
+
`initiatePayment` (creates the Paystack transaction), `authorizePayment` (verifies
|
|
144
|
+
status/amount/currency), `capturePayment` (no-op — Paystack auto-captures on
|
|
145
|
+
success), `getPaymentStatus`, `refundPayment`, `cancelPayment`/`deletePayment`
|
|
146
|
+
(no-ops), `updatePayment` (re-initiates the session), `getWebhookActionAndData`
|
|
147
|
+
(signature check + `charge.success` → capture).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nuette
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# medusa-paystack
|
|
2
|
+
|
|
3
|
+
A [Paystack](https://paystack.com) payment provider for **Medusa v2**.
|
|
4
|
+
|
|
5
|
+
Unlike store-specific forks, this provider charges in the **cart's own currency**
|
|
6
|
+
(`currency_code`). Paystack natively supports GHS, NGN, ZAR, USD, and KES — make
|
|
7
|
+
sure your Paystack account is enabled for the currencies your store sells in. This
|
|
8
|
+
plugin does **not** perform any currency conversion.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Medusa **2.16.x**
|
|
13
|
+
- A Paystack secret key
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install medusa-paystack
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configure
|
|
22
|
+
|
|
23
|
+
Add the provider to the Payment Module in your `medusa-config.ts`:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
module.exports = defineConfig({
|
|
27
|
+
modules: [
|
|
28
|
+
{
|
|
29
|
+
resolve: "@medusajs/medusa/payment",
|
|
30
|
+
options: {
|
|
31
|
+
providers: [
|
|
32
|
+
{
|
|
33
|
+
resolve: "medusa-paystack/providers/paystack",
|
|
34
|
+
id: "paystack",
|
|
35
|
+
options: {
|
|
36
|
+
secret_key: process.env.PAYSTACK_SECRET_KEY,
|
|
37
|
+
// reference_prefix: "MY-", // optional, default ""
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Options
|
|
48
|
+
|
|
49
|
+
| Option | Required | Default | Description |
|
|
50
|
+
| --- | --- | --- | --- |
|
|
51
|
+
| `secret_key` | yes | `process.env.PAYSTACK_SECRET_KEY` | Paystack secret key, used for API calls and webhook signature verification. |
|
|
52
|
+
| `reference_prefix` | no | `""` | Prefix prepended to generated transaction references. |
|
|
53
|
+
|
|
54
|
+
## Enable in the admin
|
|
55
|
+
|
|
56
|
+
In **Settings → Regions**, add **Paystack** as a payment provider for each region
|
|
57
|
+
that should accept it.
|
|
58
|
+
|
|
59
|
+
## Storefront integration
|
|
60
|
+
|
|
61
|
+
This plugin is the **server-side** half of the payment flow. Like every Medusa
|
|
62
|
+
payment provider (including the official Stripe plugin), it renders **no checkout
|
|
63
|
+
UI** — your storefront drives the payment, then completes the cart. The plugin's
|
|
64
|
+
job is to start the Paystack transaction and then securely verify the status,
|
|
65
|
+
amount, currency, and webhook signature on the server. A storefront cannot accept
|
|
66
|
+
payment with the plugin alone; you must add the steps below.
|
|
67
|
+
|
|
68
|
+
The flow your storefront implements:
|
|
69
|
+
|
|
70
|
+
1. **Initialize a payment session** on the cart, selecting this provider. The
|
|
71
|
+
provider returns Paystack's `access_code` (and `authorization_url`) in the
|
|
72
|
+
session's `data`.
|
|
73
|
+
2. **Collect payment** with Paystack — open Paystack Inline with the `access_code`,
|
|
74
|
+
or redirect the browser to `authorization_url`.
|
|
75
|
+
3. **Complete the cart** on success — this triggers the provider's server-side
|
|
76
|
+
verification and creates the order.
|
|
77
|
+
|
|
78
|
+
### Prerequisites
|
|
79
|
+
|
|
80
|
+
- Your **Paystack public key** exposed to the browser (e.g.
|
|
81
|
+
`NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY`). The plugin uses the *secret* key on the
|
|
82
|
+
server; the storefront only needs the *public* key.
|
|
83
|
+
- The Medusa JS SDK (`@medusajs/js-sdk`) with your publishable API key.
|
|
84
|
+
- For the inline popup: `npm install @paystack/inline-js`.
|
|
85
|
+
|
|
86
|
+
> **`provider_id`** is `pp_<identifier>_<id>` — `pp_paystack_` plus the `id` you set
|
|
87
|
+
> in `medusa-config.ts`. With `id: "paystack"` it is `pp_paystack_paystack`.
|
|
88
|
+
|
|
89
|
+
### Example (React / Next.js)
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
"use client";
|
|
93
|
+
|
|
94
|
+
import { useState } from "react";
|
|
95
|
+
import Medusa from "@medusajs/js-sdk";
|
|
96
|
+
|
|
97
|
+
const sdk = new Medusa({
|
|
98
|
+
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
|
|
99
|
+
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const PROVIDER_ID = "pp_paystack_paystack"; // must match pp_<identifier>_<id>
|
|
103
|
+
|
|
104
|
+
export function PayWithPaystack({ cartId, email }: { cartId: string; email: string }) {
|
|
105
|
+
const [error, setError] = useState<string>();
|
|
106
|
+
const [loading, setLoading] = useState(false);
|
|
107
|
+
|
|
108
|
+
async function pay() {
|
|
109
|
+
setLoading(true);
|
|
110
|
+
setError(undefined);
|
|
111
|
+
try {
|
|
112
|
+
// 1. Initialize the Paystack payment session on the cart.
|
|
113
|
+
// Paystack requires a customer email — pass it in `data`.
|
|
114
|
+
const { cart } = await sdk.store.cart.retrieve(cartId);
|
|
115
|
+
const { payment_collection } = await sdk.store.payment.initiatePaymentSession(
|
|
116
|
+
cart,
|
|
117
|
+
{ provider_id: PROVIDER_ID, data: { email } }
|
|
118
|
+
);
|
|
119
|
+
const session = payment_collection.payment_sessions?.find(
|
|
120
|
+
(s) => s.provider_id === PROVIDER_ID
|
|
121
|
+
);
|
|
122
|
+
const accessCode = session?.data?.access_code as string | undefined;
|
|
123
|
+
if (!accessCode) throw new Error("No access_code returned from the payment session");
|
|
124
|
+
|
|
125
|
+
// 2. Open Paystack Inline with the access code.
|
|
126
|
+
const mod = await import("@paystack/inline-js");
|
|
127
|
+
const PaystackPop = (mod.default ?? mod) as new () => {
|
|
128
|
+
resumeTransaction(
|
|
129
|
+
accessCode: string,
|
|
130
|
+
cb: {
|
|
131
|
+
onSuccess?: () => void;
|
|
132
|
+
onCancel?: () => void;
|
|
133
|
+
onError?: (e: { message: string }) => void;
|
|
134
|
+
}
|
|
135
|
+
): void;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
new PaystackPop().resumeTransaction(accessCode, {
|
|
139
|
+
onSuccess: async () => {
|
|
140
|
+
// 3. Complete the cart -> runs the provider's server-side verification
|
|
141
|
+
// (status + amount + currency) and creates the order.
|
|
142
|
+
const res = await sdk.store.cart.complete(cartId);
|
|
143
|
+
if (res.type === "order") {
|
|
144
|
+
window.location.href = `/order/confirmed/${res.order.id}`;
|
|
145
|
+
} else {
|
|
146
|
+
setError(res.error?.message ?? "Order could not be completed.");
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
onCancel: () => setError("Payment cancelled — your cart is still saved."),
|
|
150
|
+
onError: (e) => setError(e.message),
|
|
151
|
+
});
|
|
152
|
+
} catch (e) {
|
|
153
|
+
setError(e instanceof Error ? e.message : "Could not start payment.");
|
|
154
|
+
setLoading(false);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div>
|
|
160
|
+
{error && <p role="alert">{error}</p>}
|
|
161
|
+
<button onClick={pay} disabled={loading}>
|
|
162
|
+
{loading ? "Starting…" : "Pay with Paystack"}
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The same three SDK calls work in any frontend framework — only the
|
|
170
|
+
`@paystack/inline-js` popup call is Paystack-specific.
|
|
171
|
+
|
|
172
|
+
### Redirect instead of inline
|
|
173
|
+
|
|
174
|
+
If you prefer a full-page redirect over the popup, skip `@paystack/inline-js`:
|
|
175
|
+
read `authorization_url` from the session `data`, send the browser there, and on
|
|
176
|
+
return to your callback page call `sdk.store.cart.complete(cartId)`.
|
|
177
|
+
|
|
178
|
+
> The server is always authoritative: `cart.complete` runs the provider's
|
|
179
|
+
> `authorizePayment`, which re-checks the transaction status, amount, and currency
|
|
180
|
+
> with Paystack before the order is created. Never treat the client `onSuccess` as
|
|
181
|
+
> proof of payment on its own.
|
|
182
|
+
|
|
183
|
+
## Webhook
|
|
184
|
+
|
|
185
|
+
Create a webhook in your Paystack dashboard pointing at your Medusa server's payment
|
|
186
|
+
webhook endpoint for this provider:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
https://your-backend.com/hooks/payment/paystack_paystack
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The path segment is `<identifier>_<id>` — the provider's identifier (`paystack`)
|
|
193
|
+
joined with the `id` from your `medusa-config.ts`. With `id: "paystack"` the segment
|
|
194
|
+
is `paystack_paystack`; had you registered the provider with `id: "main"`, the path
|
|
195
|
+
would be `/hooks/payment/paystack_main`.
|
|
196
|
+
|
|
197
|
+
The provider verifies the `x-paystack-signature` (HMAC-SHA512 over the raw body using
|
|
198
|
+
your secret key) and captures the payment session on `charge.success`.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "medusa-paystack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Paystack payment provider for Medusa v2",
|
|
5
|
+
"author": "nuette",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"medusa-plugin",
|
|
9
|
+
"medusa-plugin-payment",
|
|
10
|
+
"medusa-v2",
|
|
11
|
+
"paystack",
|
|
12
|
+
"payment"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
".medusa/server",
|
|
16
|
+
"package.json",
|
|
17
|
+
"README.md",
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "medusa plugin:build",
|
|
23
|
+
"dev": "medusa plugin:develop",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "jest",
|
|
26
|
+
"prepublishOnly": "medusa plugin:build"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./package.json": "./package.json",
|
|
30
|
+
"./providers/*": "./.medusa/server/src/providers/*/index.js",
|
|
31
|
+
"./*": "./.medusa/server/src/*.js"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@medusajs/admin-sdk": "2.16.0",
|
|
35
|
+
"@medusajs/cli": "2.16.0",
|
|
36
|
+
"@medusajs/framework": "2.16.0",
|
|
37
|
+
"@medusajs/medusa": "2.16.0",
|
|
38
|
+
"@medusajs/test-utils": "2.16.0",
|
|
39
|
+
"@swc/core": "1.5.7",
|
|
40
|
+
"@swc/jest": "^0.2.36",
|
|
41
|
+
"@types/jest": "^29.5.12",
|
|
42
|
+
"@types/node": "^20.11.0",
|
|
43
|
+
"jest": "^29.7.0",
|
|
44
|
+
"typescript": "^5.6.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@medusajs/framework": "2.16.0"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
}
|
|
52
|
+
}
|