openpay-ng 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +78 -0
- package/dist/connectors/bachs.d.ts +33 -0
- package/dist/connectors/bachs.js +172 -0
- package/dist/connectors/flutterwave.d.ts +29 -0
- package/dist/connectors/flutterwave.js +234 -0
- package/dist/connectors/http.d.ts +18 -0
- package/dist/connectors/http.js +59 -0
- package/dist/connectors/paystack.d.ts +28 -0
- package/dist/connectors/paystack.js +238 -0
- package/dist/connectors/service.d.ts +12 -0
- package/dist/connectors/service.js +107 -0
- package/dist/connectors/types.d.ts +29 -0
- package/dist/connectors/types.js +1 -0
- package/dist/core/errors.d.ts +41 -0
- package/dist/core/errors.js +55 -0
- package/dist/core/idempotency.d.ts +4 -0
- package/dist/core/idempotency.js +11 -0
- package/dist/core/types.d.ts +127 -0
- package/dist/core/types.js +10 -0
- package/dist/core/validation.d.ts +135 -0
- package/dist/core/validation.js +61 -0
- package/dist/db/client.d.ts +6 -0
- package/dist/db/client.js +18 -0
- package/dist/db/schema.d.ts +754 -0
- package/dist/db/schema.js +62 -0
- package/dist/db/store.d.ts +60 -0
- package/dist/db/store.js +159 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/sdk.d.ts +28 -0
- package/dist/sdk.js +59 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +137 -0
- package/dist/webhooks/service.d.ts +18 -0
- package/dist/webhooks/service.js +60 -0
- package/package.json +55 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal V1 persistence. Only what reliability rules need:
|
|
4
|
+
* payments / transfers / refunds ledger rows + idempotency keys + webhook events.
|
|
5
|
+
* No business logic in SQL — constraints only.
|
|
6
|
+
*/
|
|
7
|
+
export const payments = pgTable("payments", {
|
|
8
|
+
id: uuid("id").primaryKey(),
|
|
9
|
+
provider: text("provider").notNull(),
|
|
10
|
+
reference: text("reference").notNull().unique(),
|
|
11
|
+
providerRef: text("provider_ref").notNull(),
|
|
12
|
+
amount: text("amount").notNull(),
|
|
13
|
+
currency: text("currency").notNull(),
|
|
14
|
+
status: text("status").notNull(),
|
|
15
|
+
customerEmail: text("customer_email"),
|
|
16
|
+
checkoutUrl: text("checkout_url"),
|
|
17
|
+
raw: jsonb("raw"),
|
|
18
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
19
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
20
|
+
});
|
|
21
|
+
export const transfers = pgTable("transfers", {
|
|
22
|
+
id: uuid("id").primaryKey(),
|
|
23
|
+
provider: text("provider").notNull(),
|
|
24
|
+
reference: text("reference").notNull().unique(),
|
|
25
|
+
providerRef: text("provider_ref").notNull(),
|
|
26
|
+
amount: text("amount").notNull(),
|
|
27
|
+
currency: text("currency").notNull(),
|
|
28
|
+
status: text("status").notNull(),
|
|
29
|
+
raw: jsonb("raw"),
|
|
30
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
31
|
+
});
|
|
32
|
+
export const refunds = pgTable("refunds", {
|
|
33
|
+
id: uuid("id").primaryKey(),
|
|
34
|
+
provider: text("provider").notNull(),
|
|
35
|
+
paymentReference: text("payment_reference").notNull(),
|
|
36
|
+
providerRef: text("provider_ref").notNull(),
|
|
37
|
+
amount: text("amount").notNull(),
|
|
38
|
+
currency: text("currency").notNull(),
|
|
39
|
+
status: text("status").notNull(),
|
|
40
|
+
raw: jsonb("raw"),
|
|
41
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
42
|
+
});
|
|
43
|
+
export const idempotencyKeys = pgTable("idempotency_keys", {
|
|
44
|
+
key: text("key").primaryKey(),
|
|
45
|
+
operation: text("operation").notNull(),
|
|
46
|
+
provider: text("provider").notNull(),
|
|
47
|
+
response: jsonb("response").notNull(),
|
|
48
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
49
|
+
});
|
|
50
|
+
export const webhookEvents = pgTable("webhook_events", {
|
|
51
|
+
id: uuid("id").primaryKey(),
|
|
52
|
+
provider: text("provider").notNull(),
|
|
53
|
+
eventId: text("event_id"),
|
|
54
|
+
type: text("type").notNull(),
|
|
55
|
+
reference: text("reference"),
|
|
56
|
+
payload: jsonb("payload").notNull(),
|
|
57
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
58
|
+
}, (t) => [
|
|
59
|
+
// Retried deliveries of the same provider event collapse into one row.
|
|
60
|
+
// eventId is always populated at insert time (real id or content hash).
|
|
61
|
+
uniqueIndex("webhook_events_provider_type_event_uidx").on(t.provider, t.type, t.eventId),
|
|
62
|
+
]);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { OpenPayEvent, Payment, PaymentStatus, ProviderName, Refund, Transfer } from "../core/types.js";
|
|
2
|
+
import type { Db } from "./client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a non-null dedupe id for a webhook event.
|
|
5
|
+
* Real provider event id when available, otherwise a stable content hash
|
|
6
|
+
* (identical retried deliveries produce identical hashes).
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveWebhookEventId(event: OpenPayEvent, raw: unknown): string;
|
|
9
|
+
/**
|
|
10
|
+
* Persistence port. Services depend on this interface, not on Drizzle directly.
|
|
11
|
+
* Production: DrizzlePersistence. Tests / stateless mode: MemoryPersistence.
|
|
12
|
+
*/
|
|
13
|
+
export interface Persistence {
|
|
14
|
+
findIdempotency<T>(key: string): Promise<T | null>;
|
|
15
|
+
saveIdempotency(key: string, operation: string, provider: string, response: unknown): Promise<void>;
|
|
16
|
+
savePayment(payment: Payment): Promise<void>;
|
|
17
|
+
saveTransfer(transfer: Transfer): Promise<void>;
|
|
18
|
+
saveRefund(refund: Refund): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Persist a webhook event. Returns true when newly inserted, false when
|
|
21
|
+
* this exact event was already recorded (duplicate delivery).
|
|
22
|
+
* Existing callers may ignore the return value.
|
|
23
|
+
*/
|
|
24
|
+
saveWebhookEvent(event: OpenPayEvent, raw: unknown): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Set a payment's status, matching provider + (reference OR providerRef).
|
|
27
|
+
* Returns true when a row was updated, false when no payment matched.
|
|
28
|
+
* Status-only write: never touches amount/currency/refs. Idempotent.
|
|
29
|
+
*/
|
|
30
|
+
updatePaymentStatus(provider: ProviderName, reference: string, status: PaymentStatus): Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
export declare class MemoryPersistence implements Persistence {
|
|
33
|
+
private idem;
|
|
34
|
+
private seenWebhooks;
|
|
35
|
+
readonly paymentRows: Payment[];
|
|
36
|
+
readonly transferRows: Transfer[];
|
|
37
|
+
readonly refundRows: Refund[];
|
|
38
|
+
readonly webhookRows: {
|
|
39
|
+
event: OpenPayEvent;
|
|
40
|
+
raw: unknown;
|
|
41
|
+
}[];
|
|
42
|
+
findIdempotency<T>(key: string): Promise<T | null>;
|
|
43
|
+
saveIdempotency(key: string, _op: string, _provider: string, response: unknown): Promise<void>;
|
|
44
|
+
savePayment(payment: Payment): Promise<void>;
|
|
45
|
+
saveTransfer(transfer: Transfer): Promise<void>;
|
|
46
|
+
saveRefund(refund: Refund): Promise<void>;
|
|
47
|
+
saveWebhookEvent(event: OpenPayEvent, raw: unknown): Promise<boolean>;
|
|
48
|
+
updatePaymentStatus(provider: ProviderName, reference: string, status: PaymentStatus): Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
export declare class DrizzlePersistence implements Persistence {
|
|
51
|
+
private db;
|
|
52
|
+
constructor(db: Db);
|
|
53
|
+
findIdempotency<T>(key: string): Promise<T | null>;
|
|
54
|
+
saveIdempotency(key: string, operation: string, provider: string, response: unknown): Promise<void>;
|
|
55
|
+
savePayment(payment: Payment): Promise<void>;
|
|
56
|
+
saveTransfer(transfer: Transfer): Promise<void>;
|
|
57
|
+
saveRefund(refund: Refund): Promise<void>;
|
|
58
|
+
updatePaymentStatus(provider: ProviderName, reference: string, status: PaymentStatus): Promise<boolean>;
|
|
59
|
+
saveWebhookEvent(event: OpenPayEvent, raw: unknown): Promise<boolean>;
|
|
60
|
+
}
|
package/dist/db/store.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { newId } from "../core/idempotency.js";
|
|
3
|
+
import { idempotencyKeys, payments, refunds, transfers, webhookEvents } from "./schema.js";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a non-null dedupe id for a webhook event.
|
|
6
|
+
* Real provider event id when available, otherwise a stable content hash
|
|
7
|
+
* (identical retried deliveries produce identical hashes).
|
|
8
|
+
*/
|
|
9
|
+
export function resolveWebhookEventId(event, raw) {
|
|
10
|
+
if (event.providerEventId)
|
|
11
|
+
return event.providerEventId;
|
|
12
|
+
return createHash("sha256")
|
|
13
|
+
.update(typeof raw === "string" ? raw : JSON.stringify(raw))
|
|
14
|
+
.digest("hex");
|
|
15
|
+
}
|
|
16
|
+
export class MemoryPersistence {
|
|
17
|
+
idem = new Map();
|
|
18
|
+
seenWebhooks = new Set();
|
|
19
|
+
paymentRows = [];
|
|
20
|
+
transferRows = [];
|
|
21
|
+
refundRows = [];
|
|
22
|
+
webhookRows = [];
|
|
23
|
+
async findIdempotency(key) {
|
|
24
|
+
return this.idem.get(key) ?? null;
|
|
25
|
+
}
|
|
26
|
+
async saveIdempotency(key, _op, _provider, response) {
|
|
27
|
+
// First write wins — repeated requests must not create duplicates.
|
|
28
|
+
if (!this.idem.has(key))
|
|
29
|
+
this.idem.set(key, response);
|
|
30
|
+
}
|
|
31
|
+
async savePayment(payment) {
|
|
32
|
+
const i = this.paymentRows.findIndex((p) => p.reference === payment.reference);
|
|
33
|
+
if (i >= 0)
|
|
34
|
+
this.paymentRows[i] = payment;
|
|
35
|
+
else
|
|
36
|
+
this.paymentRows.push(payment);
|
|
37
|
+
}
|
|
38
|
+
async saveTransfer(transfer) {
|
|
39
|
+
this.transferRows.push(transfer);
|
|
40
|
+
}
|
|
41
|
+
async saveRefund(refund) {
|
|
42
|
+
this.refundRows.push(refund);
|
|
43
|
+
}
|
|
44
|
+
async saveWebhookEvent(event, raw) {
|
|
45
|
+
const key = `${event.provider}\n${event.type}\n${resolveWebhookEventId(event, raw)}`;
|
|
46
|
+
if (this.seenWebhooks.has(key))
|
|
47
|
+
return false;
|
|
48
|
+
this.seenWebhooks.add(key);
|
|
49
|
+
this.webhookRows.push({ event, raw });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
async updatePaymentStatus(provider, reference, status) {
|
|
53
|
+
const row = this.paymentRows.find((p) => p.provider === provider && (p.reference === reference || p.providerRef === reference));
|
|
54
|
+
if (!row)
|
|
55
|
+
return false;
|
|
56
|
+
row.status = status;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class DrizzlePersistence {
|
|
61
|
+
db;
|
|
62
|
+
constructor(db) {
|
|
63
|
+
this.db = db;
|
|
64
|
+
}
|
|
65
|
+
async findIdempotency(key) {
|
|
66
|
+
const { eq } = await import("drizzle-orm");
|
|
67
|
+
const rows = await this.db
|
|
68
|
+
.select()
|
|
69
|
+
.from(idempotencyKeys)
|
|
70
|
+
.where(eq(idempotencyKeys.key, key))
|
|
71
|
+
.limit(1);
|
|
72
|
+
const row = rows[0];
|
|
73
|
+
return row ? row.response : null;
|
|
74
|
+
}
|
|
75
|
+
async saveIdempotency(key, operation, provider, response) {
|
|
76
|
+
await this.db
|
|
77
|
+
.insert(idempotencyKeys)
|
|
78
|
+
.values({ key, operation, provider, response: response })
|
|
79
|
+
.onConflictDoNothing({ target: idempotencyKeys.key });
|
|
80
|
+
}
|
|
81
|
+
async savePayment(payment) {
|
|
82
|
+
await this.db
|
|
83
|
+
.insert(payments)
|
|
84
|
+
.values({
|
|
85
|
+
id: payment.id,
|
|
86
|
+
provider: payment.provider,
|
|
87
|
+
reference: payment.reference,
|
|
88
|
+
providerRef: payment.providerRef,
|
|
89
|
+
amount: payment.amount,
|
|
90
|
+
currency: payment.currency,
|
|
91
|
+
status: payment.status,
|
|
92
|
+
customerEmail: payment.customerEmail,
|
|
93
|
+
checkoutUrl: payment.checkoutUrl,
|
|
94
|
+
raw: payment.raw,
|
|
95
|
+
})
|
|
96
|
+
.onConflictDoUpdate({
|
|
97
|
+
target: payments.reference,
|
|
98
|
+
set: {
|
|
99
|
+
providerRef: payment.providerRef,
|
|
100
|
+
status: payment.status,
|
|
101
|
+
checkoutUrl: payment.checkoutUrl,
|
|
102
|
+
raw: payment.raw,
|
|
103
|
+
updatedAt: new Date(),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async saveTransfer(transfer) {
|
|
108
|
+
await this.db.insert(transfers).values({
|
|
109
|
+
id: transfer.id,
|
|
110
|
+
provider: transfer.provider,
|
|
111
|
+
reference: transfer.reference,
|
|
112
|
+
providerRef: transfer.providerRef,
|
|
113
|
+
amount: transfer.amount,
|
|
114
|
+
currency: transfer.currency,
|
|
115
|
+
status: transfer.status,
|
|
116
|
+
raw: transfer.raw,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async saveRefund(refund) {
|
|
120
|
+
await this.db.insert(refunds).values({
|
|
121
|
+
id: refund.id,
|
|
122
|
+
provider: refund.provider,
|
|
123
|
+
paymentReference: refund.paymentReference,
|
|
124
|
+
providerRef: refund.providerRef,
|
|
125
|
+
amount: refund.amount,
|
|
126
|
+
currency: refund.currency,
|
|
127
|
+
status: refund.status,
|
|
128
|
+
raw: refund.raw,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async updatePaymentStatus(provider, reference, status) {
|
|
132
|
+
const { and, eq, or } = await import("drizzle-orm");
|
|
133
|
+
const rows = await this.db
|
|
134
|
+
.update(payments)
|
|
135
|
+
.set({ status, updatedAt: new Date() })
|
|
136
|
+
.where(and(eq(payments.provider, provider), or(eq(payments.reference, reference), eq(payments.providerRef, reference))))
|
|
137
|
+
.returning({ id: payments.id });
|
|
138
|
+
return rows.length > 0;
|
|
139
|
+
}
|
|
140
|
+
async saveWebhookEvent(event, raw) {
|
|
141
|
+
// The unique index is the concurrency arbiter: simultaneous duplicate
|
|
142
|
+
// deliveries collapse into one row, and RETURNING tells us if we won.
|
|
143
|
+
const rows = await this.db
|
|
144
|
+
.insert(webhookEvents)
|
|
145
|
+
.values({
|
|
146
|
+
id: newId(),
|
|
147
|
+
provider: event.provider,
|
|
148
|
+
eventId: resolveWebhookEventId(event, raw),
|
|
149
|
+
type: event.type,
|
|
150
|
+
reference: event.reference,
|
|
151
|
+
payload: raw,
|
|
152
|
+
})
|
|
153
|
+
.onConflictDoNothing({
|
|
154
|
+
target: [webhookEvents.provider, webhookEvents.type, webhookEvents.eventId],
|
|
155
|
+
})
|
|
156
|
+
.returning({ id: webhookEvents.id });
|
|
157
|
+
return rows.length > 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { OpenPay, default } from "./sdk.js";
|
|
2
|
+
export type { OpenPayConfig, ProviderConf } from "./config.js";
|
|
3
|
+
export * from "./core/types.js";
|
|
4
|
+
export * from "./core/errors.js";
|
|
5
|
+
export { normalizeAmount, toMinorUnits } from "./core/validation.js";
|
|
6
|
+
export type { WebhookResult } from "./webhooks/service.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Public API surface (V1). Intentionally small: the OpenPay class, config and
|
|
2
|
+
// domain types, catchable errors, and amount helpers. Connector classes,
|
|
3
|
+
// context resolution, and persistence implementations are internal — import
|
|
4
|
+
// them from their modules directly if you really need them.
|
|
5
|
+
export { OpenPay, default } from "./sdk.js";
|
|
6
|
+
export * from "./core/types.js";
|
|
7
|
+
export * from "./core/errors.js";
|
|
8
|
+
export { normalizeAmount, toMinorUnits } from "./core/validation.js";
|
package/dist/sdk.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type OpenPayConfig } from "./config.js";
|
|
2
|
+
import type { CreatePaymentInput, CreateRefundInput, CreateTransferInput, OpenPayEvent, Payment, ProviderName, Refund, Transfer, VerifyPaymentInput, WebhookHeaders } from "./core/types.js";
|
|
3
|
+
import { type WebhookResult } from "./webhooks/service.js";
|
|
4
|
+
export declare class OpenPay {
|
|
5
|
+
private ctx;
|
|
6
|
+
constructor(config?: OpenPayConfig);
|
|
7
|
+
readonly payments: {
|
|
8
|
+
create: (input: CreatePaymentInput) => Promise<Payment>;
|
|
9
|
+
verify: (input: VerifyPaymentInput) => Promise<Payment>;
|
|
10
|
+
retrieve: (input: VerifyPaymentInput) => Promise<Payment>;
|
|
11
|
+
};
|
|
12
|
+
readonly transfers: {
|
|
13
|
+
create: (input: CreateTransferInput) => Promise<Transfer>;
|
|
14
|
+
verify: (reference: string, provider?: ProviderName) => Promise<Transfer>;
|
|
15
|
+
};
|
|
16
|
+
readonly refunds: {
|
|
17
|
+
create: (input: CreateRefundInput) => Promise<Refund>;
|
|
18
|
+
};
|
|
19
|
+
readonly webhooks: {
|
|
20
|
+
/** Verify + normalize a raw webhook body. Persists the event when persistence is enabled. */
|
|
21
|
+
normalize: (provider: ProviderName, rawBody: string | Buffer, headers: WebhookHeaders) => Promise<OpenPayEvent | null>;
|
|
22
|
+
/** Same as normalize, but also reports retried (duplicate) deliveries. */
|
|
23
|
+
normalizeDetailed: (provider: ProviderName, rawBody: string | Buffer, headers: WebhookHeaders) => Promise<WebhookResult>;
|
|
24
|
+
};
|
|
25
|
+
/** Which providers are configured in this instance. */
|
|
26
|
+
configuredProviders(): ProviderName[];
|
|
27
|
+
}
|
|
28
|
+
export default OpenPay;
|
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenPay NG — public SDK entry.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* const openpay = new OpenPay({ provider: "paystack", secretKey: "sk_test_..." });
|
|
6
|
+
* const payment = await openpay.payments.create({
|
|
7
|
+
* amount: "5000.00",
|
|
8
|
+
* currency: "NGN",
|
|
9
|
+
* customer: { email: "customer@example.com" },
|
|
10
|
+
* });
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import { createPayment, createRefund, createTransfer, verifyPayment } from "./connectors/service.js";
|
|
14
|
+
import { resolveContext } from "./config.js";
|
|
15
|
+
import { CapabilityError } from "./core/errors.js";
|
|
16
|
+
import { handleWebhook, handleWebhookDetailed } from "./webhooks/service.js";
|
|
17
|
+
export class OpenPay {
|
|
18
|
+
ctx;
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
this.ctx = resolveContext(config);
|
|
21
|
+
}
|
|
22
|
+
payments = {
|
|
23
|
+
create: (input) => createPayment(this.ctx, input),
|
|
24
|
+
verify: (input) => verifyPayment(this.ctx, input),
|
|
25
|
+
retrieve: (input) => verifyPayment(this.ctx, input),
|
|
26
|
+
};
|
|
27
|
+
transfers = {
|
|
28
|
+
create: (input) => createTransfer(this.ctx, input),
|
|
29
|
+
verify: (reference, provider) => {
|
|
30
|
+
const name = provider ?? this.ctx.defaultProvider;
|
|
31
|
+
const c = this.ctx.connectors[name];
|
|
32
|
+
if (!c.capabilities.transfers)
|
|
33
|
+
throw new CapabilityError(name, "transfers");
|
|
34
|
+
return c.verifyTransfer(reference);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
refunds = {
|
|
38
|
+
create: (input) => createRefund(this.ctx, input),
|
|
39
|
+
};
|
|
40
|
+
webhooks = {
|
|
41
|
+
/** Verify + normalize a raw webhook body. Persists the event when persistence is enabled. */
|
|
42
|
+
normalize: (provider, rawBody, headers) => handleWebhook(this.ctx, provider, rawBody, headers),
|
|
43
|
+
/** Same as normalize, but also reports retried (duplicate) deliveries. */
|
|
44
|
+
normalizeDetailed: (provider, rawBody, headers) => handleWebhookDetailed(this.ctx, provider, rawBody, headers),
|
|
45
|
+
};
|
|
46
|
+
/** Which providers are configured in this instance. */
|
|
47
|
+
configuredProviders() {
|
|
48
|
+
return Object.keys(this.ctx.connectors).filter((k) => {
|
|
49
|
+
try {
|
|
50
|
+
void this.ctx.connectors[k];
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export default OpenPay;
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin local webhook receiver for OpenPay NG (V1).
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally dependency-free (Node built-in `http` only).
|
|
5
|
+
* All verification / normalization / persistence logic lives in the SDK —
|
|
6
|
+
* this server only transports the RAW body + headers into:
|
|
7
|
+
* openpay.webhooks.normalizeDetailed(provider, rawBody, headers)
|
|
8
|
+
*
|
|
9
|
+
* Raw body preservation is critical: Paystack's HMAC-SHA512 signature is
|
|
10
|
+
* computed over the exact request bytes, so no JSON parsing happens first.
|
|
11
|
+
* (Flutterwave compares the `verif-hash` header instead, but the transport
|
|
12
|
+
* stays identical.)
|
|
13
|
+
*/
|
|
14
|
+
import 'dotenv/config';
|
|
15
|
+
import { type Server } from 'node:http';
|
|
16
|
+
import { OpenPay } from './sdk.js';
|
|
17
|
+
export declare function createWebhookServer(openpay: OpenPay): Server;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin local webhook receiver for OpenPay NG (V1).
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally dependency-free (Node built-in `http` only).
|
|
5
|
+
* All verification / normalization / persistence logic lives in the SDK —
|
|
6
|
+
* this server only transports the RAW body + headers into:
|
|
7
|
+
* openpay.webhooks.normalizeDetailed(provider, rawBody, headers)
|
|
8
|
+
*
|
|
9
|
+
* Raw body preservation is critical: Paystack's HMAC-SHA512 signature is
|
|
10
|
+
* computed over the exact request bytes, so no JSON parsing happens first.
|
|
11
|
+
* (Flutterwave compares the `verif-hash` header instead, but the transport
|
|
12
|
+
* stays identical.)
|
|
13
|
+
*/
|
|
14
|
+
import 'dotenv/config';
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { WebhookVerificationError } from './core/errors.js';
|
|
18
|
+
import { OpenPay } from './sdk.js';
|
|
19
|
+
const MAX_BODY_BYTES = 1_000_000; // 1 MB — Paystack events are a few KB
|
|
20
|
+
function sendJson(res, status, body) {
|
|
21
|
+
const text = JSON.stringify(body);
|
|
22
|
+
res.writeHead(status, {
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
'Content-Length': Buffer.byteLength(text),
|
|
25
|
+
});
|
|
26
|
+
res.end(text);
|
|
27
|
+
}
|
|
28
|
+
async function readRawBody(req) {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
for await (const chunk of req) {
|
|
32
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
33
|
+
size += buf.length;
|
|
34
|
+
if (size > MAX_BODY_BYTES)
|
|
35
|
+
throw new Error('body_too_large');
|
|
36
|
+
chunks.push(buf);
|
|
37
|
+
}
|
|
38
|
+
return Buffer.concat(chunks);
|
|
39
|
+
}
|
|
40
|
+
/** First value wins; Node lowercases header names. */
|
|
41
|
+
function header(req, name) {
|
|
42
|
+
const v = req.headers[name];
|
|
43
|
+
return Array.isArray(v) ? v[0] : v;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Shared raw-body -> normalizeDetailed -> status mapping used by every
|
|
47
|
+
* provider route. Behavior is identical for all providers; only the
|
|
48
|
+
* provider name and signature header differ per route.
|
|
49
|
+
*/
|
|
50
|
+
async function handleProviderWebhook(openpay, provider, raw, headers, res) {
|
|
51
|
+
try {
|
|
52
|
+
const { event, duplicate } = await openpay.webhooks.normalizeDetailed(provider, raw, headers);
|
|
53
|
+
if (!event) {
|
|
54
|
+
sendJson(res, 400, { ok: false, error: 'malformed_payload' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (duplicate) {
|
|
58
|
+
sendJson(res, 200, { ok: true, deduped: true, type: event.type, reference: event.reference });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (event.type === 'unknown') {
|
|
62
|
+
// Valid signature, unrecognized event: acknowledge so the provider doesn't retry forever.
|
|
63
|
+
sendJson(res, 200, { ok: true, ignored: true, reference: event.reference });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
sendJson(res, 200, { ok: true, type: event.type, reference: event.reference });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err instanceof WebhookVerificationError) {
|
|
70
|
+
sendJson(res, 401, { ok: false, error: 'invalid_signature' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
throw err; // -> 500 via outer catch
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const PROVIDER_ROUTES = {
|
|
77
|
+
'/webhooks/paystack': 'paystack',
|
|
78
|
+
'/webhooks/flutterwave': 'flutterwave',
|
|
79
|
+
};
|
|
80
|
+
/** Signature headers per provider (values extracted from the raw request). */
|
|
81
|
+
function signatureHeaders(provider, req) {
|
|
82
|
+
if (provider === 'flutterwave')
|
|
83
|
+
return { 'verif-hash': header(req, 'verif-hash') };
|
|
84
|
+
return { 'x-paystack-signature': header(req, 'x-paystack-signature') };
|
|
85
|
+
}
|
|
86
|
+
export function createWebhookServer(openpay) {
|
|
87
|
+
const server = createServer((req, res) => {
|
|
88
|
+
void handle(req, res).catch((err) => {
|
|
89
|
+
// Genuine processing failure (e.g. DB down) -> 500 so the provider retries.
|
|
90
|
+
console.error(`webhook error: ${err instanceof Error ? err.message : String(err)}`);
|
|
91
|
+
if (!res.headersSent)
|
|
92
|
+
sendJson(res, 500, { ok: false, error: 'processing_failed' });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
async function handle(req, res) {
|
|
96
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
97
|
+
console.log(`${req.method} ${url.pathname}`);
|
|
98
|
+
if (req.method === 'GET' && url.pathname === '/healthz') {
|
|
99
|
+
sendJson(res, 200, { ok: true });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const provider = PROVIDER_ROUTES[url.pathname];
|
|
103
|
+
if (!provider) {
|
|
104
|
+
sendJson(res, 404, { ok: false, error: 'not_found' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (req.method !== 'POST') {
|
|
108
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let raw;
|
|
112
|
+
try {
|
|
113
|
+
raw = await readRawBody(req);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
sendJson(res, 413, { ok: false, error: 'body_too_large' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await handleProviderWebhook(openpay, provider, raw, signatureHeaders(provider, req), res);
|
|
120
|
+
}
|
|
121
|
+
return server;
|
|
122
|
+
}
|
|
123
|
+
// Runnable CLI: `npm run receiver`. Import-safe (tests import the factory only).
|
|
124
|
+
const argv1 = process.argv[1];
|
|
125
|
+
if (argv1 && import.meta.url === pathToFileURL(argv1).href) {
|
|
126
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
127
|
+
const defaultProvider = process.env.OPENPAY_DEFAULT_PROVIDER ?? 'paystack';
|
|
128
|
+
// Keys + DATABASE_URL resolve from env inside OpenPay; missing keys throw ConfigurationError.
|
|
129
|
+
const openpay = new OpenPay({ defaultProvider });
|
|
130
|
+
if (!process.env.DATABASE_URL) {
|
|
131
|
+
console.warn('WARNING: DATABASE_URL not set — webhooks will verify/normalize but NOT persist.');
|
|
132
|
+
}
|
|
133
|
+
createWebhookServer(openpay).listen(port, () => {
|
|
134
|
+
console.log(`OpenPay webhook receiver listening on http://localhost:${port}`);
|
|
135
|
+
console.log('Paths: POST /webhooks/paystack, POST /webhooks/flutterwave');
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OpenPayEvent, ProviderName, WebhookHeaders } from "../core/types.js";
|
|
2
|
+
import type { Persistence } from "../db/store.js";
|
|
3
|
+
import type { ProviderConnector } from "../connectors/types.js";
|
|
4
|
+
export interface WebhookContext {
|
|
5
|
+
connectors: Record<ProviderName, ProviderConnector>;
|
|
6
|
+
persistence?: Persistence;
|
|
7
|
+
}
|
|
8
|
+
/** Verify signature, normalize to OpenPayEvent, persist. Throws WebhookVerificationError on bad signature. */
|
|
9
|
+
export declare function handleWebhook(ctx: WebhookContext, provider: ProviderName, rawBody: string | Buffer, headers: WebhookHeaders): Promise<OpenPayEvent | null>;
|
|
10
|
+
export interface WebhookResult {
|
|
11
|
+
event: OpenPayEvent | null;
|
|
12
|
+
/** True when this exact event was already recorded (retried delivery). */
|
|
13
|
+
duplicate: boolean;
|
|
14
|
+
/** True when a matching payment row's status was updated by this event. */
|
|
15
|
+
projected: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Same as handleWebhook, but also reports duplicate deliveries. */
|
|
18
|
+
export declare function handleWebhookDetailed(ctx: WebhookContext, provider: ProviderName, rawBody: string | Buffer, headers: WebhookHeaders): Promise<WebhookResult>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ConfigurationError, WebhookVerificationError } from "../core/errors.js";
|
|
2
|
+
function pick(ctx, provider) {
|
|
3
|
+
const c = ctx.connectors[provider];
|
|
4
|
+
if (!c)
|
|
5
|
+
throw new ConfigurationError(`Provider '${provider}' is not configured`);
|
|
6
|
+
return c;
|
|
7
|
+
}
|
|
8
|
+
/** Verify signature, normalize to OpenPayEvent, persist. Throws WebhookVerificationError on bad signature. */
|
|
9
|
+
export async function handleWebhook(ctx, provider, rawBody, headers) {
|
|
10
|
+
const { event } = await handleWebhookDetailed(ctx, provider, rawBody, headers);
|
|
11
|
+
return event;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Project a normalized event onto its payment row (status-only, idempotent).
|
|
15
|
+
* Only terminal payment-lifecycle events project:
|
|
16
|
+
* - payment.succeeded -> successful
|
|
17
|
+
* - payment.failed -> failed
|
|
18
|
+
* payment.pending is intentionally NOT projected: it carries no new information
|
|
19
|
+
* for genuinely pending payments, and must never regress a successful/failed
|
|
20
|
+
* row (e.g. Paystack refund.pending normalizes to payment.pending but describes
|
|
21
|
+
* the refund, not the payment). transfer/refund/unknown events never project.
|
|
22
|
+
*/
|
|
23
|
+
function projectedStatus(event) {
|
|
24
|
+
if (event.type === "payment.succeeded")
|
|
25
|
+
return "successful";
|
|
26
|
+
if (event.type === "payment.failed")
|
|
27
|
+
return "failed";
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Same as handleWebhook, but also reports duplicate deliveries. */
|
|
31
|
+
export async function handleWebhookDetailed(ctx, provider, rawBody, headers) {
|
|
32
|
+
const connector = pick(ctx, provider);
|
|
33
|
+
const text = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
|
|
34
|
+
if (!connector.verifyWebhookSignature(rawBody, headers)) {
|
|
35
|
+
throw new WebhookVerificationError(provider);
|
|
36
|
+
}
|
|
37
|
+
let payload;
|
|
38
|
+
try {
|
|
39
|
+
payload = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { event: null, duplicate: false, projected: false };
|
|
43
|
+
}
|
|
44
|
+
const event = connector.normalizeWebhook(payload);
|
|
45
|
+
if (!event)
|
|
46
|
+
return { event: null, duplicate: false, projected: false };
|
|
47
|
+
if (ctx.persistence) {
|
|
48
|
+
const inserted = await ctx.persistence.saveWebhookEvent(event, payload);
|
|
49
|
+
if (!inserted)
|
|
50
|
+
return { event, duplicate: true, projected: false };
|
|
51
|
+
const status = projectedStatus(event);
|
|
52
|
+
if (status !== null && event.reference !== undefined) {
|
|
53
|
+
// Unknown references are safe: no row matches, nothing changes.
|
|
54
|
+
const updated = await ctx.persistence.updatePaymentStatus(event.provider, event.reference, status);
|
|
55
|
+
return { event, duplicate: false, projected: updated };
|
|
56
|
+
}
|
|
57
|
+
return { event, duplicate: false, projected: false };
|
|
58
|
+
}
|
|
59
|
+
return { event, duplicate: false, projected: false };
|
|
60
|
+
}
|