mockpay 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/README.md +207 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +270 -0
- package/dist/core/config.d.ts +13 -0
- package/dist/core/config.js +30 -0
- package/dist/core/db.d.ts +9 -0
- package/dist/core/db.js +104 -0
- package/dist/core/logger.d.ts +11 -0
- package/dist/core/logger.js +47 -0
- package/dist/core/runtime.d.ts +10 -0
- package/dist/core/runtime.js +34 -0
- package/dist/core/state.d.ts +18 -0
- package/dist/core/state.js +70 -0
- package/dist/core/utils.d.ts +2 -0
- package/dist/core/utils.js +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/providers/flutterwave/index.d.ts +10 -0
- package/dist/providers/flutterwave/index.js +231 -0
- package/dist/providers/paystack/index.d.ts +10 -0
- package/dist/providers/paystack/index.js +226 -0
- package/dist/routes/logs.d.ts +2 -0
- package/dist/routes/logs.js +20 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +59 -0
- package/dist/server/middleware/errorSimulation.d.ts +2 -0
- package/dist/server/middleware/errorSimulation.js +47 -0
- package/dist/server/middleware/logging.d.ts +2 -0
- package/dist/server/middleware/logging.js +7 -0
- package/dist/types/index.d.ts +59 -0
- package/dist/types/index.js +1 -0
- package/dist/webhooks/sender.d.ts +8 -0
- package/dist/webhooks/sender.js +94 -0
- package/package.json +31 -0
- package/src/cli/index.ts +291 -0
- package/src/core/config.ts +47 -0
- package/src/core/db.ts +123 -0
- package/src/core/logger.ts +57 -0
- package/src/core/runtime.ts +42 -0
- package/src/core/state.ts +91 -0
- package/src/core/utils.ts +10 -0
- package/src/index.ts +3 -0
- package/src/providers/flutterwave/index.ts +254 -0
- package/src/providers/paystack/index.ts +249 -0
- package/src/routes/logs.ts +28 -0
- package/src/server/index.ts +69 -0
- package/src/server/middleware/errorSimulation.ts +60 -0
- package/src/server/middleware/logging.ts +10 -0
- package/src/types/index.ts +64 -0
- package/src/webhooks/sender.ts +108 -0
- package/template/App.tsx +25 -0
- package/template/components/Button.tsx +45 -0
- package/template/components/Card.tsx +16 -0
- package/template/components/Input.tsx +27 -0
- package/template/components/PaymentMethodIcon.tsx +40 -0
- package/template/components/StatusScreen.tsx +117 -0
- package/template/hooks/useQueryParams.ts +22 -0
- package/template/index.html +29 -0
- package/template/index.tsx +16 -0
- package/template/package.json +25 -0
- package/template/pages/CancelledPage.tsx +20 -0
- package/template/pages/CheckoutPage.tsx +370 -0
- package/template/pages/FailedPage.tsx +20 -0
- package/template/pages/SuccessPage.tsx +20 -0
- package/template/pnpm-lock.yaml +1192 -0
- package/template/react-icons.d.ts +8 -0
- package/template/tsconfig.json +31 -0
- package/template/types.ts +25 -0
- package/template/vite.config.ts +23 -0
- package/tsconfig.json +16 -0
package/src/core/db.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import ChronoDB from "chronodb";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
|
|
4
|
+
import { getConfig } from "./config.js";
|
|
5
|
+
|
|
6
|
+
let dbPromise: Promise<any> | null = null;
|
|
7
|
+
let collectionsPromise: Promise<Collections> | null = null;
|
|
8
|
+
|
|
9
|
+
export interface Collections {
|
|
10
|
+
transactions: any;
|
|
11
|
+
transfers: any;
|
|
12
|
+
webhooks: any;
|
|
13
|
+
settings: any;
|
|
14
|
+
logs: any;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getDb(): Promise<any> {
|
|
18
|
+
if (!dbPromise) {
|
|
19
|
+
dbPromise = openDb();
|
|
20
|
+
}
|
|
21
|
+
return dbPromise;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getCollections(): Promise<Collections> {
|
|
25
|
+
if (!collectionsPromise) {
|
|
26
|
+
collectionsPromise = initCollections();
|
|
27
|
+
}
|
|
28
|
+
return collectionsPromise;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function openDb(): Promise<any> {
|
|
32
|
+
const { dataDir } = getConfig();
|
|
33
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return await ChronoDB.open({ path: dataDir, cloudSync: false });
|
|
37
|
+
} catch {
|
|
38
|
+
const previous = process.cwd();
|
|
39
|
+
process.chdir(dataDir);
|
|
40
|
+
try {
|
|
41
|
+
return await ChronoDB.open({ cloudSync: false });
|
|
42
|
+
} finally {
|
|
43
|
+
process.chdir(previous);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function initCollections(): Promise<Collections> {
|
|
49
|
+
const db = await getDb();
|
|
50
|
+
|
|
51
|
+
const transactions = db.col("transactions", {
|
|
52
|
+
schema: {
|
|
53
|
+
createdAt: { type: "string" },
|
|
54
|
+
updatedAt: { type: "string" },
|
|
55
|
+
provider: { type: "string", important: true },
|
|
56
|
+
reference: { type: "string", important: true, distinct: true },
|
|
57
|
+
status: { type: "string", important: true },
|
|
58
|
+
amount: { type: "number", important: true },
|
|
59
|
+
currency: { type: "string", default: "NGN" },
|
|
60
|
+
customerEmail: { type: "string", important: true },
|
|
61
|
+
customerName: { type: "string", nullable: true },
|
|
62
|
+
callbackUrl: { type: "string", nullable: true },
|
|
63
|
+
metadata: { type: "string", nullable: true }
|
|
64
|
+
},
|
|
65
|
+
indexes: ["provider", "reference", "status"]
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const transfers = db.col("transfers", {
|
|
69
|
+
schema: {
|
|
70
|
+
createdAt: { type: "string" },
|
|
71
|
+
updatedAt: { type: "string" },
|
|
72
|
+
provider: { type: "string", important: true },
|
|
73
|
+
reference: { type: "string", important: true, distinct: true },
|
|
74
|
+
status: { type: "string", important: true },
|
|
75
|
+
amount: { type: "number", important: true },
|
|
76
|
+
currency: { type: "string", default: "NGN" },
|
|
77
|
+
bankCode: { type: "string", nullable: true },
|
|
78
|
+
accountNumber: { type: "string", nullable: true },
|
|
79
|
+
narration: { type: "string", nullable: true },
|
|
80
|
+
metadata: { type: "string", nullable: true }
|
|
81
|
+
},
|
|
82
|
+
indexes: ["provider", "reference", "status"]
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const webhooks = db.col("webhooks", {
|
|
86
|
+
schema: {
|
|
87
|
+
createdAt: { type: "string" },
|
|
88
|
+
updatedAt: { type: "string" },
|
|
89
|
+
provider: { type: "string", important: true },
|
|
90
|
+
event: { type: "string", important: true },
|
|
91
|
+
url: { type: "string", important: true },
|
|
92
|
+
status: { type: "string", important: true },
|
|
93
|
+
attempts: { type: "number", default: 0 },
|
|
94
|
+
payload: { type: "string", important: true },
|
|
95
|
+
lastAttemptAt: { type: "number", nullable: true }
|
|
96
|
+
},
|
|
97
|
+
indexes: ["provider", "event", "status"]
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const settings = db.col("settings", {
|
|
101
|
+
schema: {
|
|
102
|
+
createdAt: { type: "string" },
|
|
103
|
+
updatedAt: { type: "string" },
|
|
104
|
+
key: { type: "string", important: true, distinct: true },
|
|
105
|
+
value: { type: "string", important: true }
|
|
106
|
+
},
|
|
107
|
+
indexes: ["key"]
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const logs = db.col("logs", {
|
|
111
|
+
schema: {
|
|
112
|
+
createdAt: { type: "string" },
|
|
113
|
+
updatedAt: { type: "string" },
|
|
114
|
+
level: { type: "string", important: true },
|
|
115
|
+
message: { type: "string", important: true },
|
|
116
|
+
source: { type: "string", nullable: true },
|
|
117
|
+
timestamp: { type: "number", important: true }
|
|
118
|
+
},
|
|
119
|
+
indexes: ["level", "timestamp"]
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return { transactions, transfers, webhooks, settings, logs } as Collections;
|
|
123
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
|
|
4
|
+
import { getCollections } from "./db.js";
|
|
5
|
+
import type { LogEntry } from "../types/index.js";
|
|
6
|
+
|
|
7
|
+
export type LogLevel = "info" | "warn" | "error" | "http";
|
|
8
|
+
|
|
9
|
+
const emitter = new EventEmitter();
|
|
10
|
+
|
|
11
|
+
function colorize(level: LogLevel, message: string): string {
|
|
12
|
+
switch (level) {
|
|
13
|
+
case "info":
|
|
14
|
+
return chalk.cyan(message);
|
|
15
|
+
case "warn":
|
|
16
|
+
return chalk.yellow(message);
|
|
17
|
+
case "error":
|
|
18
|
+
return chalk.red(message);
|
|
19
|
+
case "http":
|
|
20
|
+
return chalk.green(message);
|
|
21
|
+
default:
|
|
22
|
+
return message;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function persist(entry: LogEntry): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
const { logs } = await getCollections();
|
|
29
|
+
await logs.add(entry);
|
|
30
|
+
} catch {
|
|
31
|
+
// Best-effort logging only.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function log(level: LogLevel, message: string, source?: string): void {
|
|
36
|
+
const entry: LogEntry = {
|
|
37
|
+
level,
|
|
38
|
+
message,
|
|
39
|
+
source,
|
|
40
|
+
timestamp: Date.now()
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const prefix = source ? `[${source}] ` : "";
|
|
44
|
+
console.log(colorize(level, `${prefix}${message}`));
|
|
45
|
+
emitter.emit("log", entry);
|
|
46
|
+
void persist(entry);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const logger = {
|
|
50
|
+
info: (message: string, source?: string) => log("info", message, source),
|
|
51
|
+
warn: (message: string, source?: string) => log("warn", message, source),
|
|
52
|
+
error: (message: string, source?: string) => log("error", message, source),
|
|
53
|
+
http: (message: string, source?: string) => log("http", message, source),
|
|
54
|
+
on: (handler: (entry: LogEntry) => void) => emitter.on("log", handler),
|
|
55
|
+
off: (handler: (entry: LogEntry) => void) => emitter.off("log", handler)
|
|
56
|
+
};
|
|
57
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
interface RuntimeState {
|
|
5
|
+
pid: number;
|
|
6
|
+
startedAt: number;
|
|
7
|
+
dataDir?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const runtimePath = path.resolve(process.cwd(), ".mockpay", "runtime.json");
|
|
11
|
+
|
|
12
|
+
export async function writeRuntime(pid: number, dataDir?: string): Promise<void> {
|
|
13
|
+
await fs.mkdir(path.dirname(runtimePath), { recursive: true });
|
|
14
|
+
const state: RuntimeState = { pid, startedAt: Date.now(), dataDir };
|
|
15
|
+
await fs.writeFile(runtimePath, JSON.stringify(state, null, 2), "utf-8");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readRuntime(): Promise<RuntimeState | null> {
|
|
19
|
+
try {
|
|
20
|
+
const content = await fs.readFile(runtimePath, "utf-8");
|
|
21
|
+
return JSON.parse(content) as RuntimeState;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function clearRuntime(): Promise<void> {
|
|
28
|
+
try {
|
|
29
|
+
await fs.unlink(runtimePath);
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isPidRunning(pid: number): boolean {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getCollections } from "./db.js";
|
|
2
|
+
import { getConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
export type PaymentResult = "success" | "failed" | "cancelled";
|
|
5
|
+
export type NextError = "none" | "500" | "timeout" | "network";
|
|
6
|
+
|
|
7
|
+
export interface WebhookConfig {
|
|
8
|
+
delayMs: number;
|
|
9
|
+
duplicate: boolean;
|
|
10
|
+
drop: boolean;
|
|
11
|
+
retryCount: number;
|
|
12
|
+
retryDelayMs: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NEXT_PAYMENT_KEY = "next_payment_result";
|
|
16
|
+
const NEXT_ERROR_KEY = "next_error";
|
|
17
|
+
const WEBHOOK_CONFIG_KEY = "webhook_config";
|
|
18
|
+
const LAST_WEBHOOK_KEY = "last_webhook";
|
|
19
|
+
|
|
20
|
+
async function getSetting<T>(key: string, fallback: T): Promise<T> {
|
|
21
|
+
const { settings } = await getCollections();
|
|
22
|
+
const existing = await settings.getOne({ key });
|
|
23
|
+
if (!existing) return fallback;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(existing.value) as T;
|
|
26
|
+
} catch {
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function setSetting<T>(key: string, value: T): Promise<void> {
|
|
32
|
+
const { settings } = await getCollections();
|
|
33
|
+
const existing = await settings.getOne({ key });
|
|
34
|
+
const payload = JSON.stringify(value);
|
|
35
|
+
if (existing) {
|
|
36
|
+
await settings.updateById(existing.id, { value: payload });
|
|
37
|
+
} else {
|
|
38
|
+
await settings.add({ key, value: payload });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function takeNextPaymentResult(): Promise<PaymentResult> {
|
|
43
|
+
const current = await getSetting<PaymentResult>(NEXT_PAYMENT_KEY, "success");
|
|
44
|
+
await setSetting(NEXT_PAYMENT_KEY, "success");
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function setNextPaymentResult(result: PaymentResult): Promise<void> {
|
|
49
|
+
await setSetting(NEXT_PAYMENT_KEY, result);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function takeNextError(): Promise<NextError> {
|
|
53
|
+
const current = await getSetting<NextError>(NEXT_ERROR_KEY, "none");
|
|
54
|
+
await setSetting(NEXT_ERROR_KEY, "none");
|
|
55
|
+
return current;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function setNextError(error: NextError): Promise<void> {
|
|
59
|
+
await setSetting(NEXT_ERROR_KEY, error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function getWebhookConfig(): Promise<WebhookConfig> {
|
|
63
|
+
const config = getConfig();
|
|
64
|
+
const fallback: WebhookConfig = {
|
|
65
|
+
delayMs: config.webhookDelayMs,
|
|
66
|
+
duplicate: config.webhookDuplicate,
|
|
67
|
+
drop: config.webhookDrop,
|
|
68
|
+
retryCount: config.webhookRetryCount,
|
|
69
|
+
retryDelayMs: config.webhookRetryDelayMs
|
|
70
|
+
};
|
|
71
|
+
return getSetting<WebhookConfig>(WEBHOOK_CONFIG_KEY, fallback);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function setWebhookConfig(value: WebhookConfig): Promise<void> {
|
|
75
|
+
await setSetting(WEBHOOK_CONFIG_KEY, value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function setLastWebhook(value: unknown): Promise<void> {
|
|
79
|
+
await setSetting(LAST_WEBHOOK_KEY, value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function getLastWebhook<T>(): Promise<T | null> {
|
|
83
|
+
return getSetting<T | null>(LAST_WEBHOOK_KEY, null);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function resetState(): Promise<void> {
|
|
87
|
+
await setSetting(NEXT_PAYMENT_KEY, "success");
|
|
88
|
+
await setSetting(NEXT_ERROR_KEY, "none");
|
|
89
|
+
await setWebhookConfig(await getWebhookConfig());
|
|
90
|
+
}
|
|
91
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
export function generateReference(prefix: string): string {
|
|
4
|
+
const random = crypto.randomBytes(6).toString("hex");
|
|
5
|
+
return `${prefix}_${Date.now()}_${random}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function toKobo(amount: number): number {
|
|
9
|
+
return Math.round(amount * 100);
|
|
10
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { Express, Request, Response } from "express";
|
|
2
|
+
|
|
3
|
+
import { getCollections } from "../../core/db.js";
|
|
4
|
+
import { getConfig } from "../../core/config.js";
|
|
5
|
+
import { generateReference } from "../../core/utils.js";
|
|
6
|
+
import { takeNextPaymentResult } from "../../core/state.js";
|
|
7
|
+
import { logger } from "../../core/logger.js";
|
|
8
|
+
import { sendWebhook } from "../../webhooks/sender.js";
|
|
9
|
+
import type { PaymentProvider, TransactionRecord } from "../../types/index.js";
|
|
10
|
+
|
|
11
|
+
type CheckoutStatus = "success" | "failed" | "cancelled";
|
|
12
|
+
|
|
13
|
+
function mapCheckoutStatusToFlutterwave(status: CheckoutStatus): "successful" | "failed" | "cancelled" {
|
|
14
|
+
if (status === "success") return "successful";
|
|
15
|
+
if (status === "failed") return "failed";
|
|
16
|
+
return "cancelled";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mapCliStatusToFlutterwave(status: "success" | "failed" | "cancelled"): "successful" | "failed" | "cancelled" {
|
|
20
|
+
if (status === "success") return "successful";
|
|
21
|
+
if (status === "failed") return "failed";
|
|
22
|
+
return "cancelled";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mapFlutterwaveStatusToCheckout(status: "successful" | "failed" | "cancelled"): CheckoutStatus {
|
|
26
|
+
if (status === "successful") return "success";
|
|
27
|
+
if (status === "failed") return "failed";
|
|
28
|
+
return "cancelled";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function webhookEventFromStatus(status: string): string {
|
|
32
|
+
if (status === "successful") return "charge.completed";
|
|
33
|
+
if (status === "cancelled") return "charge.cancelled";
|
|
34
|
+
return "charge.failed";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildVerifyResponse(transaction: any) {
|
|
38
|
+
return {
|
|
39
|
+
id: transaction.id,
|
|
40
|
+
tx_ref: transaction.reference,
|
|
41
|
+
flw_ref: `FLW-MOCK-${transaction.reference}`,
|
|
42
|
+
amount: transaction.amount,
|
|
43
|
+
currency: transaction.currency,
|
|
44
|
+
status: transaction.status,
|
|
45
|
+
customer: {
|
|
46
|
+
email: transaction.customerEmail,
|
|
47
|
+
name: transaction.customerName
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class FlutterwaveProvider implements PaymentProvider {
|
|
53
|
+
registerRoutes(app: Express): void {
|
|
54
|
+
app.post("/payments", this.initialize);
|
|
55
|
+
app.post("/mock/complete", this.completeCheckout);
|
|
56
|
+
app.get("/transactions/verify_by_reference", this.verifyByReference);
|
|
57
|
+
app.get("/transactions/:id/verify", this.verifyById);
|
|
58
|
+
app.post("/transfers", this.createTransfer);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private initialize = async (req: Request, res: Response) => {
|
|
62
|
+
const { transactions } = await getCollections();
|
|
63
|
+
const { frontendUrl, flutterwavePort } = getConfig();
|
|
64
|
+
const reference = generateReference("FLW");
|
|
65
|
+
const amount = Number(req.body?.amount ?? 0);
|
|
66
|
+
const email = String(req.body?.customer?.email ?? req.body?.email ?? "customer@example.com");
|
|
67
|
+
const name = req.body?.customer?.name
|
|
68
|
+
? String(req.body?.customer?.name)
|
|
69
|
+
: req.body?.name
|
|
70
|
+
? String(req.body?.name)
|
|
71
|
+
: null;
|
|
72
|
+
const currency = String(req.body?.currency ?? "NGN").toUpperCase();
|
|
73
|
+
const callbackUrl = req.body?.redirect_url || null;
|
|
74
|
+
|
|
75
|
+
const record: TransactionRecord = {
|
|
76
|
+
provider: "flutterwave",
|
|
77
|
+
reference,
|
|
78
|
+
status: "pending",
|
|
79
|
+
amount,
|
|
80
|
+
currency,
|
|
81
|
+
customerEmail: email,
|
|
82
|
+
customerName: name,
|
|
83
|
+
callbackUrl,
|
|
84
|
+
metadata: JSON.stringify(req.body?.meta ?? null)
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
await transactions.add(record);
|
|
88
|
+
|
|
89
|
+
const checkoutBase = frontendUrl ?? `http://localhost:${flutterwavePort}`;
|
|
90
|
+
const apiBase = `http://localhost:${flutterwavePort}`;
|
|
91
|
+
const checkoutUrl = new URL("/checkout", checkoutBase);
|
|
92
|
+
checkoutUrl.searchParams.set("provider", "flutterwave");
|
|
93
|
+
checkoutUrl.searchParams.set("api_base", apiBase);
|
|
94
|
+
checkoutUrl.searchParams.set("ref", reference);
|
|
95
|
+
checkoutUrl.searchParams.set("amount", String(amount));
|
|
96
|
+
checkoutUrl.searchParams.set("currency", currency);
|
|
97
|
+
checkoutUrl.searchParams.set("email", email);
|
|
98
|
+
if (name) checkoutUrl.searchParams.set("name", name);
|
|
99
|
+
if (callbackUrl) checkoutUrl.searchParams.set("redirect_url", callbackUrl);
|
|
100
|
+
|
|
101
|
+
res.json({
|
|
102
|
+
status: "success",
|
|
103
|
+
message: "Hosted Link created",
|
|
104
|
+
data: {
|
|
105
|
+
link: checkoutUrl.toString(),
|
|
106
|
+
tx_ref: reference,
|
|
107
|
+
amount,
|
|
108
|
+
currency,
|
|
109
|
+
customer: {
|
|
110
|
+
email,
|
|
111
|
+
name
|
|
112
|
+
},
|
|
113
|
+
api_base: apiBase
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
private completeCheckout = async (req: Request, res: Response) => {
|
|
119
|
+
const { transactions } = await getCollections();
|
|
120
|
+
const reference = String(req.body?.reference ?? "");
|
|
121
|
+
const checkoutStatus = String(req.body?.status ?? "") as CheckoutStatus;
|
|
122
|
+
|
|
123
|
+
if (!reference) {
|
|
124
|
+
res.status(400).json({ status: "error", message: "reference is required" });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!["success", "failed", "cancelled"].includes(checkoutStatus)) {
|
|
129
|
+
res.status(400).json({ status: "error", message: "status must be success|failed|cancelled" });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const transaction = await transactions.getOne({ reference, provider: "flutterwave" });
|
|
134
|
+
if (!transaction) {
|
|
135
|
+
res.status(404).json({ status: "error", message: "Transaction not found" });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// CLI-driven result takes precedence over checkout input when configured.
|
|
140
|
+
const nextCliStatus = await takeNextPaymentResult();
|
|
141
|
+
const finalStatus =
|
|
142
|
+
nextCliStatus === "success"
|
|
143
|
+
? mapCheckoutStatusToFlutterwave(checkoutStatus)
|
|
144
|
+
: mapCliStatusToFlutterwave(nextCliStatus);
|
|
145
|
+
await transactions.updateById(transaction.id, { status: finalStatus });
|
|
146
|
+
transaction.status = finalStatus;
|
|
147
|
+
|
|
148
|
+
const { defaultWebhookUrl } = getConfig();
|
|
149
|
+
if (defaultWebhookUrl) {
|
|
150
|
+
const event = webhookEventFromStatus(finalStatus);
|
|
151
|
+
void sendWebhook({
|
|
152
|
+
provider: "flutterwave",
|
|
153
|
+
event,
|
|
154
|
+
url: defaultWebhookUrl,
|
|
155
|
+
payload: {
|
|
156
|
+
event,
|
|
157
|
+
data: {
|
|
158
|
+
id: transaction.id,
|
|
159
|
+
tx_ref: transaction.reference,
|
|
160
|
+
status: finalStatus,
|
|
161
|
+
amount: transaction.amount,
|
|
162
|
+
currency: transaction.currency,
|
|
163
|
+
customer: {
|
|
164
|
+
email: transaction.customerEmail,
|
|
165
|
+
name: transaction.customerName
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
logger.warn("No default webhook URL provided for Flutterwave webhook", "flutterwave");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
res.json({
|
|
175
|
+
status: "success",
|
|
176
|
+
message: "Checkout status captured",
|
|
177
|
+
data: {
|
|
178
|
+
transaction_id: transaction.id,
|
|
179
|
+
tx_ref: reference,
|
|
180
|
+
status: finalStatus,
|
|
181
|
+
checkout_status: mapFlutterwaveStatusToCheckout(finalStatus)
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
private verifyByReference = async (req: Request, res: Response) => {
|
|
187
|
+
const { transactions } = await getCollections();
|
|
188
|
+
const txRef = String(req.query?.tx_ref ?? "");
|
|
189
|
+
|
|
190
|
+
if (!txRef) {
|
|
191
|
+
res.status(400).json({ status: "error", message: "tx_ref query parameter is required" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const transaction = await transactions.getOne({ reference: txRef, provider: "flutterwave" });
|
|
196
|
+
if (!transaction) {
|
|
197
|
+
res.status(404).json({ status: "error", message: "Transaction not found" });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
res.json({
|
|
202
|
+
status: "success",
|
|
203
|
+
message: "Transaction fetched successfully",
|
|
204
|
+
data: buildVerifyResponse(transaction)
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
private verifyById = async (req: Request, res: Response) => {
|
|
209
|
+
const { transactions } = await getCollections();
|
|
210
|
+
const id = String(req.params.id ?? "");
|
|
211
|
+
const transaction = await transactions.getById(id);
|
|
212
|
+
|
|
213
|
+
if (!transaction || transaction.provider !== "flutterwave") {
|
|
214
|
+
res.status(404).json({ status: "error", message: "Transaction not found" });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
res.json({
|
|
219
|
+
status: "success",
|
|
220
|
+
message: "Transaction fetched successfully",
|
|
221
|
+
data: buildVerifyResponse(transaction)
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
private createTransfer = async (req: Request, res: Response) => {
|
|
226
|
+
const { transfers } = await getCollections();
|
|
227
|
+
const reference = generateReference("FLT");
|
|
228
|
+
const amount = Number(req.body?.amount ?? 0);
|
|
229
|
+
|
|
230
|
+
const transfer = await transfers.add({
|
|
231
|
+
provider: "flutterwave",
|
|
232
|
+
reference,
|
|
233
|
+
status: "pending",
|
|
234
|
+
amount,
|
|
235
|
+
currency: String(req.body?.currency ?? "NGN"),
|
|
236
|
+
bankCode: req.body?.bank_code ?? null,
|
|
237
|
+
accountNumber: req.body?.account_number ?? null,
|
|
238
|
+
narration: req.body?.narration ?? null,
|
|
239
|
+
metadata: JSON.stringify(req.body?.meta ?? null)
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
res.json({
|
|
243
|
+
status: "success",
|
|
244
|
+
message: "Transfer queued",
|
|
245
|
+
data: {
|
|
246
|
+
id: transfer.id,
|
|
247
|
+
reference: transfer.reference,
|
|
248
|
+
status: transfer.status,
|
|
249
|
+
amount: transfer.amount,
|
|
250
|
+
currency: transfer.currency
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
}
|