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/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# mockpay
|
|
2
|
+
|
|
3
|
+
Local mock Paystack and Flutterwave servers for offline/local testing. Use this in development to replace live gateway URLs and to simulate payment outcomes, errors, and webhooks.
|
|
4
|
+
|
|
5
|
+
Default base URLs:
|
|
6
|
+
- Paystack-like: `http://localhost:4010`
|
|
7
|
+
- Flutterwave-like: `http://localhost:4020`
|
|
8
|
+
|
|
9
|
+
Hosted checkout (served by mockpay):
|
|
10
|
+
- http://localhost:4010/checkout
|
|
11
|
+
- http://localhost:4020/checkout
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i -g mockpay
|
|
17
|
+
mockpay start
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Servers:
|
|
21
|
+
- Paystack: http://localhost:4010
|
|
22
|
+
- Flutterwave: http://localhost:4020
|
|
23
|
+
|
|
24
|
+
## Integration Goal
|
|
25
|
+
|
|
26
|
+
Replace live gateway URLs in development:
|
|
27
|
+
- Instead of `https://api.paystack.co`, use `http://localhost:4010`
|
|
28
|
+
- Instead of `https://api.flutterwave.com/v3`, use `http://localhost:4020`
|
|
29
|
+
|
|
30
|
+
Typical flow:
|
|
31
|
+
1. Initialize payment from your backend.
|
|
32
|
+
2. Open the hosted checkout link in the browser.
|
|
33
|
+
3. Complete payment in the mock checkout UI.
|
|
34
|
+
4. Verify from your backend.
|
|
35
|
+
|
|
36
|
+
## CLI Commands
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
mockpay start
|
|
40
|
+
mockpay stop
|
|
41
|
+
mockpay status
|
|
42
|
+
mockpay pay success|fail|cancel
|
|
43
|
+
mockpay error 500|timeout|network
|
|
44
|
+
mockpay webhook resend
|
|
45
|
+
mockpay webhook config --delay 1000 --retry 2 --retry-delay 2000 --duplicate --drop
|
|
46
|
+
mockpay reset
|
|
47
|
+
mockpay logs
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Notes:
|
|
51
|
+
- `mockpay pay ...` applies to the next payment only, then resets to `success`.
|
|
52
|
+
- `mockpay error ...` applies to the next API request only, then resets to `none`.
|
|
53
|
+
- `mockpay logs` streams live logs over SSE from the Paystack server.
|
|
54
|
+
|
|
55
|
+
## Environment
|
|
56
|
+
|
|
57
|
+
Copy `.env.example` to `.env` and adjust if needed.
|
|
58
|
+
|
|
59
|
+
Key settings:
|
|
60
|
+
- `MOCKPAY_PAYSTACK_PORT`
|
|
61
|
+
- `MOCKPAY_FLUTTERWAVE_PORT`
|
|
62
|
+
- `MOCKPAY_FRONTEND_URL`
|
|
63
|
+
- `MOCKPAY_DATA_DIR`
|
|
64
|
+
- `MOCKPAY_DEFAULT_WEBHOOK_URL`
|
|
65
|
+
|
|
66
|
+
Webhook controls:
|
|
67
|
+
- `MOCKPAY_WEBHOOK_DELAY_MS`
|
|
68
|
+
- `MOCKPAY_WEBHOOK_RETRY_COUNT`
|
|
69
|
+
- `MOCKPAY_WEBHOOK_RETRY_DELAY_MS`
|
|
70
|
+
- `MOCKPAY_WEBHOOK_DUPLICATE`
|
|
71
|
+
- `MOCKPAY_WEBHOOK_DROP`
|
|
72
|
+
|
|
73
|
+
## API Coverage
|
|
74
|
+
|
|
75
|
+
### Paystack
|
|
76
|
+
- `POST /transaction/initialize`
|
|
77
|
+
- `GET /transaction/verify/:reference`
|
|
78
|
+
- `POST /transaction/verify/:reference`
|
|
79
|
+
- `POST /transfer`
|
|
80
|
+
- `GET /banks`
|
|
81
|
+
|
|
82
|
+
### Flutterwave
|
|
83
|
+
- `POST /payments`
|
|
84
|
+
- `GET /transactions/verify_by_reference?tx_ref=...`
|
|
85
|
+
- `GET /transactions/:id/verify`
|
|
86
|
+
- `POST /transfers`
|
|
87
|
+
|
|
88
|
+
## Payment Flow
|
|
89
|
+
|
|
90
|
+
### Paystack-style flow
|
|
91
|
+
1. `POST /transaction/initialize`
|
|
92
|
+
2. Open `data.authorization_url`
|
|
93
|
+
3. Complete payment on checkout (`success` / `failed` / `cancelled`)
|
|
94
|
+
4. Verify on your backend with `/transaction/verify/:reference`
|
|
95
|
+
|
|
96
|
+
### Flutterwave-style flow
|
|
97
|
+
1. `POST /payments`
|
|
98
|
+
2. Open `data.link`
|
|
99
|
+
3. Complete payment on checkout (`success` / `failed` / `cancelled`)
|
|
100
|
+
4. Verify on your backend using:
|
|
101
|
+
- `/transactions/verify_by_reference?tx_ref=...` or
|
|
102
|
+
- `/transactions/:id/verify`
|
|
103
|
+
|
|
104
|
+
## Example Requests
|
|
105
|
+
|
|
106
|
+
### Paystack initialize
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
curl -X POST http://localhost:4010/transaction/initialize \
|
|
110
|
+
-H "Content-Type: application/json" \
|
|
111
|
+
-d '{
|
|
112
|
+
"amount": 5000,
|
|
113
|
+
"currency": "NGN",
|
|
114
|
+
"email": "test@example.com",
|
|
115
|
+
"name": "Ada Lovelace",
|
|
116
|
+
"callback_url": "http://localhost:3000/paystack/callback"
|
|
117
|
+
}'
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Paystack verify
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
curl http://localhost:4010/transaction/verify/PSK_1234567890_abcdef
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Flutterwave payments
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
curl -X POST http://localhost:4020/payments \
|
|
130
|
+
-H "Content-Type: application/json" \
|
|
131
|
+
-d '{
|
|
132
|
+
"amount": 5000,
|
|
133
|
+
"currency": "NGN",
|
|
134
|
+
"customer": {
|
|
135
|
+
"email": "test@example.com",
|
|
136
|
+
"name": "Ada Lovelace"
|
|
137
|
+
},
|
|
138
|
+
"redirect_url": "http://localhost:3000/flutterwave/callback"
|
|
139
|
+
}'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Flutterwave verify by reference
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
curl "http://localhost:4020/transactions/verify_by_reference?tx_ref=FLW_1234567890_abcdef"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Flutterwave verify by id
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
curl "http://localhost:4020/transactions/<transaction_id>/verify"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Error Simulation
|
|
155
|
+
|
|
156
|
+
Simulate one request failure:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
mockpay error 500
|
|
160
|
+
mockpay error timeout
|
|
161
|
+
mockpay error network
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Notes:
|
|
165
|
+
- `network` will drop the socket without a response.
|
|
166
|
+
- `timeout` waits 15 seconds before responding with `504`.
|
|
167
|
+
|
|
168
|
+
## Webhooks
|
|
169
|
+
|
|
170
|
+
Set a default webhook URL:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
MOCKPAY_DEFAULT_WEBHOOK_URL=http://localhost:3000/webhooks/mockpay
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Configure behavior at runtime:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
mockpay webhook config --delay 1000 --retry 2 --retry-delay 2000 --duplicate --drop
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Resend last webhook:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
mockpay webhook resend
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
npm install
|
|
192
|
+
npm run dev
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Building the Hosted Checkout
|
|
196
|
+
|
|
197
|
+
The checkout UI is in `template/` and should be built before publishing:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npm --prefix template install
|
|
201
|
+
npm --prefix template run build
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Notes
|
|
205
|
+
|
|
206
|
+
- ChronoDB is used for file-based persistence. Data is stored under `MOCKPAY_DATA_DIR` (default `.mockpay/data`).
|
|
207
|
+
- Hosted checkout URLs include transaction details (`ref`, `amount`, `currency`, `email`, `name`, provider).
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import { setNextPaymentResult, setNextError, getWebhookConfig, setWebhookConfig } from "../core/state.js";
|
|
9
|
+
import { readRuntime, writeRuntime, clearRuntime, isPidRunning } from "../core/runtime.js";
|
|
10
|
+
import { resendLastWebhook } from "../webhooks/sender.js";
|
|
11
|
+
import { getCollections, getDb } from "../core/db.js";
|
|
12
|
+
import { getConfig } from "../core/config.js";
|
|
13
|
+
const program = new Command();
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
program
|
|
17
|
+
.name("mockpay")
|
|
18
|
+
.description("Local Paystack + Flutterwave mock servers")
|
|
19
|
+
.version("0.1.0");
|
|
20
|
+
program
|
|
21
|
+
.command("start")
|
|
22
|
+
.description("Start mock servers")
|
|
23
|
+
.action(async () => {
|
|
24
|
+
const runtime = await readRuntime();
|
|
25
|
+
if (runtime && isPidRunning(runtime.pid)) {
|
|
26
|
+
console.log(chalk.yellow(`Mockpay already running (pid ${runtime.pid})`));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const config = getConfig();
|
|
30
|
+
const isHealthy = async () => {
|
|
31
|
+
const targets = [
|
|
32
|
+
`http://localhost:${config.paystackPort}/__health`,
|
|
33
|
+
`http://localhost:${config.flutterwavePort}/__health`
|
|
34
|
+
];
|
|
35
|
+
for (const url of targets) {
|
|
36
|
+
try {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timeout = setTimeout(() => controller.abort(), 1000);
|
|
39
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
if (res.ok)
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
};
|
|
50
|
+
if (await isHealthy()) {
|
|
51
|
+
console.log(chalk.yellow("Mockpay already running"));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const jsServerPath = path.resolve(__dirname, "..", "server", "index.js");
|
|
55
|
+
const tsServerPath = path.resolve(__dirname, "..", "server", "index.ts");
|
|
56
|
+
const serverPath = fs.existsSync(jsServerPath) ? jsServerPath : tsServerPath;
|
|
57
|
+
const child = spawn(process.execPath, [...process.execArgv, serverPath], {
|
|
58
|
+
detached: true,
|
|
59
|
+
stdio: "ignore",
|
|
60
|
+
env: {
|
|
61
|
+
...process.env,
|
|
62
|
+
MOCKPAY_DATA_DIR: config.dataDir
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
child.unref();
|
|
66
|
+
if (!child.pid) {
|
|
67
|
+
console.log(chalk.red("Failed to start mock servers"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await writeRuntime(child.pid, config.dataDir);
|
|
71
|
+
console.log(chalk.green(`Mockpay started (pid ${child.pid})`));
|
|
72
|
+
console.log(chalk.gray(`Paystack: http://localhost:${config.paystackPort}`));
|
|
73
|
+
console.log(chalk.gray(`Flutterwave: http://localhost:${config.flutterwavePort}`));
|
|
74
|
+
});
|
|
75
|
+
program
|
|
76
|
+
.command("stop")
|
|
77
|
+
.description("Stop mock servers")
|
|
78
|
+
.action(async () => {
|
|
79
|
+
const runtime = await readRuntime();
|
|
80
|
+
if (!runtime || !isPidRunning(runtime.pid)) {
|
|
81
|
+
console.log(chalk.yellow("Mockpay is not running"));
|
|
82
|
+
await clearRuntime();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
process.kill(runtime.pid);
|
|
87
|
+
await clearRuntime();
|
|
88
|
+
console.log(chalk.green("Mockpay stopped"));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
console.log(chalk.red(`Failed to stop process ${runtime.pid}`));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
program
|
|
95
|
+
.command("status")
|
|
96
|
+
.description("Show running services")
|
|
97
|
+
.action(async () => {
|
|
98
|
+
const config = getConfig();
|
|
99
|
+
const targets = [
|
|
100
|
+
{ name: "Paystack", url: `http://localhost:${config.paystackPort}/__health` },
|
|
101
|
+
{ name: "Flutterwave", url: `http://localhost:${config.flutterwavePort}/__health` }
|
|
102
|
+
];
|
|
103
|
+
const results = await Promise.all(targets.map(async (target) => {
|
|
104
|
+
try {
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
107
|
+
const res = await fetch(target.url, { signal: controller.signal });
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
return { name: target.name, ok: res.ok };
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return { name: target.name, ok: false };
|
|
113
|
+
}
|
|
114
|
+
}));
|
|
115
|
+
const running = results.filter((r) => r.ok);
|
|
116
|
+
if (running.length === 0) {
|
|
117
|
+
console.log(chalk.yellow("Not running"));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
running.forEach((r) => console.log(chalk.green(`${r.name} running`)));
|
|
121
|
+
});
|
|
122
|
+
program
|
|
123
|
+
.command("pay")
|
|
124
|
+
.description("Set next payment result")
|
|
125
|
+
.argument("<result>", "success|fail|cancel")
|
|
126
|
+
.action(async (result) => {
|
|
127
|
+
const runtime = await readRuntime();
|
|
128
|
+
if (runtime?.dataDir)
|
|
129
|
+
process.env.MOCKPAY_DATA_DIR = runtime.dataDir;
|
|
130
|
+
const map = {
|
|
131
|
+
success: "success",
|
|
132
|
+
fail: "failed",
|
|
133
|
+
cancel: "cancelled"
|
|
134
|
+
};
|
|
135
|
+
const mapped = map[result];
|
|
136
|
+
if (!mapped) {
|
|
137
|
+
console.log(chalk.red("Expected success|fail|cancel"));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await setNextPaymentResult(mapped);
|
|
141
|
+
console.log(chalk.green(`Next payment result set to ${mapped}`));
|
|
142
|
+
});
|
|
143
|
+
program
|
|
144
|
+
.command("error")
|
|
145
|
+
.description("Simulate next request failure")
|
|
146
|
+
.argument("<type>", "500|timeout|network")
|
|
147
|
+
.action(async (type) => {
|
|
148
|
+
const runtime = await readRuntime();
|
|
149
|
+
if (runtime?.dataDir)
|
|
150
|
+
process.env.MOCKPAY_DATA_DIR = runtime.dataDir;
|
|
151
|
+
const allowed = ["500", "timeout", "network"];
|
|
152
|
+
if (!allowed.includes(type)) {
|
|
153
|
+
console.log(chalk.red("Expected 500|timeout|network"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
await setNextError(type);
|
|
157
|
+
console.log(chalk.green(`Next error set to ${type}`));
|
|
158
|
+
});
|
|
159
|
+
const webhook = program.command("webhook").description("Webhook actions");
|
|
160
|
+
webhook
|
|
161
|
+
.command("resend")
|
|
162
|
+
.description("Resend last webhook")
|
|
163
|
+
.action(async () => {
|
|
164
|
+
const runtime = await readRuntime();
|
|
165
|
+
if (runtime?.dataDir)
|
|
166
|
+
process.env.MOCKPAY_DATA_DIR = runtime.dataDir;
|
|
167
|
+
const ok = await resendLastWebhook();
|
|
168
|
+
if (ok) {
|
|
169
|
+
console.log(chalk.green("Webhook resent"));
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
console.log(chalk.yellow("No webhook to resend"));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
webhook
|
|
176
|
+
.command("config")
|
|
177
|
+
.description("View or update webhook behavior")
|
|
178
|
+
.option("--delay <ms>", "Delay before sending")
|
|
179
|
+
.option("--retry <count>", "Retry count")
|
|
180
|
+
.option("--retry-delay <ms>", "Retry delay")
|
|
181
|
+
.option("--duplicate", "Send duplicate webhook")
|
|
182
|
+
.option("--no-duplicate", "Disable duplicate webhook")
|
|
183
|
+
.option("--drop", "Drop webhook")
|
|
184
|
+
.option("--no-drop", "Disable drop")
|
|
185
|
+
.action(async (options) => {
|
|
186
|
+
const runtime = await readRuntime();
|
|
187
|
+
if (runtime?.dataDir)
|
|
188
|
+
process.env.MOCKPAY_DATA_DIR = runtime.dataDir;
|
|
189
|
+
const current = await getWebhookConfig();
|
|
190
|
+
const updated = {
|
|
191
|
+
delayMs: options.delay ? Number(options.delay) : current.delayMs,
|
|
192
|
+
retryCount: options.retry ? Number(options.retry) : current.retryCount,
|
|
193
|
+
retryDelayMs: options.retryDelay ? Number(options.retryDelay) : current.retryDelayMs,
|
|
194
|
+
duplicate: typeof options.duplicate === "boolean" ? options.duplicate : current.duplicate,
|
|
195
|
+
drop: typeof options.drop === "boolean" ? options.drop : current.drop
|
|
196
|
+
};
|
|
197
|
+
await setWebhookConfig(updated);
|
|
198
|
+
console.log(chalk.green("Webhook config updated"));
|
|
199
|
+
console.log(updated);
|
|
200
|
+
});
|
|
201
|
+
program
|
|
202
|
+
.command("reset")
|
|
203
|
+
.description("Clear ChronoDB data")
|
|
204
|
+
.action(async () => {
|
|
205
|
+
const runtime = await readRuntime();
|
|
206
|
+
if (runtime?.dataDir)
|
|
207
|
+
process.env.MOCKPAY_DATA_DIR = runtime.dataDir;
|
|
208
|
+
const { transactions, transfers, webhooks, settings, logs } = await getCollections();
|
|
209
|
+
await transactions.deleteAll();
|
|
210
|
+
await transfers.deleteAll();
|
|
211
|
+
await webhooks.deleteAll();
|
|
212
|
+
await settings.deleteAll();
|
|
213
|
+
await logs.deleteAll();
|
|
214
|
+
const db = await getDb();
|
|
215
|
+
if (db?.snapshots?.deleteAll) {
|
|
216
|
+
await db.snapshots.deleteAll();
|
|
217
|
+
}
|
|
218
|
+
console.log(chalk.green("Database cleared"));
|
|
219
|
+
});
|
|
220
|
+
program
|
|
221
|
+
.command("logs")
|
|
222
|
+
.description("Stream live logs")
|
|
223
|
+
.action(async () => {
|
|
224
|
+
const runtime = await readRuntime();
|
|
225
|
+
if (runtime?.dataDir)
|
|
226
|
+
process.env.MOCKPAY_DATA_DIR = runtime.dataDir;
|
|
227
|
+
const config = getConfig();
|
|
228
|
+
const url = `http://localhost:${config.paystackPort}/__logs`;
|
|
229
|
+
console.log(chalk.gray(`Streaming logs from ${url}`));
|
|
230
|
+
try {
|
|
231
|
+
const res = await fetch(url, {
|
|
232
|
+
headers: {
|
|
233
|
+
Accept: "text/event-stream"
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
if (!res.body) {
|
|
237
|
+
console.log(chalk.red("No log stream available"));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const reader = res.body.getReader();
|
|
241
|
+
let buffer = "";
|
|
242
|
+
while (true) {
|
|
243
|
+
const { done, value } = await reader.read();
|
|
244
|
+
if (done)
|
|
245
|
+
break;
|
|
246
|
+
buffer += new TextDecoder().decode(value);
|
|
247
|
+
const parts = buffer.split("\n\n");
|
|
248
|
+
buffer = parts.pop() ?? "";
|
|
249
|
+
for (const part of parts) {
|
|
250
|
+
const line = part.trim();
|
|
251
|
+
if (!line.startsWith("data:"))
|
|
252
|
+
continue;
|
|
253
|
+
const payload = line.replace(/^data:\s*/, "");
|
|
254
|
+
try {
|
|
255
|
+
const entry = JSON.parse(payload);
|
|
256
|
+
const time = new Date(entry.timestamp).toLocaleTimeString();
|
|
257
|
+
const prefix = entry.source ? `[${entry.source}] ` : "";
|
|
258
|
+
console.log(`${chalk.gray(time)} ${prefix}${entry.message}`);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// ignore parse issues
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
console.log(chalk.red("Unable to connect to log stream. Is mockpay running?"));
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
paystackPort: number;
|
|
3
|
+
flutterwavePort: number;
|
|
4
|
+
dataDir: string;
|
|
5
|
+
frontendUrl?: string;
|
|
6
|
+
defaultWebhookUrl?: string;
|
|
7
|
+
webhookDelayMs: number;
|
|
8
|
+
webhookRetryCount: number;
|
|
9
|
+
webhookRetryDelayMs: number;
|
|
10
|
+
webhookDuplicate: boolean;
|
|
11
|
+
webhookDrop: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function getConfig(): Config;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import path from "path";
|
|
3
|
+
dotenv.config();
|
|
4
|
+
function toBool(value, fallback) {
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return fallback;
|
|
7
|
+
return value.toLowerCase() === "true";
|
|
8
|
+
}
|
|
9
|
+
function toNum(value, fallback) {
|
|
10
|
+
const parsed = Number(value);
|
|
11
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
12
|
+
}
|
|
13
|
+
export function getConfig() {
|
|
14
|
+
const baseDir = process.cwd();
|
|
15
|
+
const dataDir = process.env.MOCKPAY_DATA_DIR
|
|
16
|
+
? path.resolve(baseDir, process.env.MOCKPAY_DATA_DIR)
|
|
17
|
+
: path.resolve(baseDir, ".mockpay", "data");
|
|
18
|
+
return {
|
|
19
|
+
paystackPort: toNum(process.env.MOCKPAY_PAYSTACK_PORT, 4010),
|
|
20
|
+
flutterwavePort: toNum(process.env.MOCKPAY_FLUTTERWAVE_PORT, 4020),
|
|
21
|
+
dataDir,
|
|
22
|
+
frontendUrl: process.env.MOCKPAY_FRONTEND_URL || undefined,
|
|
23
|
+
defaultWebhookUrl: process.env.MOCKPAY_DEFAULT_WEBHOOK_URL || undefined,
|
|
24
|
+
webhookDelayMs: toNum(process.env.MOCKPAY_WEBHOOK_DELAY_MS, 1500),
|
|
25
|
+
webhookRetryCount: toNum(process.env.MOCKPAY_WEBHOOK_RETRY_COUNT, 0),
|
|
26
|
+
webhookRetryDelayMs: toNum(process.env.MOCKPAY_WEBHOOK_RETRY_DELAY_MS, 2000),
|
|
27
|
+
webhookDuplicate: toBool(process.env.MOCKPAY_WEBHOOK_DUPLICATE, false),
|
|
28
|
+
webhookDrop: toBool(process.env.MOCKPAY_WEBHOOK_DROP, false)
|
|
29
|
+
};
|
|
30
|
+
}
|
package/dist/core/db.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import ChronoDB from "chronodb";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import { getConfig } from "./config.js";
|
|
4
|
+
let dbPromise = null;
|
|
5
|
+
let collectionsPromise = null;
|
|
6
|
+
export async function getDb() {
|
|
7
|
+
if (!dbPromise) {
|
|
8
|
+
dbPromise = openDb();
|
|
9
|
+
}
|
|
10
|
+
return dbPromise;
|
|
11
|
+
}
|
|
12
|
+
export async function getCollections() {
|
|
13
|
+
if (!collectionsPromise) {
|
|
14
|
+
collectionsPromise = initCollections();
|
|
15
|
+
}
|
|
16
|
+
return collectionsPromise;
|
|
17
|
+
}
|
|
18
|
+
async function openDb() {
|
|
19
|
+
const { dataDir } = getConfig();
|
|
20
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
21
|
+
try {
|
|
22
|
+
return await ChronoDB.open({ path: dataDir, cloudSync: false });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
const previous = process.cwd();
|
|
26
|
+
process.chdir(dataDir);
|
|
27
|
+
try {
|
|
28
|
+
return await ChronoDB.open({ cloudSync: false });
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
process.chdir(previous);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function initCollections() {
|
|
36
|
+
const db = await getDb();
|
|
37
|
+
const transactions = db.col("transactions", {
|
|
38
|
+
schema: {
|
|
39
|
+
createdAt: { type: "string" },
|
|
40
|
+
updatedAt: { type: "string" },
|
|
41
|
+
provider: { type: "string", important: true },
|
|
42
|
+
reference: { type: "string", important: true, distinct: true },
|
|
43
|
+
status: { type: "string", important: true },
|
|
44
|
+
amount: { type: "number", important: true },
|
|
45
|
+
currency: { type: "string", default: "NGN" },
|
|
46
|
+
customerEmail: { type: "string", important: true },
|
|
47
|
+
customerName: { type: "string", nullable: true },
|
|
48
|
+
callbackUrl: { type: "string", nullable: true },
|
|
49
|
+
metadata: { type: "string", nullable: true }
|
|
50
|
+
},
|
|
51
|
+
indexes: ["provider", "reference", "status"]
|
|
52
|
+
});
|
|
53
|
+
const transfers = db.col("transfers", {
|
|
54
|
+
schema: {
|
|
55
|
+
createdAt: { type: "string" },
|
|
56
|
+
updatedAt: { type: "string" },
|
|
57
|
+
provider: { type: "string", important: true },
|
|
58
|
+
reference: { type: "string", important: true, distinct: true },
|
|
59
|
+
status: { type: "string", important: true },
|
|
60
|
+
amount: { type: "number", important: true },
|
|
61
|
+
currency: { type: "string", default: "NGN" },
|
|
62
|
+
bankCode: { type: "string", nullable: true },
|
|
63
|
+
accountNumber: { type: "string", nullable: true },
|
|
64
|
+
narration: { type: "string", nullable: true },
|
|
65
|
+
metadata: { type: "string", nullable: true }
|
|
66
|
+
},
|
|
67
|
+
indexes: ["provider", "reference", "status"]
|
|
68
|
+
});
|
|
69
|
+
const webhooks = db.col("webhooks", {
|
|
70
|
+
schema: {
|
|
71
|
+
createdAt: { type: "string" },
|
|
72
|
+
updatedAt: { type: "string" },
|
|
73
|
+
provider: { type: "string", important: true },
|
|
74
|
+
event: { type: "string", important: true },
|
|
75
|
+
url: { type: "string", important: true },
|
|
76
|
+
status: { type: "string", important: true },
|
|
77
|
+
attempts: { type: "number", default: 0 },
|
|
78
|
+
payload: { type: "string", important: true },
|
|
79
|
+
lastAttemptAt: { type: "number", nullable: true }
|
|
80
|
+
},
|
|
81
|
+
indexes: ["provider", "event", "status"]
|
|
82
|
+
});
|
|
83
|
+
const settings = db.col("settings", {
|
|
84
|
+
schema: {
|
|
85
|
+
createdAt: { type: "string" },
|
|
86
|
+
updatedAt: { type: "string" },
|
|
87
|
+
key: { type: "string", important: true, distinct: true },
|
|
88
|
+
value: { type: "string", important: true }
|
|
89
|
+
},
|
|
90
|
+
indexes: ["key"]
|
|
91
|
+
});
|
|
92
|
+
const logs = db.col("logs", {
|
|
93
|
+
schema: {
|
|
94
|
+
createdAt: { type: "string" },
|
|
95
|
+
updatedAt: { type: "string" },
|
|
96
|
+
level: { type: "string", important: true },
|
|
97
|
+
message: { type: "string", important: true },
|
|
98
|
+
source: { type: "string", nullable: true },
|
|
99
|
+
timestamp: { type: "number", important: true }
|
|
100
|
+
},
|
|
101
|
+
indexes: ["level", "timestamp"]
|
|
102
|
+
});
|
|
103
|
+
return { transactions, transfers, webhooks, settings, logs };
|
|
104
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import type { LogEntry } from "../types/index.js";
|
|
3
|
+
export type LogLevel = "info" | "warn" | "error" | "http";
|
|
4
|
+
export declare const logger: {
|
|
5
|
+
info: (message: string, source?: string) => void;
|
|
6
|
+
warn: (message: string, source?: string) => void;
|
|
7
|
+
error: (message: string, source?: string) => void;
|
|
8
|
+
http: (message: string, source?: string) => void;
|
|
9
|
+
on: (handler: (entry: LogEntry) => void) => EventEmitter<[never]>;
|
|
10
|
+
off: (handler: (entry: LogEntry) => void) => EventEmitter<[never]>;
|
|
11
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
import { getCollections } from "./db.js";
|
|
4
|
+
const emitter = new EventEmitter();
|
|
5
|
+
function colorize(level, message) {
|
|
6
|
+
switch (level) {
|
|
7
|
+
case "info":
|
|
8
|
+
return chalk.cyan(message);
|
|
9
|
+
case "warn":
|
|
10
|
+
return chalk.yellow(message);
|
|
11
|
+
case "error":
|
|
12
|
+
return chalk.red(message);
|
|
13
|
+
case "http":
|
|
14
|
+
return chalk.green(message);
|
|
15
|
+
default:
|
|
16
|
+
return message;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function persist(entry) {
|
|
20
|
+
try {
|
|
21
|
+
const { logs } = await getCollections();
|
|
22
|
+
await logs.add(entry);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Best-effort logging only.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function log(level, message, source) {
|
|
29
|
+
const entry = {
|
|
30
|
+
level,
|
|
31
|
+
message,
|
|
32
|
+
source,
|
|
33
|
+
timestamp: Date.now()
|
|
34
|
+
};
|
|
35
|
+
const prefix = source ? `[${source}] ` : "";
|
|
36
|
+
console.log(colorize(level, `${prefix}${message}`));
|
|
37
|
+
emitter.emit("log", entry);
|
|
38
|
+
void persist(entry);
|
|
39
|
+
}
|
|
40
|
+
export const logger = {
|
|
41
|
+
info: (message, source) => log("info", message, source),
|
|
42
|
+
warn: (message, source) => log("warn", message, source),
|
|
43
|
+
error: (message, source) => log("error", message, source),
|
|
44
|
+
http: (message, source) => log("http", message, source),
|
|
45
|
+
on: (handler) => emitter.on("log", handler),
|
|
46
|
+
off: (handler) => emitter.off("log", handler)
|
|
47
|
+
};
|