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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenPay NG contributors
|
|
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,222 @@
|
|
|
1
|
+
# OpenPay NG
|
|
2
|
+
|
|
3
|
+
One consistent TypeScript interface for Nigerian payment providers. OpenPay is
|
|
4
|
+
a developer-friendly abstraction layer — not a replacement for Paystack,
|
|
5
|
+
Flutterwave, or Bachs. You write provider-agnostic payment code; OpenPay
|
|
6
|
+
handles provider-specific differences underneath.
|
|
7
|
+
|
|
8
|
+
> Sandbox-proven with Paystack and Flutterwave (create → checkout → verify →
|
|
9
|
+
> real webhook). Not production-hardened — see Current limitations.
|
|
10
|
+
|
|
11
|
+
## 1. Installation
|
|
12
|
+
|
|
13
|
+
Requires Node.js 20+.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install openpay-ng
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
PostgreSQL is optional but recommended: without `DATABASE_URL` the SDK runs
|
|
20
|
+
stateless (no idempotency cache, no webhook/event persistence).
|
|
21
|
+
|
|
22
|
+
## 2. Quick start
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { OpenPay } from "openpay-ng";
|
|
26
|
+
|
|
27
|
+
const openpay = new OpenPay({
|
|
28
|
+
provider: "paystack",
|
|
29
|
+
secretKey: process.env.PAYSTACK_SECRET_KEY!,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const payment = await openpay.payments.create({
|
|
33
|
+
amount: "5000.00", // decimal string, never a float — see Idempotency/Money note
|
|
34
|
+
currency: "NGN",
|
|
35
|
+
customer: { email: "customer@example.com" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
console.log(payment.checkoutUrl); // send the customer here to pay
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 3. Configuration
|
|
42
|
+
|
|
43
|
+
Single-provider shorthand:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
new OpenPay({ provider: "paystack", secretKey: "..." });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Multi-provider / explicit form (an explicit per-provider block always wins):
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
new OpenPay({
|
|
53
|
+
defaultProvider: "paystack",
|
|
54
|
+
paystack: { secretKey: "..." },
|
|
55
|
+
flutterwave: { secretKey: "...", webhookSecretHash: "..." },
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Keys resolve explicit-config → environment, so `PAYSTACK_SECRET_KEY`,
|
|
60
|
+
`FLUTTERWAVE_SECRET_KEY`, and `BACHS_SECRET_KEY` work with no config at all.
|
|
61
|
+
`openpay.configuredProviders()` lists what's actually wired up. Unconfigured
|
|
62
|
+
providers throw `ConfigurationError` only when used.
|
|
63
|
+
|
|
64
|
+
## 4. Creating a payment
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const payment = await openpay.payments.create({
|
|
68
|
+
amount: "5000.00",
|
|
69
|
+
currency: "NGN",
|
|
70
|
+
customer: { email: "customer@example.com", name: "Ada", phone: "0803..." },
|
|
71
|
+
provider: "flutterwave", // optional: overrides the default provider
|
|
72
|
+
reference: "order_123", // optional: your unique reference (generated if omitted)
|
|
73
|
+
idempotencyKey: "…", // optional: defaults to operation+provider+reference
|
|
74
|
+
redirectUrl: "https://yourapp.com/pay/callback", // REQUIRED by Flutterwave
|
|
75
|
+
metadata: { plan: "pro" }, // optional string map
|
|
76
|
+
});
|
|
77
|
+
// payment: { id, provider, reference, providerRef, amount, currency,
|
|
78
|
+
// status: "pending", checkoutUrl?, customerEmail?, raw? }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 5. Verifying a payment
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const verified = await openpay.payments.verify({ reference: payment.reference });
|
|
85
|
+
// or: openpay.payments.verify({ reference, provider: "flutterwave" });
|
|
86
|
+
// verified.status: "pending" | "successful" | "failed" | "unknown"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`unknown` is **not** `failed`: it means the outcome couldn't be determined
|
|
90
|
+
(e.g. provider timeout). Verify again before retrying — never treat it as a
|
|
91
|
+
failure. `payments.retrieve()` is an alias of `verify()`.
|
|
92
|
+
|
|
93
|
+
Note the two verify shapes in V1: `payments.verify({ reference, provider? })`
|
|
94
|
+
takes an input object, while `transfers.verify(reference, provider?)` takes
|
|
95
|
+
positional arguments. Both default to the configured default provider.
|
|
96
|
+
|
|
97
|
+
## 6. Webhooks
|
|
98
|
+
|
|
99
|
+
Point the provider dashboard at your receiver (`POST /webhooks/paystack`,
|
|
100
|
+
`POST /webhooks/flutterwave`), then feed the **raw** body plus headers into
|
|
101
|
+
the SDK — raw bytes matter because signature checks run over them:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// Express-style example (use a raw-body parser, not express.json()).
|
|
105
|
+
// Node/Express req.headers can be passed directly, including repeated headers.
|
|
106
|
+
app.post("/webhooks/paystack", async (req, res) => {
|
|
107
|
+
const event = await openpay.webhooks.normalize("paystack", req.body, req.headers);
|
|
108
|
+
// event: { type: "payment.succeeded" | …, provider, reference?, … } | null
|
|
109
|
+
res.status(200).json({ ok: true });
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`normalizeDetailed()` additionally reports `{ event, duplicate, projected }`
|
|
114
|
+
for retried deliveries. A dependency-free reference receiver ships with the
|
|
115
|
+
repo (`npm run receiver`, local dev only — not part of the published API).
|
|
116
|
+
|
|
117
|
+
Persistence-dependent webhook behavior requires `DATABASE_URL`. Without it
|
|
118
|
+
(stateless mode) webhooks still verify and normalize, but events are not
|
|
119
|
+
stored, retries are not deduplicated (`duplicate` is always false), and
|
|
120
|
+
payment rows are not projected. The same applies to idempotency below.
|
|
121
|
+
|
|
122
|
+
## 7. Idempotency
|
|
123
|
+
|
|
124
|
+
Repeated `payments.create()` calls with the same reference/idempotency key
|
|
125
|
+
return the cached result without a second provider charge. This requires
|
|
126
|
+
`DATABASE_URL` (first write wins); in stateless mode every call reaches the
|
|
127
|
+
provider. Webhook retries collapse via a unique `(provider, type, event)`
|
|
128
|
+
record — same requirement: without persistence there is no stored record to
|
|
129
|
+
deduplicate against.
|
|
130
|
+
|
|
131
|
+
Money is always a decimal string (`"5000.00"`, ISO-4217 currency). The
|
|
132
|
+
`normalizeAmount()` helper is exported for convenience.
|
|
133
|
+
|
|
134
|
+
## 8. Supported providers
|
|
135
|
+
|
|
136
|
+
| Provider | Create/verify | Webhooks | Notes |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| Paystack | ✅ sandbox-proven | ✅ sandbox-proven (HMAC-SHA512) | Full V1 surface incl. refunds/transfers (code; transfers/refunds not live-tested) |
|
|
139
|
+
| Flutterwave | ✅ sandbox-proven | ✅ sandbox-proven (`verif-hash`) | `redirectUrl` required on create; refunds need the numeric transaction id; refunds/transfers not live-tested |
|
|
140
|
+
| Bachs | checkout-session based | provisional | Implemented, **not live-tested**; refunds/transfers throw `CapabilityError` |
|
|
141
|
+
|
|
142
|
+
## 9. Normalized payment statuses
|
|
143
|
+
|
|
144
|
+
Every provider maps to `pending | successful | failed | unknown`. Timeouts and
|
|
145
|
+
unrecognized provider states map to `unknown`. Webhook events project onto
|
|
146
|
+
payment rows: `payment.succeeded → successful`, `payment.failed → failed`;
|
|
147
|
+
`payment.pending` and non-payment events never regress a stored status.
|
|
148
|
+
Projection requires persistence (see Webhooks above).
|
|
149
|
+
|
|
150
|
+
### References: `reference` vs `providerRef`
|
|
151
|
+
|
|
152
|
+
Every payment carries two identifiers. `reference` is yours (the merchant
|
|
153
|
+
reference you passed, or a generated one). `providerRef` is the provider's
|
|
154
|
+
own identifier for the same payment, and it differs per provider:
|
|
155
|
+
|
|
156
|
+
- Paystack: `providerRef` echoes your reference.
|
|
157
|
+
- Flutterwave: `providerRef` is the provider's `flw_ref` at creation (falls back to your `tx_ref`), then the numeric
|
|
158
|
+
transaction id after verification.
|
|
159
|
+
- Bachs: `providerRef` is the `checkout_id`.
|
|
160
|
+
|
|
161
|
+
You need `providerRef` when correlating OpenPay records with provider
|
|
162
|
+
dashboard entries, and for provider-specific operations — e.g. Flutterwave
|
|
163
|
+
refunds address the numeric transaction id, so pass the `providerRef` you
|
|
164
|
+
received from `verify()` as `paymentReference`.
|
|
165
|
+
|
|
166
|
+
### Data stored in Postgres
|
|
167
|
+
|
|
168
|
+
Payment, transfer, refund, idempotency, and webhook rows persist the raw
|
|
169
|
+
provider responses (`jsonb`), which may include provider-returned customer
|
|
170
|
+
and payment metadata (e.g. customer email, card brand/last digits — never
|
|
171
|
+
full card numbers, which OpenPay never touches). This audit trail is what
|
|
172
|
+
powers idempotency, dedupe, and status projection. Storage only happens when
|
|
173
|
+
persistence is configured (`DATABASE_URL`); stateless mode stores nothing.
|
|
174
|
+
Apply your own retention and access policies to the database.
|
|
175
|
+
|
|
176
|
+
## 10. Environment variables
|
|
177
|
+
|
|
178
|
+
```env
|
|
179
|
+
DATABASE_URL=postgres://openpay:openpay@localhost:5432/openpay
|
|
180
|
+
PAYSTACK_SECRET_KEY=your_test_secret_key
|
|
181
|
+
FLUTTERWAVE_SECRET_KEY=your_test_secret_key
|
|
182
|
+
BACHS_SECRET_KEY=your_test_secret_key
|
|
183
|
+
PAYSTACK_WEBHOOK_SECRET=your_webhook_secret
|
|
184
|
+
FLUTTERWAVE_WEBHOOK_SECRET_HASH=your_webhook_secret
|
|
185
|
+
BACHS_WEBHOOK_SECRET=your_webhook_secret
|
|
186
|
+
OPENPAY_DEFAULT_PROVIDER=paystack
|
|
187
|
+
PORT=3000
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## 11. Sandbox/testing
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npm test # mocked unit suite, no network
|
|
194
|
+
npm run build # emits dist/
|
|
195
|
+
npm pack --dry-run # inspect publish contents (never publishes)
|
|
196
|
+
npm run smoke:consumer # builds, packs, installs into a temp project,
|
|
197
|
+
# exercises the public API + types with mocked HTTP
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Live sandbox checks need real test keys and (for webhooks) a public tunnel,
|
|
201
|
+
e.g. `ngrok http 3000`, with the dashboard URL pointed at
|
|
202
|
+
`https://<tunnel-host>/webhooks/<provider>`. Never commit real keys.
|
|
203
|
+
|
|
204
|
+
## 12. Current limitations
|
|
205
|
+
|
|
206
|
+
- Sandbox-proven only (Paystack + Flutterwave payments/webhooks). **Not
|
|
207
|
+
production readiness.**
|
|
208
|
+
- Bachs implemented but not live-tested; no refund/transfer support there.
|
|
209
|
+
- Flutterwave refunds/transfers implemented but not live-tested.
|
|
210
|
+
- Webhook events do not auto-create payment rows for unknown references.
|
|
211
|
+
- No routing/failover, card vaulting, dashboard, or multi-language SDKs (V1 scope).
|
|
212
|
+
|
|
213
|
+
## 13. Errors
|
|
214
|
+
|
|
215
|
+
All errors extend `OpenPayError` (with machine-readable `code`):
|
|
216
|
+
|
|
217
|
+
- `ValidationError` — bad input (e.g. non-decimal amount, Flutterwave create without `redirectUrl`)
|
|
218
|
+
- `ConfigurationError` — missing/unconfigured provider or keys
|
|
219
|
+
- `ProviderError` — the provider rejected the call (`status`, `retryable`, `raw` attached)
|
|
220
|
+
- `CapabilityError` — provider doesn't support the operation in V1
|
|
221
|
+
- `UnknownResultError` — ambiguous outcome; verify before retrying (`retryable: true`)
|
|
222
|
+
- `WebhookVerificationError` — signature/hash check failed
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { HttpClient, ProviderConnector } from "./connectors/types.js";
|
|
2
|
+
import type { ProviderName } from "./core/types.js";
|
|
3
|
+
import { type Persistence } from "./db/store.js";
|
|
4
|
+
export interface ProviderConf {
|
|
5
|
+
secretKey?: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
webhookSecret?: string;
|
|
8
|
+
webhookSecretHash?: string;
|
|
9
|
+
http?: HttpClient;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface OpenPayConfig {
|
|
13
|
+
defaultProvider?: ProviderName;
|
|
14
|
+
paystack?: ProviderConf;
|
|
15
|
+
flutterwave?: ProviderConf;
|
|
16
|
+
bachs?: ProviderConf;
|
|
17
|
+
/**
|
|
18
|
+
* Shorthand for single-provider setups. Equivalent to setting
|
|
19
|
+
* `defaultProvider` plus `{ secretKey }` on that provider's block;
|
|
20
|
+
* an explicit per-provider block always wins.
|
|
21
|
+
*
|
|
22
|
+
* new OpenPay({ provider: "paystack", secretKey: process.env.PAYSTACK_SECRET_KEY })
|
|
23
|
+
*/
|
|
24
|
+
provider?: ProviderName;
|
|
25
|
+
secretKey?: string;
|
|
26
|
+
/** Pass DATABASE_URL to enable Postgres persistence; omit for stateless/memory. */
|
|
27
|
+
databaseUrl?: string;
|
|
28
|
+
persistence?: Persistence;
|
|
29
|
+
http?: HttpClient;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ResolvedContext {
|
|
33
|
+
connectors: Record<ProviderName, ProviderConnector>;
|
|
34
|
+
defaultProvider: ProviderName;
|
|
35
|
+
persistence?: Persistence;
|
|
36
|
+
}
|
|
37
|
+
export declare function resolveContext(cfg?: OpenPayConfig): ResolvedContext;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { BachsConnector } from "./connectors/bachs.js";
|
|
2
|
+
import { FlutterwaveConnector } from "./connectors/flutterwave.js";
|
|
3
|
+
import { PaystackConnector } from "./connectors/paystack.js";
|
|
4
|
+
import { ConfigurationError } from "./core/errors.js";
|
|
5
|
+
import { getDb } from "./db/client.js";
|
|
6
|
+
import { DrizzlePersistence, MemoryPersistence } from "./db/store.js";
|
|
7
|
+
/** Env lookup that treats empty strings as unset (common in half-filled .env files). */
|
|
8
|
+
function env(name) {
|
|
9
|
+
const v = process.env[name];
|
|
10
|
+
return v && v.length > 0 ? v : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function resolveContext(cfg = {}) {
|
|
13
|
+
const defaultProvider = cfg.provider ?? cfg.defaultProvider ?? "paystack";
|
|
14
|
+
const connectors = {};
|
|
15
|
+
// Shorthand secret applies to the resolved default provider only, and loses
|
|
16
|
+
// to an explicit per-provider secretKey.
|
|
17
|
+
const shorthandKeyFor = (name) => name === defaultProvider ? cfg.secretKey : undefined;
|
|
18
|
+
const paystackKey = cfg.paystack?.secretKey || shorthandKeyFor("paystack") || env("PAYSTACK_SECRET_KEY");
|
|
19
|
+
const flwKey = cfg.flutterwave?.secretKey || shorthandKeyFor("flutterwave") || env("FLUTTERWAVE_SECRET_KEY");
|
|
20
|
+
const bachsKey = cfg.bachs?.secretKey || shorthandKeyFor("bachs") || env("BACHS_SECRET_KEY");
|
|
21
|
+
if (paystackKey) {
|
|
22
|
+
connectors.paystack = new PaystackConnector({
|
|
23
|
+
secretKey: paystackKey,
|
|
24
|
+
baseUrl: cfg.paystack?.baseUrl,
|
|
25
|
+
webhookSecret: cfg.paystack?.webhookSecret || env("PAYSTACK_WEBHOOK_SECRET"),
|
|
26
|
+
http: cfg.paystack?.http ?? cfg.http,
|
|
27
|
+
timeoutMs: cfg.paystack?.timeoutMs ?? cfg.timeoutMs,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (flwKey) {
|
|
31
|
+
connectors.flutterwave = new FlutterwaveConnector({
|
|
32
|
+
secretKey: flwKey,
|
|
33
|
+
baseUrl: cfg.flutterwave?.baseUrl,
|
|
34
|
+
webhookSecretHash: cfg.flutterwave?.webhookSecretHash || env("FLUTTERWAVE_WEBHOOK_SECRET_HASH"),
|
|
35
|
+
http: cfg.flutterwave?.http ?? cfg.http,
|
|
36
|
+
timeoutMs: cfg.flutterwave?.timeoutMs ?? cfg.timeoutMs,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (bachsKey) {
|
|
40
|
+
connectors.bachs = new BachsConnector({
|
|
41
|
+
secretKey: bachsKey,
|
|
42
|
+
baseUrl: cfg.bachs?.baseUrl,
|
|
43
|
+
webhookSecret: cfg.bachs?.webhookSecret || env("BACHS_WEBHOOK_SECRET"),
|
|
44
|
+
http: cfg.bachs?.http ?? cfg.http,
|
|
45
|
+
timeoutMs: cfg.bachs?.timeoutMs ?? cfg.timeoutMs,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (!connectors[defaultProvider]) {
|
|
49
|
+
const available = Object.keys(connectors).join(", ") || "none";
|
|
50
|
+
throw new ConfigurationError(`Default provider '${defaultProvider}' is not configured (available: ${available}). ` +
|
|
51
|
+
`Set ${defaultProvider.toUpperCase()}_SECRET_KEY or pass explicit keys.`);
|
|
52
|
+
}
|
|
53
|
+
// Fill missing connectors lazily? No — fail fast only for the default.
|
|
54
|
+
// Accessing an unconfigured provider later throws ConfigurationError.
|
|
55
|
+
let persistence = cfg.persistence;
|
|
56
|
+
const dbUrl = cfg.databaseUrl ?? process.env.DATABASE_URL;
|
|
57
|
+
if (!persistence && dbUrl) {
|
|
58
|
+
try {
|
|
59
|
+
persistence = new DrizzlePersistence(getDb(dbUrl));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
persistence = new MemoryPersistence();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Proxy missing connectors so errors stay typed at call time.
|
|
66
|
+
const handler = {
|
|
67
|
+
get(target, prop) {
|
|
68
|
+
const v = target[prop];
|
|
69
|
+
if (v)
|
|
70
|
+
return v;
|
|
71
|
+
if (prop === "then")
|
|
72
|
+
return undefined;
|
|
73
|
+
throw new ConfigurationError(`Provider '${prop}' is not configured. Set its secret key first.`);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const proxied = new Proxy(connectors, handler);
|
|
77
|
+
return { connectors: proxied, defaultProvider, persistence };
|
|
78
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 mapBachsPaymentStatus(s: string): PaymentStatus;
|
|
4
|
+
/**
|
|
5
|
+
* Bachs is checkout-session based, not charge based.
|
|
6
|
+
* createPayment -> POST /v1/checkout-sessions (pricing + customer + reference)
|
|
7
|
+
* verifyPayment -> GET /v1/checkout-sessions/:id (payment_status + charge)
|
|
8
|
+
* Refunds/transfers have no V1 equivalent -> CapabilityError.
|
|
9
|
+
*/
|
|
10
|
+
export declare class BachsConnector implements ProviderConnector {
|
|
11
|
+
readonly name: "bachs";
|
|
12
|
+
readonly capabilities: ProviderCapabilities;
|
|
13
|
+
private secretKey;
|
|
14
|
+
private baseUrl;
|
|
15
|
+
private timeoutMs;
|
|
16
|
+
private http;
|
|
17
|
+
private webhookSecret?;
|
|
18
|
+
constructor(cfg: ConnectorConfig & {
|
|
19
|
+
webhookSecret?: string;
|
|
20
|
+
});
|
|
21
|
+
private headers;
|
|
22
|
+
createPayment(input: CreatePaymentInput & {
|
|
23
|
+
reference: string;
|
|
24
|
+
}): Promise<Payment>;
|
|
25
|
+
verifyPayment(input: VerifyPaymentInput): Promise<Payment>;
|
|
26
|
+
createRefund(_input: CreateRefundInput): Promise<Refund>;
|
|
27
|
+
createTransfer(_input: CreateTransferInput & {
|
|
28
|
+
reference: string;
|
|
29
|
+
}): Promise<Transfer>;
|
|
30
|
+
verifyTransfer(_reference: string): Promise<Transfer>;
|
|
31
|
+
verifyWebhookSignature(rawBody: string | Buffer, headers: WebhookHeaders): boolean;
|
|
32
|
+
normalizeWebhook(payload: unknown): OpenPayEvent | null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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 } from "../core/validation.js";
|
|
5
|
+
import { firstHeader, providerJson } from "./http.js";
|
|
6
|
+
const SANDBOX_BASE = "https://sandbox-api.bachs.io";
|
|
7
|
+
const LIVE_BASE = "https://api.bachs.io";
|
|
8
|
+
export function mapBachsPaymentStatus(s) {
|
|
9
|
+
switch (s.toLowerCase()) {
|
|
10
|
+
case "succeeded":
|
|
11
|
+
case "paid":
|
|
12
|
+
case "completed":
|
|
13
|
+
case "success":
|
|
14
|
+
return "successful";
|
|
15
|
+
case "failed":
|
|
16
|
+
case "cancelled":
|
|
17
|
+
case "canceled":
|
|
18
|
+
case "expired":
|
|
19
|
+
return "failed";
|
|
20
|
+
case "pending":
|
|
21
|
+
case "processing":
|
|
22
|
+
case "requires_action":
|
|
23
|
+
case "requiresaction":
|
|
24
|
+
case "created":
|
|
25
|
+
return "pending";
|
|
26
|
+
default:
|
|
27
|
+
return "unknown";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Bachs is checkout-session based, not charge based.
|
|
32
|
+
* createPayment -> POST /v1/checkout-sessions (pricing + customer + reference)
|
|
33
|
+
* verifyPayment -> GET /v1/checkout-sessions/:id (payment_status + charge)
|
|
34
|
+
* Refunds/transfers have no V1 equivalent -> CapabilityError.
|
|
35
|
+
*/
|
|
36
|
+
export class BachsConnector {
|
|
37
|
+
name = "bachs";
|
|
38
|
+
capabilities = {
|
|
39
|
+
paymentsCreate: true,
|
|
40
|
+
paymentsVerify: true,
|
|
41
|
+
refunds: false,
|
|
42
|
+
transfers: false,
|
|
43
|
+
webhooks: true,
|
|
44
|
+
};
|
|
45
|
+
secretKey;
|
|
46
|
+
baseUrl;
|
|
47
|
+
timeoutMs;
|
|
48
|
+
http;
|
|
49
|
+
webhookSecret;
|
|
50
|
+
constructor(cfg) {
|
|
51
|
+
if (!cfg.secretKey)
|
|
52
|
+
throw new ConfigurationError("Bachs secret key is required");
|
|
53
|
+
this.secretKey = cfg.secretKey;
|
|
54
|
+
this.baseUrl = (cfg.baseUrl ?? (cfg.secretKey.startsWith("sk_live_") ? LIVE_BASE : SANDBOX_BASE)).replace(/\/$/, "");
|
|
55
|
+
this.timeoutMs = cfg.timeoutMs ?? 15_000;
|
|
56
|
+
this.http = cfg.http;
|
|
57
|
+
this.webhookSecret = cfg.webhookSecret;
|
|
58
|
+
}
|
|
59
|
+
headers(idempotencyKey) {
|
|
60
|
+
const h = { Authorization: `Bearer ${this.secretKey}` };
|
|
61
|
+
// Bachs supports Idempotency-Key on POSTs — forward ours when available.
|
|
62
|
+
if (idempotencyKey)
|
|
63
|
+
h["Idempotency-Key"] = idempotencyKey;
|
|
64
|
+
return h;
|
|
65
|
+
}
|
|
66
|
+
async createPayment(input) {
|
|
67
|
+
const amount = normalizeAmount(input.amount);
|
|
68
|
+
const data = await providerJson(`${this.baseUrl}/v1/checkout-sessions`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: this.headers(input.idempotencyKey),
|
|
71
|
+
body: {
|
|
72
|
+
pricing: { currency: input.currency, amount },
|
|
73
|
+
customer: { email: input.customer.email, name: input.customer.name },
|
|
74
|
+
reference: input.reference,
|
|
75
|
+
success_url: input.redirectUrl,
|
|
76
|
+
cancel_url: input.redirectUrl,
|
|
77
|
+
metadata: input.metadata,
|
|
78
|
+
},
|
|
79
|
+
timeoutMs: this.timeoutMs,
|
|
80
|
+
provider: "bachs",
|
|
81
|
+
reference: input.reference,
|
|
82
|
+
http: this.http,
|
|
83
|
+
});
|
|
84
|
+
const checkoutId = data.checkout_id ?? data.checkoutId ?? data.id ?? input.reference;
|
|
85
|
+
const checkoutUrl = data.checkout_url ?? data.checkoutUrl ?? data.url;
|
|
86
|
+
return {
|
|
87
|
+
id: newId(),
|
|
88
|
+
provider: "bachs",
|
|
89
|
+
reference: input.reference,
|
|
90
|
+
providerRef: checkoutId,
|
|
91
|
+
amount,
|
|
92
|
+
currency: input.currency,
|
|
93
|
+
status: "pending",
|
|
94
|
+
checkoutUrl,
|
|
95
|
+
customerEmail: input.customer.email,
|
|
96
|
+
raw: data,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async verifyPayment(input) {
|
|
100
|
+
const data = await providerJson(`${this.baseUrl}/v1/checkout-sessions/${encodeURIComponent(input.reference)}`, {
|
|
101
|
+
headers: this.headers(),
|
|
102
|
+
timeoutMs: this.timeoutMs,
|
|
103
|
+
provider: "bachs",
|
|
104
|
+
reference: input.reference,
|
|
105
|
+
http: this.http,
|
|
106
|
+
});
|
|
107
|
+
const rawStatus = data.payment_status ?? data.charge?.status ?? data.status ?? "unknown";
|
|
108
|
+
return {
|
|
109
|
+
id: newId(),
|
|
110
|
+
provider: "bachs",
|
|
111
|
+
reference: data.checkout_id ?? input.reference,
|
|
112
|
+
providerRef: data.checkout_id ?? input.reference,
|
|
113
|
+
amount: typeof data.amount === "number"
|
|
114
|
+
? data.amount.toFixed(2)
|
|
115
|
+
: typeof data.amount === "string"
|
|
116
|
+
? normalizeAmount(data.amount)
|
|
117
|
+
: "0.00",
|
|
118
|
+
currency: data.currency ?? "NGN",
|
|
119
|
+
status: mapBachsPaymentStatus(rawStatus),
|
|
120
|
+
customerEmail: data.customer?.email,
|
|
121
|
+
raw: data,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async createRefund(_input) {
|
|
125
|
+
throw new CapabilityError("bachs", "refunds", "no refund API in Bachs V1 docs; use dashboard or provider API directly");
|
|
126
|
+
}
|
|
127
|
+
async createTransfer(_input) {
|
|
128
|
+
throw new CapabilityError("bachs", "transfers", "no transfer/payout API in Bachs V1 docs");
|
|
129
|
+
}
|
|
130
|
+
async verifyTransfer(_reference) {
|
|
131
|
+
throw new CapabilityError("bachs", "transfers", "no transfer/payout API in Bachs V1 docs");
|
|
132
|
+
}
|
|
133
|
+
verifyWebhookSignature(rawBody, headers) {
|
|
134
|
+
// Provisional: HMAC-SHA256 over raw body. Confirm header name against Bachs dashboard.
|
|
135
|
+
if (!this.webhookSecret)
|
|
136
|
+
return false;
|
|
137
|
+
const sig = firstHeader(headers, "x-bachs-signature", "bachs-signature", "x-webhook-signature");
|
|
138
|
+
if (!sig)
|
|
139
|
+
return false;
|
|
140
|
+
const digest = createHmac("sha256", this.webhookSecret).update(rawBody).digest("hex");
|
|
141
|
+
const a = Buffer.from(digest);
|
|
142
|
+
const b = Buffer.from(sig.replace(/^sha256=/, ""));
|
|
143
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
144
|
+
}
|
|
145
|
+
normalizeWebhook(payload) {
|
|
146
|
+
if (typeof payload !== "object" || payload === null)
|
|
147
|
+
return null;
|
|
148
|
+
const p = payload;
|
|
149
|
+
const name = (p.type ?? p.event ?? "").toLowerCase();
|
|
150
|
+
if (!name)
|
|
151
|
+
return null;
|
|
152
|
+
if (name === "collection.succeeded" || name === "checkout.succeeded" || name === "payment.succeeded") {
|
|
153
|
+
return {
|
|
154
|
+
type: "payment.succeeded",
|
|
155
|
+
provider: "bachs",
|
|
156
|
+
providerEventId: p.data?.id,
|
|
157
|
+
reference: p.data?.checkout_id ?? p.data?.reference,
|
|
158
|
+
raw: payload,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (name === "collection.failed" || name === "payment.failed" || name === "checkout.expired") {
|
|
162
|
+
return {
|
|
163
|
+
type: "payment.failed",
|
|
164
|
+
provider: "bachs",
|
|
165
|
+
providerEventId: p.data?.id,
|
|
166
|
+
reference: p.data?.checkout_id ?? p.data?.reference,
|
|
167
|
+
raw: payload,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { type: "unknown", provider: "bachs", raw: payload };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 mapFlutterwaveStatus(s: string): PaymentStatus;
|
|
4
|
+
export declare class FlutterwaveConnector implements ProviderConnector {
|
|
5
|
+
readonly name: "flutterwave";
|
|
6
|
+
readonly capabilities: ProviderCapabilities;
|
|
7
|
+
private secretKey;
|
|
8
|
+
private baseUrl;
|
|
9
|
+
private timeoutMs;
|
|
10
|
+
private http;
|
|
11
|
+
private webhookSecret;
|
|
12
|
+
constructor(cfg: ConnectorConfig & {
|
|
13
|
+
webhookSecretHash?: 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
|
+
/** Documented `{event, data}` envelope shape. Unchanged behavior. */
|
|
28
|
+
private normalizeDocumentedShape;
|
|
29
|
+
}
|