openpay-ng 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/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +78 -0
- package/dist/connectors/bachs.d.ts +33 -0
- package/dist/connectors/bachs.js +172 -0
- package/dist/connectors/flutterwave.d.ts +29 -0
- package/dist/connectors/flutterwave.js +234 -0
- package/dist/connectors/http.d.ts +18 -0
- package/dist/connectors/http.js +59 -0
- package/dist/connectors/paystack.d.ts +28 -0
- package/dist/connectors/paystack.js +238 -0
- package/dist/connectors/service.d.ts +12 -0
- package/dist/connectors/service.js +107 -0
- package/dist/connectors/types.d.ts +29 -0
- package/dist/connectors/types.js +1 -0
- package/dist/core/errors.d.ts +41 -0
- package/dist/core/errors.js +55 -0
- package/dist/core/idempotency.d.ts +4 -0
- package/dist/core/idempotency.js +11 -0
- package/dist/core/types.d.ts +127 -0
- package/dist/core/types.js +10 -0
- package/dist/core/validation.d.ts +135 -0
- package/dist/core/validation.js +61 -0
- package/dist/db/client.d.ts +6 -0
- package/dist/db/client.js +18 -0
- package/dist/db/schema.d.ts +754 -0
- package/dist/db/schema.js +62 -0
- package/dist/db/store.d.ts +60 -0
- package/dist/db/store.js +159 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/sdk.d.ts +28 -0
- package/dist/sdk.js +59 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +137 -0
- package/dist/webhooks/service.d.ts +18 -0
- package/dist/webhooks/service.js +60 -0
- package/package.json +55 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { ConfigurationError, ValidationError } from "../core/errors.js";
|
|
2
|
+
import { newId } from "../core/idempotency.js";
|
|
3
|
+
import { normalizeAmount } from "../core/validation.js";
|
|
4
|
+
import { firstHeader, providerJson } from "./http.js";
|
|
5
|
+
const DEFAULT_BASE = "https://api.flutterwave.com/v3";
|
|
6
|
+
export function mapFlutterwaveStatus(s) {
|
|
7
|
+
switch (s.toLowerCase()) {
|
|
8
|
+
case "successful":
|
|
9
|
+
case "success":
|
|
10
|
+
case "completed":
|
|
11
|
+
return "successful";
|
|
12
|
+
case "failed":
|
|
13
|
+
case "cancelled":
|
|
14
|
+
return "failed";
|
|
15
|
+
case "pending":
|
|
16
|
+
case "processing":
|
|
17
|
+
case "new":
|
|
18
|
+
return "pending";
|
|
19
|
+
default:
|
|
20
|
+
return "unknown";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class FlutterwaveConnector {
|
|
24
|
+
name = "flutterwave";
|
|
25
|
+
capabilities = {
|
|
26
|
+
paymentsCreate: true,
|
|
27
|
+
paymentsVerify: true,
|
|
28
|
+
refunds: true,
|
|
29
|
+
transfers: true,
|
|
30
|
+
webhooks: true,
|
|
31
|
+
};
|
|
32
|
+
secretKey;
|
|
33
|
+
baseUrl;
|
|
34
|
+
timeoutMs;
|
|
35
|
+
http;
|
|
36
|
+
webhookSecret;
|
|
37
|
+
constructor(cfg) {
|
|
38
|
+
if (!cfg.secretKey)
|
|
39
|
+
throw new ConfigurationError("Flutterwave secret key is required");
|
|
40
|
+
this.secretKey = cfg.secretKey;
|
|
41
|
+
this.baseUrl = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
42
|
+
this.timeoutMs = cfg.timeoutMs ?? 15_000;
|
|
43
|
+
this.http = cfg.http;
|
|
44
|
+
this.webhookSecret = cfg.webhookSecretHash ?? cfg.secretKey;
|
|
45
|
+
}
|
|
46
|
+
headers() {
|
|
47
|
+
return { Authorization: `Bearer ${this.secretKey}` };
|
|
48
|
+
}
|
|
49
|
+
async createPayment(input) {
|
|
50
|
+
// Provider evidence (sandbox 400: "Redirect URL is required"): Flutterwave
|
|
51
|
+
// mandates redirect_url on POST /v3/payments. Fail fast with a clear SDK
|
|
52
|
+
// error instead of an opaque provider rejection.
|
|
53
|
+
if (!input.redirectUrl) {
|
|
54
|
+
throw new ValidationError("Flutterwave requires redirectUrl for payments.create()");
|
|
55
|
+
}
|
|
56
|
+
const amount = normalizeAmount(input.amount);
|
|
57
|
+
const data = await providerJson(`${this.baseUrl}/payments`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: this.headers(),
|
|
60
|
+
body: {
|
|
61
|
+
tx_ref: input.reference,
|
|
62
|
+
amount,
|
|
63
|
+
currency: input.currency,
|
|
64
|
+
customer: { email: input.customer.email, name: input.customer.name, phonenumber: input.customer.phone },
|
|
65
|
+
redirect_url: input.redirectUrl,
|
|
66
|
+
meta: input.metadata,
|
|
67
|
+
},
|
|
68
|
+
timeoutMs: this.timeoutMs,
|
|
69
|
+
provider: "flutterwave",
|
|
70
|
+
reference: input.reference,
|
|
71
|
+
http: this.http,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
id: newId(),
|
|
75
|
+
provider: "flutterwave",
|
|
76
|
+
reference: input.reference,
|
|
77
|
+
providerRef: data.data.flw_ref ?? input.reference,
|
|
78
|
+
amount,
|
|
79
|
+
currency: input.currency,
|
|
80
|
+
status: "pending",
|
|
81
|
+
checkoutUrl: data.data.link,
|
|
82
|
+
customerEmail: input.customer.email,
|
|
83
|
+
raw: data,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async verifyPayment(input) {
|
|
87
|
+
// Prefer verify-by-reference so callers can use the merchant tx_ref.
|
|
88
|
+
const data = await providerJson(`${this.baseUrl}/transactions/verify_by_reference?tx_ref=${encodeURIComponent(input.reference)}`, {
|
|
89
|
+
headers: this.headers(),
|
|
90
|
+
timeoutMs: this.timeoutMs,
|
|
91
|
+
provider: "flutterwave",
|
|
92
|
+
reference: input.reference,
|
|
93
|
+
http: this.http,
|
|
94
|
+
});
|
|
95
|
+
const d = data.data;
|
|
96
|
+
return {
|
|
97
|
+
id: newId(),
|
|
98
|
+
provider: "flutterwave",
|
|
99
|
+
reference: d.tx_ref,
|
|
100
|
+
providerRef: String(d.id),
|
|
101
|
+
amount: Number(d.amount).toFixed(2),
|
|
102
|
+
currency: d.currency,
|
|
103
|
+
status: mapFlutterwaveStatus(d.status),
|
|
104
|
+
customerEmail: d.customer?.email,
|
|
105
|
+
raw: data,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async createRefund(input) {
|
|
109
|
+
// Flutterwave refunds address the numeric transaction id.
|
|
110
|
+
// verifyPayment() stores it in providerRef; callers may also pass it directly.
|
|
111
|
+
const data = await providerJson(`${this.baseUrl}/transactions/${encodeURIComponent(input.paymentReference)}/refund`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: this.headers(),
|
|
114
|
+
body: {
|
|
115
|
+
amount: input.amount ? normalizeAmount(input.amount) : undefined,
|
|
116
|
+
comments: input.reason,
|
|
117
|
+
},
|
|
118
|
+
timeoutMs: this.timeoutMs,
|
|
119
|
+
provider: "flutterwave",
|
|
120
|
+
reference: input.paymentReference,
|
|
121
|
+
http: this.http,
|
|
122
|
+
});
|
|
123
|
+
const s = String(data.data.status).toLowerCase();
|
|
124
|
+
return {
|
|
125
|
+
id: newId(),
|
|
126
|
+
provider: "flutterwave",
|
|
127
|
+
paymentReference: input.paymentReference,
|
|
128
|
+
providerRef: String(data.data.id),
|
|
129
|
+
amount: normalizeAmount(input.amount ?? "0.00"),
|
|
130
|
+
currency: input.currency ?? "NGN",
|
|
131
|
+
status: s.startsWith("completed") ? "successful" : s === "failed" ? "failed" : "pending",
|
|
132
|
+
raw: data,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async createTransfer(input) {
|
|
136
|
+
const data = await providerJson(`${this.baseUrl}/transfers`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: this.headers(),
|
|
139
|
+
body: {
|
|
140
|
+
account_bank: input.bankCode,
|
|
141
|
+
account_number: input.accountNumber,
|
|
142
|
+
amount: normalizeAmount(input.amount),
|
|
143
|
+
currency: input.currency,
|
|
144
|
+
narration: input.narration,
|
|
145
|
+
reference: input.reference,
|
|
146
|
+
meta: input.metadata,
|
|
147
|
+
},
|
|
148
|
+
timeoutMs: this.timeoutMs,
|
|
149
|
+
provider: "flutterwave",
|
|
150
|
+
reference: input.reference,
|
|
151
|
+
http: this.http,
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
id: newId(),
|
|
155
|
+
provider: "flutterwave",
|
|
156
|
+
reference: input.reference,
|
|
157
|
+
providerRef: String(data.data.id),
|
|
158
|
+
amount: normalizeAmount(input.amount),
|
|
159
|
+
currency: input.currency,
|
|
160
|
+
status: mapFlutterwaveStatus(data.data.status),
|
|
161
|
+
raw: data,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async verifyTransfer(reference) {
|
|
165
|
+
const data = await providerJson(`${this.baseUrl}/transfers/${encodeURIComponent(reference)}`, {
|
|
166
|
+
headers: this.headers(),
|
|
167
|
+
timeoutMs: this.timeoutMs,
|
|
168
|
+
provider: "flutterwave",
|
|
169
|
+
reference,
|
|
170
|
+
http: this.http,
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
id: newId(),
|
|
174
|
+
provider: "flutterwave",
|
|
175
|
+
reference: data.data.reference,
|
|
176
|
+
providerRef: String(data.data.id),
|
|
177
|
+
amount: Number(data.data.amount).toFixed(2),
|
|
178
|
+
currency: data.data.currency,
|
|
179
|
+
status: mapFlutterwaveStatus(data.data.status),
|
|
180
|
+
raw: data,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
verifyWebhookSignature(_rawBody, headers) {
|
|
184
|
+
// Flutterwave sends `verif-hash` == your webhook secret hash (dashboard setting).
|
|
185
|
+
const h = firstHeader(headers, "verif-hash", "Verif-Hash");
|
|
186
|
+
if (!h)
|
|
187
|
+
return false;
|
|
188
|
+
return h === this.webhookSecret;
|
|
189
|
+
}
|
|
190
|
+
normalizeWebhook(payload) {
|
|
191
|
+
if (typeof payload !== "object" || payload === null)
|
|
192
|
+
return null;
|
|
193
|
+
const p = payload;
|
|
194
|
+
if (p.event)
|
|
195
|
+
return this.normalizeDocumentedShape(p, payload);
|
|
196
|
+
// Flat sandbox charge shape: top-level txRef identifies the payment.
|
|
197
|
+
const txRef = p.txRef ?? p.tx_ref;
|
|
198
|
+
if (txRef) {
|
|
199
|
+
const ok = (p.status ?? "").toLowerCase() === "successful";
|
|
200
|
+
return {
|
|
201
|
+
type: ok ? "payment.succeeded" : "payment.failed",
|
|
202
|
+
provider: "flutterwave",
|
|
203
|
+
providerEventId: p.flwRef ?? (p.id !== undefined ? String(p.id) : undefined),
|
|
204
|
+
reference: txRef,
|
|
205
|
+
raw: payload,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
/** Documented `{event, data}` envelope shape. Unchanged behavior. */
|
|
211
|
+
normalizeDocumentedShape(p, payload) {
|
|
212
|
+
if (p.event === "charge.completed") {
|
|
213
|
+
const ok = (p.data?.status ?? "").toLowerCase() === "successful";
|
|
214
|
+
return {
|
|
215
|
+
type: ok ? "payment.succeeded" : "payment.failed",
|
|
216
|
+
provider: "flutterwave",
|
|
217
|
+
providerEventId: p.data?.id ? String(p.data.id) : undefined,
|
|
218
|
+
reference: p.data?.tx_ref ?? p.data?.reference,
|
|
219
|
+
raw: payload,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (p.event === "transfer.completed") {
|
|
223
|
+
const ok = ["successful", "success"].includes((p.data?.status ?? "").toLowerCase());
|
|
224
|
+
return {
|
|
225
|
+
type: ok ? "transfer.succeeded" : "transfer.failed",
|
|
226
|
+
provider: "flutterwave",
|
|
227
|
+
providerEventId: p.data?.id ? String(p.data.id) : undefined,
|
|
228
|
+
reference: p.data?.reference ?? p.data?.tx_ref,
|
|
229
|
+
raw: payload,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return { type: "unknown", provider: "flutterwave", raw: payload };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { WebhookHeaders } from "../core/types.js";
|
|
2
|
+
import type { HttpClient } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* First value wins for repeated headers. String inputs behave exactly as
|
|
5
|
+
* before; this only additionally accepts Node/Express-style string arrays.
|
|
6
|
+
*/
|
|
7
|
+
export declare function firstHeader(headers: WebhookHeaders, ...names: string[]): string | undefined;
|
|
8
|
+
export interface RequestOptions {
|
|
9
|
+
method?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
body?: unknown;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
provider: string;
|
|
14
|
+
reference?: string;
|
|
15
|
+
http?: HttpClient;
|
|
16
|
+
}
|
|
17
|
+
/** JSON request with timeout. Timeouts/network errors -> UnknownResultError (never auto-fail). */
|
|
18
|
+
export declare function providerJson<T>(url: string, opts: RequestOptions): Promise<T>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ProviderError, UnknownResultError } from "../core/errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* First value wins for repeated headers. String inputs behave exactly as
|
|
4
|
+
* before; this only additionally accepts Node/Express-style string arrays.
|
|
5
|
+
*/
|
|
6
|
+
export function firstHeader(headers, ...names) {
|
|
7
|
+
for (const name of names) {
|
|
8
|
+
const v = headers[name];
|
|
9
|
+
if (Array.isArray(v))
|
|
10
|
+
return v[0];
|
|
11
|
+
if (v !== undefined)
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/** JSON request with timeout. Timeouts/network errors -> UnknownResultError (never auto-fail). */
|
|
17
|
+
export async function providerJson(url, opts) {
|
|
18
|
+
const http = opts.http ?? ((u, init) => fetch(u, init));
|
|
19
|
+
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
22
|
+
try {
|
|
23
|
+
const res = await http(url, {
|
|
24
|
+
method: opts.method ?? "GET",
|
|
25
|
+
headers: { "Content-Type": "application/json", ...(opts.headers ?? {}) },
|
|
26
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
27
|
+
signal: controller.signal,
|
|
28
|
+
});
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let json = null;
|
|
31
|
+
try {
|
|
32
|
+
json = text ? JSON.parse(text) : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
json = { _rawText: text };
|
|
36
|
+
}
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const message = typeof json === "object" && json !== null && "message" in json
|
|
39
|
+
? String(json.message)
|
|
40
|
+
: `HTTP ${res.status}`;
|
|
41
|
+
throw new ProviderError(message, {
|
|
42
|
+
provider: opts.provider,
|
|
43
|
+
status: res.status,
|
|
44
|
+
retryable: res.status >= 500 || res.status === 429,
|
|
45
|
+
raw: json,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return json;
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err instanceof ProviderError)
|
|
52
|
+
throw err;
|
|
53
|
+
const aborted = err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
|
|
54
|
+
throw new UnknownResultError(opts.provider, opts.reference ?? url, err);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CreatePaymentInput, CreateRefundInput, CreateTransferInput, OpenPayEvent, Payment, PaymentStatus, ProviderCapabilities, Refund, Transfer, VerifyPaymentInput, WebhookHeaders } from "../core/types.js";
|
|
2
|
+
import type { ConnectorConfig, ProviderConnector } from "./types.js";
|
|
3
|
+
export declare function mapPaystackPaymentStatus(s: string): PaymentStatus;
|
|
4
|
+
export declare class PaystackConnector implements ProviderConnector {
|
|
5
|
+
readonly name: "paystack";
|
|
6
|
+
readonly capabilities: ProviderCapabilities;
|
|
7
|
+
private secretKey;
|
|
8
|
+
private baseUrl;
|
|
9
|
+
private timeoutMs;
|
|
10
|
+
private http;
|
|
11
|
+
private webhookSecret;
|
|
12
|
+
constructor(cfg: ConnectorConfig & {
|
|
13
|
+
webhookSecret?: string;
|
|
14
|
+
});
|
|
15
|
+
private headers;
|
|
16
|
+
createPayment(input: CreatePaymentInput & {
|
|
17
|
+
reference: string;
|
|
18
|
+
}): Promise<Payment>;
|
|
19
|
+
verifyPayment(input: VerifyPaymentInput): Promise<Payment>;
|
|
20
|
+
createRefund(input: CreateRefundInput): Promise<Refund>;
|
|
21
|
+
createTransfer(input: CreateTransferInput & {
|
|
22
|
+
reference: string;
|
|
23
|
+
}): Promise<Transfer>;
|
|
24
|
+
verifyTransfer(reference: string): Promise<Transfer>;
|
|
25
|
+
verifyWebhookSignature(rawBody: string | Buffer, headers: WebhookHeaders): boolean;
|
|
26
|
+
normalizeWebhook(payload: unknown): OpenPayEvent | null;
|
|
27
|
+
assertSupports(op: "refunds" | "transfers"): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { CapabilityError, ConfigurationError } from "../core/errors.js";
|
|
3
|
+
import { newId } from "../core/idempotency.js";
|
|
4
|
+
import { normalizeAmount, toMinorUnits } from "../core/validation.js";
|
|
5
|
+
import { firstHeader, providerJson } from "./http.js";
|
|
6
|
+
const DEFAULT_BASE = "https://api.paystack.co";
|
|
7
|
+
export function mapPaystackPaymentStatus(s) {
|
|
8
|
+
switch (s.toLowerCase()) {
|
|
9
|
+
case "success":
|
|
10
|
+
return "successful";
|
|
11
|
+
case "failed":
|
|
12
|
+
case "abandoned":
|
|
13
|
+
return "failed";
|
|
14
|
+
case "reversed":
|
|
15
|
+
return "successful"; // money moved; reversal/refund tracked separately
|
|
16
|
+
case "pending":
|
|
17
|
+
case "ongoing":
|
|
18
|
+
case "processing":
|
|
19
|
+
case "queued":
|
|
20
|
+
return "pending";
|
|
21
|
+
default:
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class PaystackConnector {
|
|
26
|
+
name = "paystack";
|
|
27
|
+
capabilities = {
|
|
28
|
+
paymentsCreate: true,
|
|
29
|
+
paymentsVerify: true,
|
|
30
|
+
refunds: true,
|
|
31
|
+
transfers: true,
|
|
32
|
+
webhooks: true,
|
|
33
|
+
};
|
|
34
|
+
secretKey;
|
|
35
|
+
baseUrl;
|
|
36
|
+
timeoutMs;
|
|
37
|
+
http;
|
|
38
|
+
webhookSecret;
|
|
39
|
+
constructor(cfg) {
|
|
40
|
+
if (!cfg.secretKey)
|
|
41
|
+
throw new ConfigurationError("Paystack secret key is required");
|
|
42
|
+
this.secretKey = cfg.secretKey;
|
|
43
|
+
this.baseUrl = (cfg.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
44
|
+
this.timeoutMs = cfg.timeoutMs ?? 15_000;
|
|
45
|
+
this.http = cfg.http;
|
|
46
|
+
this.webhookSecret = cfg.webhookSecret ?? cfg.secretKey;
|
|
47
|
+
}
|
|
48
|
+
headers() {
|
|
49
|
+
return { Authorization: `Bearer ${this.secretKey}` };
|
|
50
|
+
}
|
|
51
|
+
async createPayment(input) {
|
|
52
|
+
const amount = normalizeAmount(input.amount);
|
|
53
|
+
const data = await providerJson(`${this.baseUrl}/transaction/initialize`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: this.headers(),
|
|
56
|
+
body: {
|
|
57
|
+
email: input.customer.email,
|
|
58
|
+
amount: toMinorUnits(amount),
|
|
59
|
+
reference: input.reference,
|
|
60
|
+
currency: input.currency,
|
|
61
|
+
callback_url: input.redirectUrl,
|
|
62
|
+
metadata: input.metadata,
|
|
63
|
+
},
|
|
64
|
+
timeoutMs: this.timeoutMs,
|
|
65
|
+
provider: "paystack",
|
|
66
|
+
reference: input.reference,
|
|
67
|
+
http: this.http,
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
id: newId(),
|
|
71
|
+
provider: "paystack",
|
|
72
|
+
reference: input.reference,
|
|
73
|
+
providerRef: data.data.reference,
|
|
74
|
+
amount,
|
|
75
|
+
currency: input.currency,
|
|
76
|
+
status: "pending",
|
|
77
|
+
checkoutUrl: data.data.authorization_url,
|
|
78
|
+
customerEmail: input.customer.email,
|
|
79
|
+
raw: data,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async verifyPayment(input) {
|
|
83
|
+
const data = await providerJson(`${this.baseUrl}/transaction/verify/${encodeURIComponent(input.reference)}`, {
|
|
84
|
+
headers: this.headers(),
|
|
85
|
+
timeoutMs: this.timeoutMs,
|
|
86
|
+
provider: "paystack",
|
|
87
|
+
reference: input.reference,
|
|
88
|
+
http: this.http,
|
|
89
|
+
});
|
|
90
|
+
const d = data.data;
|
|
91
|
+
return {
|
|
92
|
+
id: newId(),
|
|
93
|
+
provider: "paystack",
|
|
94
|
+
reference: d.reference,
|
|
95
|
+
providerRef: d.reference,
|
|
96
|
+
amount: (d.amount / 100).toFixed(2),
|
|
97
|
+
currency: d.currency,
|
|
98
|
+
status: mapPaystackPaymentStatus(d.status),
|
|
99
|
+
customerEmail: d.customer?.email,
|
|
100
|
+
raw: data,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async createRefund(input) {
|
|
104
|
+
const data = await providerJson(`${this.baseUrl}/refund`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: this.headers(),
|
|
107
|
+
body: {
|
|
108
|
+
transaction: input.paymentReference,
|
|
109
|
+
amount: input.amount ? toMinorUnits(normalizeAmount(input.amount)) : undefined,
|
|
110
|
+
},
|
|
111
|
+
timeoutMs: this.timeoutMs,
|
|
112
|
+
provider: "paystack",
|
|
113
|
+
reference: input.paymentReference,
|
|
114
|
+
http: this.http,
|
|
115
|
+
});
|
|
116
|
+
const s = String(data.data.status).toLowerCase();
|
|
117
|
+
const status = s === "processed" ? "successful" : s === "failed" ? "failed" : s === "pending" || s === "processing" ? "pending" : "unknown";
|
|
118
|
+
return {
|
|
119
|
+
id: newId(),
|
|
120
|
+
provider: "paystack",
|
|
121
|
+
paymentReference: input.paymentReference,
|
|
122
|
+
providerRef: String(data.data.id),
|
|
123
|
+
amount: normalizeAmount(input.amount ?? "0.00"),
|
|
124
|
+
currency: input.currency ?? "NGN",
|
|
125
|
+
status,
|
|
126
|
+
raw: data,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async createTransfer(input) {
|
|
130
|
+
// Step 1: create recipient, Step 2: initiate transfer
|
|
131
|
+
const recipient = await providerJson(`${this.baseUrl}/transferrecipient`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: this.headers(),
|
|
134
|
+
body: {
|
|
135
|
+
type: "nuban",
|
|
136
|
+
name: input.accountName ?? input.accountNumber,
|
|
137
|
+
account_number: input.accountNumber,
|
|
138
|
+
bank_code: input.bankCode,
|
|
139
|
+
currency: input.currency,
|
|
140
|
+
},
|
|
141
|
+
timeoutMs: this.timeoutMs,
|
|
142
|
+
provider: "paystack",
|
|
143
|
+
reference: input.reference,
|
|
144
|
+
http: this.http,
|
|
145
|
+
});
|
|
146
|
+
const transfer = await providerJson(`${this.baseUrl}/transfer`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: this.headers(),
|
|
149
|
+
body: {
|
|
150
|
+
source: "balance",
|
|
151
|
+
amount: toMinorUnits(normalizeAmount(input.amount)),
|
|
152
|
+
recipient: recipient.data.recipient_code,
|
|
153
|
+
reference: input.reference,
|
|
154
|
+
reason: input.narration,
|
|
155
|
+
},
|
|
156
|
+
timeoutMs: this.timeoutMs,
|
|
157
|
+
provider: "paystack",
|
|
158
|
+
reference: input.reference,
|
|
159
|
+
http: this.http,
|
|
160
|
+
});
|
|
161
|
+
const s = transfer.data.status.toLowerCase();
|
|
162
|
+
return {
|
|
163
|
+
id: newId(),
|
|
164
|
+
provider: "paystack",
|
|
165
|
+
reference: input.reference,
|
|
166
|
+
providerRef: transfer.data.reference,
|
|
167
|
+
amount: normalizeAmount(input.amount),
|
|
168
|
+
currency: input.currency,
|
|
169
|
+
status: s === "success" ? "successful" : s === "failed" ? "failed" : "pending",
|
|
170
|
+
raw: { recipient, transfer },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async verifyTransfer(reference) {
|
|
174
|
+
const data = await providerJson(`${this.baseUrl}/transfer/verify/${encodeURIComponent(reference)}`, {
|
|
175
|
+
headers: this.headers(),
|
|
176
|
+
timeoutMs: this.timeoutMs,
|
|
177
|
+
provider: "paystack",
|
|
178
|
+
reference,
|
|
179
|
+
http: this.http,
|
|
180
|
+
});
|
|
181
|
+
const s = data.data.status.toLowerCase();
|
|
182
|
+
return {
|
|
183
|
+
id: newId(),
|
|
184
|
+
provider: "paystack",
|
|
185
|
+
reference: data.data.reference,
|
|
186
|
+
providerRef: data.data.reference,
|
|
187
|
+
amount: (data.data.amount / 100).toFixed(2),
|
|
188
|
+
currency: data.data.currency,
|
|
189
|
+
status: s === "success" ? "successful" : s === "failed" ? "failed" : s === "pending" ? "pending" : "unknown",
|
|
190
|
+
raw: data,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
verifyWebhookSignature(rawBody, headers) {
|
|
194
|
+
const sig = firstHeader(headers, "x-paystack-signature", "X-Paystack-Signature");
|
|
195
|
+
if (!sig)
|
|
196
|
+
return false;
|
|
197
|
+
const digest = createHmac("sha512", this.webhookSecret).update(rawBody).digest("hex");
|
|
198
|
+
const a = Buffer.from(digest);
|
|
199
|
+
const b = Buffer.from(sig);
|
|
200
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
201
|
+
}
|
|
202
|
+
normalizeWebhook(payload) {
|
|
203
|
+
if (typeof payload !== "object" || payload === null)
|
|
204
|
+
return null;
|
|
205
|
+
const p = payload;
|
|
206
|
+
if (!p.event)
|
|
207
|
+
return null;
|
|
208
|
+
const map = {
|
|
209
|
+
"charge.success": "payment.succeeded",
|
|
210
|
+
"charge.failed": "payment.failed",
|
|
211
|
+
"transfer.success": "transfer.succeeded",
|
|
212
|
+
"transfer.failed": "transfer.failed",
|
|
213
|
+
"transfer.reversed": "transfer.failed",
|
|
214
|
+
"refund.processed": "refund.processed",
|
|
215
|
+
"refund.failed": "refund.failed",
|
|
216
|
+
"refund.pending": "payment.pending",
|
|
217
|
+
"refund.processing": "payment.pending",
|
|
218
|
+
};
|
|
219
|
+
const type = map[p.event] ?? "unknown";
|
|
220
|
+
// Stable dedupe id: numeric object id where present, otherwise the
|
|
221
|
+
// provider's own references (refund retries share refund_reference).
|
|
222
|
+
const eventId = p.data?.id !== undefined
|
|
223
|
+
? String(p.data.id)
|
|
224
|
+
: (p.data?.refund_reference ?? p.data?.transaction_reference ?? p.data?.reference);
|
|
225
|
+
return {
|
|
226
|
+
type,
|
|
227
|
+
provider: "paystack",
|
|
228
|
+
providerEventId: eventId,
|
|
229
|
+
reference: p.data?.reference ?? p.data?.transaction_reference,
|
|
230
|
+
raw: payload,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// Narrowing helper so TS keeps `capabilities` honest if extended later.
|
|
234
|
+
assertSupports(op) {
|
|
235
|
+
if (!this.capabilities[op])
|
|
236
|
+
throw new CapabilityError("paystack", op);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CreatePaymentInput, CreateRefundInput, CreateTransferInput, Payment, ProviderName, Refund, Transfer, VerifyPaymentInput } from "../core/types.js";
|
|
2
|
+
import type { Persistence } from "../db/store.js";
|
|
3
|
+
import type { ProviderConnector } from "./types.js";
|
|
4
|
+
export interface ServiceContext {
|
|
5
|
+
connectors: Record<ProviderName, ProviderConnector>;
|
|
6
|
+
defaultProvider: ProviderName;
|
|
7
|
+
persistence?: Persistence;
|
|
8
|
+
}
|
|
9
|
+
export declare function createPayment(ctx: ServiceContext, input: CreatePaymentInput): Promise<Payment>;
|
|
10
|
+
export declare function verifyPayment(ctx: ServiceContext, input: VerifyPaymentInput): Promise<Payment>;
|
|
11
|
+
export declare function createTransfer(ctx: ServiceContext, input: CreateTransferInput): Promise<Transfer>;
|
|
12
|
+
export declare function createRefund(ctx: ServiceContext, input: CreateRefundInput): Promise<Refund>;
|