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 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
@@ -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
+ }