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,107 @@
|
|
|
1
|
+
import { ConfigurationError, ValidationError } from "../core/errors.js";
|
|
2
|
+
import { defaultIdempotencyKey, newReference } from "../core/idempotency.js";
|
|
3
|
+
import { CreatePaymentSchema, CreateRefundSchema, CreateTransferSchema, normalizeAmount, VerifyPaymentSchema, } from "../core/validation.js";
|
|
4
|
+
function pickConnector(ctx, provider) {
|
|
5
|
+
const name = provider ?? ctx.defaultProvider;
|
|
6
|
+
const c = ctx.connectors[name];
|
|
7
|
+
if (!c)
|
|
8
|
+
throw new ConfigurationError(`Provider '${name}' is not configured`);
|
|
9
|
+
return c;
|
|
10
|
+
}
|
|
11
|
+
export async function createPayment(ctx, input) {
|
|
12
|
+
const parsed = CreatePaymentSchema.safeParse(input);
|
|
13
|
+
if (!parsed.success)
|
|
14
|
+
throw new ValidationError(parsed.error.message, parsed.error.flatten());
|
|
15
|
+
const provider = parsed.data.provider ?? ctx.defaultProvider;
|
|
16
|
+
const reference = parsed.data.reference ?? newReference("pay");
|
|
17
|
+
const key = parsed.data.idempotencyKey ?? defaultIdempotencyKey("payments.create", provider, reference);
|
|
18
|
+
if (ctx.persistence) {
|
|
19
|
+
const cached = await ctx.persistence.findIdempotency(key);
|
|
20
|
+
if (cached)
|
|
21
|
+
return cached;
|
|
22
|
+
}
|
|
23
|
+
const connector = pickConnector(ctx, provider);
|
|
24
|
+
const payment = await connector.createPayment({
|
|
25
|
+
amount: normalizeAmount(parsed.data.amount),
|
|
26
|
+
currency: parsed.data.currency,
|
|
27
|
+
customer: parsed.data.customer,
|
|
28
|
+
provider,
|
|
29
|
+
reference,
|
|
30
|
+
idempotencyKey: key,
|
|
31
|
+
redirectUrl: parsed.data.redirectUrl,
|
|
32
|
+
metadata: parsed.data.metadata,
|
|
33
|
+
});
|
|
34
|
+
if (ctx.persistence) {
|
|
35
|
+
await ctx.persistence.savePayment(payment);
|
|
36
|
+
await ctx.persistence.saveIdempotency(key, "payments.create", provider, payment);
|
|
37
|
+
}
|
|
38
|
+
return payment;
|
|
39
|
+
}
|
|
40
|
+
export async function verifyPayment(ctx, input) {
|
|
41
|
+
const parsed = VerifyPaymentSchema.safeParse(input);
|
|
42
|
+
if (!parsed.success)
|
|
43
|
+
throw new ValidationError(parsed.error.message);
|
|
44
|
+
const connector = pickConnector(ctx, parsed.data.provider);
|
|
45
|
+
const payment = await connector.verifyPayment({ reference: parsed.data.reference, provider: connector.name });
|
|
46
|
+
if (ctx.persistence)
|
|
47
|
+
await ctx.persistence.savePayment(payment);
|
|
48
|
+
return payment;
|
|
49
|
+
}
|
|
50
|
+
export async function createTransfer(ctx, input) {
|
|
51
|
+
const parsed = CreateTransferSchema.safeParse(input);
|
|
52
|
+
if (!parsed.success)
|
|
53
|
+
throw new ValidationError(parsed.error.message, parsed.error.flatten());
|
|
54
|
+
const provider = parsed.data.provider ?? ctx.defaultProvider;
|
|
55
|
+
const reference = parsed.data.reference ?? newReference("trf");
|
|
56
|
+
const key = parsed.data.idempotencyKey ?? defaultIdempotencyKey("transfers.create", provider, reference);
|
|
57
|
+
if (ctx.persistence) {
|
|
58
|
+
const cached = await ctx.persistence.findIdempotency(key);
|
|
59
|
+
if (cached)
|
|
60
|
+
return cached;
|
|
61
|
+
}
|
|
62
|
+
const connector = pickConnector(ctx, provider);
|
|
63
|
+
const transfer = await connector.createTransfer({
|
|
64
|
+
amount: normalizeAmount(parsed.data.amount),
|
|
65
|
+
currency: parsed.data.currency,
|
|
66
|
+
accountNumber: parsed.data.accountNumber,
|
|
67
|
+
bankCode: parsed.data.bankCode,
|
|
68
|
+
accountName: parsed.data.accountName,
|
|
69
|
+
narration: parsed.data.narration,
|
|
70
|
+
provider,
|
|
71
|
+
reference,
|
|
72
|
+
idempotencyKey: key,
|
|
73
|
+
metadata: parsed.data.metadata,
|
|
74
|
+
});
|
|
75
|
+
if (ctx.persistence) {
|
|
76
|
+
await ctx.persistence.saveTransfer(transfer);
|
|
77
|
+
await ctx.persistence.saveIdempotency(key, "transfers.create", provider, transfer);
|
|
78
|
+
}
|
|
79
|
+
return transfer;
|
|
80
|
+
}
|
|
81
|
+
export async function createRefund(ctx, input) {
|
|
82
|
+
const parsed = CreateRefundSchema.safeParse(input);
|
|
83
|
+
if (!parsed.success)
|
|
84
|
+
throw new ValidationError(parsed.error.message, parsed.error.flatten());
|
|
85
|
+
const provider = parsed.data.provider ?? ctx.defaultProvider;
|
|
86
|
+
const key = parsed.data.idempotencyKey ??
|
|
87
|
+
defaultIdempotencyKey("refunds.create", provider, `${parsed.data.paymentReference}:${parsed.data.amount ?? "full"}`);
|
|
88
|
+
if (ctx.persistence) {
|
|
89
|
+
const cached = await ctx.persistence.findIdempotency(key);
|
|
90
|
+
if (cached)
|
|
91
|
+
return cached;
|
|
92
|
+
}
|
|
93
|
+
const connector = pickConnector(ctx, provider);
|
|
94
|
+
const refund = await connector.createRefund({
|
|
95
|
+
paymentReference: parsed.data.paymentReference,
|
|
96
|
+
amount: parsed.data.amount ? normalizeAmount(parsed.data.amount) : undefined,
|
|
97
|
+
currency: parsed.data.currency,
|
|
98
|
+
reason: parsed.data.reason,
|
|
99
|
+
provider,
|
|
100
|
+
idempotencyKey: key,
|
|
101
|
+
});
|
|
102
|
+
if (ctx.persistence) {
|
|
103
|
+
await ctx.persistence.saveRefund(refund);
|
|
104
|
+
await ctx.persistence.saveIdempotency(key, "refunds.create", provider, refund);
|
|
105
|
+
}
|
|
106
|
+
return refund;
|
|
107
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CreatePaymentInput, CreateRefundInput, CreateTransferInput, OpenPayEvent, Payment, ProviderCapabilities, ProviderName, Refund, Transfer, VerifyPaymentInput, WebhookHeaders } from "../core/types.js";
|
|
2
|
+
/** Minimal fetch-compatible HTTP client (injectable for tests). */
|
|
3
|
+
export type HttpClient = (url: string, init: RequestInit) => Promise<Response>;
|
|
4
|
+
export interface ConnectorConfig {
|
|
5
|
+
secretKey: string;
|
|
6
|
+
/** Override base URL (tests, sandbox vs live). */
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
http?: HttpClient;
|
|
10
|
+
}
|
|
11
|
+
export interface CreatePaymentResult {
|
|
12
|
+
payment: Payment;
|
|
13
|
+
}
|
|
14
|
+
export interface ProviderConnector {
|
|
15
|
+
readonly name: ProviderName;
|
|
16
|
+
readonly capabilities: ProviderCapabilities;
|
|
17
|
+
createPayment(input: CreatePaymentInput & {
|
|
18
|
+
reference: string;
|
|
19
|
+
}): Promise<Payment>;
|
|
20
|
+
verifyPayment(input: VerifyPaymentInput): Promise<Payment>;
|
|
21
|
+
/** Throw CapabilityError when unsupported. */
|
|
22
|
+
createRefund(input: CreateRefundInput): Promise<Refund>;
|
|
23
|
+
createTransfer(input: CreateTransferInput & {
|
|
24
|
+
reference: string;
|
|
25
|
+
}): Promise<Transfer>;
|
|
26
|
+
verifyTransfer(reference: string): Promise<Transfer>;
|
|
27
|
+
verifyWebhookSignature(rawBody: string | Buffer, headers: WebhookHeaders): boolean;
|
|
28
|
+
normalizeWebhook(payload: unknown): OpenPayEvent | null;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Typed errors so apps can branch without parsing strings. */
|
|
2
|
+
export type OpenPayErrorCode = "validation_error" | "configuration_error" | "provider_error" | "capability_error" | "unknown_result" | "webhook_verification_error" | "idempotency_conflict";
|
|
3
|
+
export interface OpenPayErrorOptions {
|
|
4
|
+
provider?: string;
|
|
5
|
+
/** HTTP status from provider (if any). */
|
|
6
|
+
status?: number;
|
|
7
|
+
/** Safe to retry with same idempotency key? */
|
|
8
|
+
retryable?: boolean;
|
|
9
|
+
raw?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare class OpenPayError extends Error {
|
|
12
|
+
readonly code: OpenPayErrorCode;
|
|
13
|
+
readonly provider?: string;
|
|
14
|
+
readonly status?: number;
|
|
15
|
+
readonly retryable: boolean;
|
|
16
|
+
readonly raw?: unknown;
|
|
17
|
+
constructor(message: string, code: OpenPayErrorCode, opts?: OpenPayErrorOptions);
|
|
18
|
+
}
|
|
19
|
+
export declare class ValidationError extends OpenPayError {
|
|
20
|
+
constructor(message: string, raw?: unknown);
|
|
21
|
+
}
|
|
22
|
+
export declare class ConfigurationError extends OpenPayError {
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
25
|
+
export declare class ProviderError extends OpenPayError {
|
|
26
|
+
constructor(message: string, opts?: OpenPayErrorOptions);
|
|
27
|
+
}
|
|
28
|
+
/** Provider does not support this operation (e.g. Bachs refunds in V1). */
|
|
29
|
+
export declare class CapabilityError extends OpenPayError {
|
|
30
|
+
constructor(provider: string, operation: string, detail?: string);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Provider timed out or returned an ambiguous result.
|
|
34
|
+
* Outcome is UNKNOWN — verify before retrying, never treat as failed.
|
|
35
|
+
*/
|
|
36
|
+
export declare class UnknownResultError extends OpenPayError {
|
|
37
|
+
constructor(provider: string, reference: string, raw?: unknown);
|
|
38
|
+
}
|
|
39
|
+
export declare class WebhookVerificationError extends OpenPayError {
|
|
40
|
+
constructor(provider: string);
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Typed errors so apps can branch without parsing strings. */
|
|
2
|
+
export class OpenPayError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
provider;
|
|
5
|
+
status;
|
|
6
|
+
retryable;
|
|
7
|
+
raw;
|
|
8
|
+
constructor(message, code, opts = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = this.constructor.name;
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.provider = opts.provider;
|
|
13
|
+
this.status = opts.status;
|
|
14
|
+
this.retryable = opts.retryable ?? false;
|
|
15
|
+
this.raw = opts.raw;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class ValidationError extends OpenPayError {
|
|
19
|
+
constructor(message, raw) {
|
|
20
|
+
super(message, "validation_error", { retryable: false, raw });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class ConfigurationError extends OpenPayError {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message, "configuration_error", { retryable: false });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class ProviderError extends OpenPayError {
|
|
29
|
+
constructor(message, opts = {}) {
|
|
30
|
+
super(message, "provider_error", opts);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Provider does not support this operation (e.g. Bachs refunds in V1). */
|
|
34
|
+
export class CapabilityError extends OpenPayError {
|
|
35
|
+
constructor(provider, operation, detail) {
|
|
36
|
+
super(`${provider} does not support ${operation} in OpenPay V1${detail ? `: ${detail}` : ""}`, "capability_error", { provider, retryable: false });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Provider timed out or returned an ambiguous result.
|
|
41
|
+
* Outcome is UNKNOWN — verify before retrying, never treat as failed.
|
|
42
|
+
*/
|
|
43
|
+
export class UnknownResultError extends OpenPayError {
|
|
44
|
+
constructor(provider, reference, raw) {
|
|
45
|
+
super(`Unknown outcome for ${reference} on ${provider}: verify before retrying`, "unknown_result", { provider, retryable: true, raw });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class WebhookVerificationError extends OpenPayError {
|
|
49
|
+
constructor(provider) {
|
|
50
|
+
super(`Webhook signature verification failed for ${provider}`, "webhook_verification_error", {
|
|
51
|
+
provider,
|
|
52
|
+
retryable: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Build a deterministic idempotency key from operation + provider + reference. */
|
|
2
|
+
export declare function defaultIdempotencyKey(operation: string, provider: string, reference: string): string;
|
|
3
|
+
export declare function newReference(prefix?: string): string;
|
|
4
|
+
export declare function newId(): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/** Build a deterministic idempotency key from operation + provider + reference. */
|
|
3
|
+
export function defaultIdempotencyKey(operation, provider, reference) {
|
|
4
|
+
return `${operation}:${provider}:${reference}`;
|
|
5
|
+
}
|
|
6
|
+
export function newReference(prefix = "op") {
|
|
7
|
+
return `${prefix}_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
|
|
8
|
+
}
|
|
9
|
+
export function newId() {
|
|
10
|
+
return randomUUID();
|
|
11
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenPay NG — common domain models (V1).
|
|
3
|
+
*
|
|
4
|
+
* Money convention (Bachs-style, per project decision):
|
|
5
|
+
* - `amount` is ALWAYS a decimal string at the currency's precision, e.g. "5000.00"
|
|
6
|
+
* - `currency` is an ISO-4217 code, e.g. "NGN"
|
|
7
|
+
* Never use floats or minor-unit integers in the public interface.
|
|
8
|
+
* Minor-unit conversion (e.g. Paystack kobo) happens INSIDE connectors only.
|
|
9
|
+
*/
|
|
10
|
+
export type ProviderName = "paystack" | "flutterwave" | "bachs";
|
|
11
|
+
/**
|
|
12
|
+
* Webhook request headers. Node/Express `req.headers` (`IncomingHttpHeaders`)
|
|
13
|
+
* can be passed directly: values may be `string | string[] | undefined` and
|
|
14
|
+
* the first value wins for repeated headers — same rule as the local receiver.
|
|
15
|
+
*/
|
|
16
|
+
export type WebhookHeaderValue = string | string[] | undefined;
|
|
17
|
+
export type WebhookHeaders = Record<string, WebhookHeaderValue>;
|
|
18
|
+
/**
|
|
19
|
+
* Normalized payment lifecycle.
|
|
20
|
+
* - pending: created / awaiting customer action / processor pending
|
|
21
|
+
* - successful: money moved and confirmed (verify or webhook)
|
|
22
|
+
* - failed: definitively failed / cancelled / expired
|
|
23
|
+
* - unknown: timed out, ambiguous, or unmapped — MUST be verified before retry
|
|
24
|
+
*/
|
|
25
|
+
export type PaymentStatus = "pending" | "successful" | "failed" | "unknown";
|
|
26
|
+
export type TransferStatus = PaymentStatus;
|
|
27
|
+
export type RefundStatus = PaymentStatus;
|
|
28
|
+
export interface Customer {
|
|
29
|
+
email: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
phone?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CreatePaymentInput {
|
|
34
|
+
/** Decimal string, e.g. "5000.00" */
|
|
35
|
+
amount: string;
|
|
36
|
+
currency: string;
|
|
37
|
+
customer: Customer;
|
|
38
|
+
/** Which provider connector to use. Defaults via config. */
|
|
39
|
+
provider?: ProviderName;
|
|
40
|
+
/** Merchant's unique reference. Generated if omitted. */
|
|
41
|
+
reference?: string;
|
|
42
|
+
/** Idempotency key. Defaults to `payments:{provider}:{reference}`. */
|
|
43
|
+
idempotencyKey?: string;
|
|
44
|
+
redirectUrl?: string;
|
|
45
|
+
metadata?: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
export interface Payment {
|
|
48
|
+
/** Our record id (uuid). */
|
|
49
|
+
id: string;
|
|
50
|
+
provider: ProviderName;
|
|
51
|
+
/** Merchant reference (what you passed / what we generated). */
|
|
52
|
+
reference: string;
|
|
53
|
+
/** Provider-side id: Paystack reference, Flutterwave tx_ref/id, Bachs checkout_id. */
|
|
54
|
+
providerRef: string;
|
|
55
|
+
amount: string;
|
|
56
|
+
currency: string;
|
|
57
|
+
status: PaymentStatus;
|
|
58
|
+
/** Hosted checkout / authorization URL for the customer (if any). */
|
|
59
|
+
checkoutUrl?: string;
|
|
60
|
+
customerEmail?: string;
|
|
61
|
+
/** Raw provider response for debugging. Never expose card data. */
|
|
62
|
+
raw?: unknown;
|
|
63
|
+
}
|
|
64
|
+
export interface VerifyPaymentInput {
|
|
65
|
+
provider?: ProviderName;
|
|
66
|
+
/** Merchant reference OR provider ref — connectors try both. */
|
|
67
|
+
reference: string;
|
|
68
|
+
}
|
|
69
|
+
export interface CreateTransferInput {
|
|
70
|
+
amount: string;
|
|
71
|
+
currency: string;
|
|
72
|
+
accountNumber: string;
|
|
73
|
+
bankCode: string;
|
|
74
|
+
accountName?: string;
|
|
75
|
+
narration?: string;
|
|
76
|
+
provider?: ProviderName;
|
|
77
|
+
reference?: string;
|
|
78
|
+
idempotencyKey?: string;
|
|
79
|
+
metadata?: Record<string, string>;
|
|
80
|
+
}
|
|
81
|
+
export interface Transfer {
|
|
82
|
+
id: string;
|
|
83
|
+
provider: ProviderName;
|
|
84
|
+
reference: string;
|
|
85
|
+
providerRef: string;
|
|
86
|
+
amount: string;
|
|
87
|
+
currency: string;
|
|
88
|
+
status: TransferStatus;
|
|
89
|
+
raw?: unknown;
|
|
90
|
+
}
|
|
91
|
+
export interface CreateRefundInput {
|
|
92
|
+
/** Merchant payment reference to refund. */
|
|
93
|
+
paymentReference: string;
|
|
94
|
+
amount?: string;
|
|
95
|
+
currency?: string;
|
|
96
|
+
reason?: string;
|
|
97
|
+
provider?: ProviderName;
|
|
98
|
+
idempotencyKey?: string;
|
|
99
|
+
}
|
|
100
|
+
export interface Refund {
|
|
101
|
+
id: string;
|
|
102
|
+
provider: ProviderName;
|
|
103
|
+
paymentReference: string;
|
|
104
|
+
providerRef: string;
|
|
105
|
+
amount: string;
|
|
106
|
+
currency: string;
|
|
107
|
+
status: RefundStatus;
|
|
108
|
+
raw?: unknown;
|
|
109
|
+
}
|
|
110
|
+
export type OpenPayEventType = "payment.succeeded" | "payment.failed" | "payment.pending" | "transfer.succeeded" | "transfer.failed" | "refund.processed" | "refund.failed" | "unknown";
|
|
111
|
+
export interface OpenPayEvent {
|
|
112
|
+
type: OpenPayEventType;
|
|
113
|
+
provider: ProviderName;
|
|
114
|
+
/** Provider event id if available (for dedupe). */
|
|
115
|
+
providerEventId?: string;
|
|
116
|
+
/** Merchant reference if extractable. */
|
|
117
|
+
reference?: string;
|
|
118
|
+
raw: unknown;
|
|
119
|
+
}
|
|
120
|
+
/** Capability flags — OpenPay never pretends providers are identical. */
|
|
121
|
+
export interface ProviderCapabilities {
|
|
122
|
+
paymentsCreate: boolean;
|
|
123
|
+
paymentsVerify: boolean;
|
|
124
|
+
refunds: boolean;
|
|
125
|
+
transfers: boolean;
|
|
126
|
+
webhooks: boolean;
|
|
127
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenPay NG — common domain models (V1).
|
|
3
|
+
*
|
|
4
|
+
* Money convention (Bachs-style, per project decision):
|
|
5
|
+
* - `amount` is ALWAYS a decimal string at the currency's precision, e.g. "5000.00"
|
|
6
|
+
* - `currency` is an ISO-4217 code, e.g. "NGN"
|
|
7
|
+
* Never use floats or minor-unit integers in the public interface.
|
|
8
|
+
* Minor-unit conversion (e.g. Paystack kobo) happens INSIDE connectors only.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Decimal-string money, e.g. "5000.00". Up to 2 decimals, no floats. */
|
|
3
|
+
export declare const AmountSchema: z.ZodEffects<z.ZodString, string, string>;
|
|
4
|
+
export declare const CurrencySchema: z.ZodDefault<z.ZodString>;
|
|
5
|
+
export declare const CustomerSchema: z.ZodObject<{
|
|
6
|
+
email: z.ZodString;
|
|
7
|
+
name: z.ZodOptional<z.ZodString>;
|
|
8
|
+
phone: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, "strip", z.ZodTypeAny, {
|
|
10
|
+
email: string;
|
|
11
|
+
name?: string | undefined;
|
|
12
|
+
phone?: string | undefined;
|
|
13
|
+
}, {
|
|
14
|
+
email: string;
|
|
15
|
+
name?: string | undefined;
|
|
16
|
+
phone?: string | undefined;
|
|
17
|
+
}>;
|
|
18
|
+
export declare const ProviderSchema: z.ZodEnum<["paystack", "flutterwave", "bachs"]>;
|
|
19
|
+
export declare const CreatePaymentSchema: z.ZodObject<{
|
|
20
|
+
amount: z.ZodEffects<z.ZodString, string, string>;
|
|
21
|
+
currency: z.ZodDefault<z.ZodString>;
|
|
22
|
+
customer: z.ZodObject<{
|
|
23
|
+
email: z.ZodString;
|
|
24
|
+
name: z.ZodOptional<z.ZodString>;
|
|
25
|
+
phone: z.ZodOptional<z.ZodString>;
|
|
26
|
+
}, "strip", z.ZodTypeAny, {
|
|
27
|
+
email: string;
|
|
28
|
+
name?: string | undefined;
|
|
29
|
+
phone?: string | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
email: string;
|
|
32
|
+
name?: string | undefined;
|
|
33
|
+
phone?: string | undefined;
|
|
34
|
+
}>;
|
|
35
|
+
provider: z.ZodOptional<z.ZodEnum<["paystack", "flutterwave", "bachs"]>>;
|
|
36
|
+
reference: z.ZodOptional<z.ZodString>;
|
|
37
|
+
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
38
|
+
redirectUrl: z.ZodOptional<z.ZodString>;
|
|
39
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
40
|
+
}, "strip", z.ZodTypeAny, {
|
|
41
|
+
amount: string;
|
|
42
|
+
currency: string;
|
|
43
|
+
customer: {
|
|
44
|
+
email: string;
|
|
45
|
+
name?: string | undefined;
|
|
46
|
+
phone?: string | undefined;
|
|
47
|
+
};
|
|
48
|
+
provider?: "paystack" | "flutterwave" | "bachs" | undefined;
|
|
49
|
+
reference?: string | undefined;
|
|
50
|
+
idempotencyKey?: string | undefined;
|
|
51
|
+
redirectUrl?: string | undefined;
|
|
52
|
+
metadata?: Record<string, string> | undefined;
|
|
53
|
+
}, {
|
|
54
|
+
amount: string;
|
|
55
|
+
customer: {
|
|
56
|
+
email: string;
|
|
57
|
+
name?: string | undefined;
|
|
58
|
+
phone?: string | undefined;
|
|
59
|
+
};
|
|
60
|
+
provider?: "paystack" | "flutterwave" | "bachs" | undefined;
|
|
61
|
+
currency?: string | undefined;
|
|
62
|
+
reference?: string | undefined;
|
|
63
|
+
idempotencyKey?: string | undefined;
|
|
64
|
+
redirectUrl?: string | undefined;
|
|
65
|
+
metadata?: Record<string, string> | undefined;
|
|
66
|
+
}>;
|
|
67
|
+
export declare const VerifyPaymentSchema: z.ZodObject<{
|
|
68
|
+
reference: z.ZodString;
|
|
69
|
+
provider: z.ZodOptional<z.ZodEnum<["paystack", "flutterwave", "bachs"]>>;
|
|
70
|
+
}, "strip", z.ZodTypeAny, {
|
|
71
|
+
reference: string;
|
|
72
|
+
provider?: "paystack" | "flutterwave" | "bachs" | undefined;
|
|
73
|
+
}, {
|
|
74
|
+
reference: string;
|
|
75
|
+
provider?: "paystack" | "flutterwave" | "bachs" | undefined;
|
|
76
|
+
}>;
|
|
77
|
+
export declare const CreateTransferSchema: z.ZodObject<{
|
|
78
|
+
amount: z.ZodEffects<z.ZodString, string, string>;
|
|
79
|
+
currency: z.ZodDefault<z.ZodString>;
|
|
80
|
+
accountNumber: z.ZodString;
|
|
81
|
+
bankCode: z.ZodString;
|
|
82
|
+
accountName: z.ZodOptional<z.ZodString>;
|
|
83
|
+
narration: z.ZodOptional<z.ZodString>;
|
|
84
|
+
provider: z.ZodOptional<z.ZodEnum<["paystack", "flutterwave", "bachs"]>>;
|
|
85
|
+
reference: z.ZodOptional<z.ZodString>;
|
|
86
|
+
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
87
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
88
|
+
}, "strip", z.ZodTypeAny, {
|
|
89
|
+
amount: string;
|
|
90
|
+
currency: string;
|
|
91
|
+
accountNumber: string;
|
|
92
|
+
bankCode: string;
|
|
93
|
+
provider?: "paystack" | "flutterwave" | "bachs" | undefined;
|
|
94
|
+
reference?: string | undefined;
|
|
95
|
+
idempotencyKey?: string | undefined;
|
|
96
|
+
metadata?: Record<string, string> | undefined;
|
|
97
|
+
accountName?: string | undefined;
|
|
98
|
+
narration?: string | undefined;
|
|
99
|
+
}, {
|
|
100
|
+
amount: string;
|
|
101
|
+
accountNumber: string;
|
|
102
|
+
bankCode: string;
|
|
103
|
+
provider?: "paystack" | "flutterwave" | "bachs" | undefined;
|
|
104
|
+
currency?: string | undefined;
|
|
105
|
+
reference?: string | undefined;
|
|
106
|
+
idempotencyKey?: string | undefined;
|
|
107
|
+
metadata?: Record<string, string> | undefined;
|
|
108
|
+
accountName?: string | undefined;
|
|
109
|
+
narration?: string | undefined;
|
|
110
|
+
}>;
|
|
111
|
+
export declare const CreateRefundSchema: z.ZodObject<{
|
|
112
|
+
paymentReference: z.ZodString;
|
|
113
|
+
amount: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
114
|
+
currency: z.ZodDefault<z.ZodString>;
|
|
115
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
116
|
+
provider: z.ZodOptional<z.ZodEnum<["paystack", "flutterwave", "bachs"]>>;
|
|
117
|
+
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
118
|
+
}, "strip", z.ZodTypeAny, {
|
|
119
|
+
currency: string;
|
|
120
|
+
paymentReference: string;
|
|
121
|
+
provider?: "paystack" | "flutterwave" | "bachs" | undefined;
|
|
122
|
+
amount?: string | undefined;
|
|
123
|
+
idempotencyKey?: string | undefined;
|
|
124
|
+
reason?: string | undefined;
|
|
125
|
+
}, {
|
|
126
|
+
paymentReference: string;
|
|
127
|
+
provider?: "paystack" | "flutterwave" | "bachs" | undefined;
|
|
128
|
+
amount?: string | undefined;
|
|
129
|
+
currency?: string | undefined;
|
|
130
|
+
idempotencyKey?: string | undefined;
|
|
131
|
+
reason?: string | undefined;
|
|
132
|
+
}>;
|
|
133
|
+
export declare function normalizeAmount(value: string): string;
|
|
134
|
+
/** Convert "5000.00" NGN -> 500000 kobo (Paystack minor units). Integer math only. */
|
|
135
|
+
export declare function toMinorUnits(amount: string): number;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Decimal-string money, e.g. "5000.00". Up to 2 decimals, no floats. */
|
|
3
|
+
export const AmountSchema = z
|
|
4
|
+
.string()
|
|
5
|
+
.regex(/^\d+(\.\d{1,2})?$/, "amount must be a decimal string like \"5000.00\"")
|
|
6
|
+
.refine((v) => Number(v) > 0, "amount must be greater than zero");
|
|
7
|
+
export const CurrencySchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.regex(/^[A-Z]{3}$/, "currency must be a 3-letter ISO-4217 code like NGN")
|
|
10
|
+
.default("NGN");
|
|
11
|
+
export const CustomerSchema = z.object({
|
|
12
|
+
email: z.string().email(),
|
|
13
|
+
name: z.string().min(1).max(200).optional(),
|
|
14
|
+
phone: z.string().min(3).max(30).optional(),
|
|
15
|
+
});
|
|
16
|
+
export const ProviderSchema = z.enum(["paystack", "flutterwave", "bachs"]);
|
|
17
|
+
export const CreatePaymentSchema = z.object({
|
|
18
|
+
amount: AmountSchema,
|
|
19
|
+
currency: CurrencySchema,
|
|
20
|
+
customer: CustomerSchema,
|
|
21
|
+
provider: ProviderSchema.optional(),
|
|
22
|
+
reference: z.string().min(3).max(128).optional(),
|
|
23
|
+
idempotencyKey: z.string().min(8).max(256).optional(),
|
|
24
|
+
redirectUrl: z.string().url().optional(),
|
|
25
|
+
metadata: z.record(z.string()).optional(),
|
|
26
|
+
});
|
|
27
|
+
export const VerifyPaymentSchema = z.object({
|
|
28
|
+
reference: z.string().min(1).max(256),
|
|
29
|
+
provider: ProviderSchema.optional(),
|
|
30
|
+
});
|
|
31
|
+
export const CreateTransferSchema = z.object({
|
|
32
|
+
amount: AmountSchema,
|
|
33
|
+
currency: CurrencySchema,
|
|
34
|
+
accountNumber: z.string().min(5).max(20),
|
|
35
|
+
bankCode: z.string().min(2).max(12),
|
|
36
|
+
accountName: z.string().min(1).max(200).optional(),
|
|
37
|
+
narration: z.string().max(200).optional(),
|
|
38
|
+
provider: ProviderSchema.optional(),
|
|
39
|
+
reference: z.string().min(3).max(128).optional(),
|
|
40
|
+
idempotencyKey: z.string().min(8).max(256).optional(),
|
|
41
|
+
metadata: z.record(z.string()).optional(),
|
|
42
|
+
});
|
|
43
|
+
export const CreateRefundSchema = z.object({
|
|
44
|
+
paymentReference: z.string().min(1).max(256),
|
|
45
|
+
amount: AmountSchema.optional(),
|
|
46
|
+
currency: CurrencySchema,
|
|
47
|
+
reason: z.string().max(300).optional(),
|
|
48
|
+
provider: ProviderSchema.optional(),
|
|
49
|
+
idempotencyKey: z.string().min(8).max(256).optional(),
|
|
50
|
+
});
|
|
51
|
+
export function normalizeAmount(value) {
|
|
52
|
+
// Always store/compare with 2 decimals: "5000" -> "5000.00"
|
|
53
|
+
const n = Number(value);
|
|
54
|
+
return n.toFixed(2);
|
|
55
|
+
}
|
|
56
|
+
/** Convert "5000.00" NGN -> 500000 kobo (Paystack minor units). Integer math only. */
|
|
57
|
+
export function toMinorUnits(amount) {
|
|
58
|
+
const [whole = "0", frac = ""] = amount.split(".");
|
|
59
|
+
const fracPadded = (frac + "00").slice(0, 2);
|
|
60
|
+
return Number(whole) * 100 + Number(fracPadded);
|
|
61
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
+
import * as schema from "./schema.js";
|
|
3
|
+
export type Db = NodePgDatabase<typeof schema>;
|
|
4
|
+
export declare function getDb(connectionString?: string): Db;
|
|
5
|
+
/** For tests / scripts: reset the cached client. */
|
|
6
|
+
export declare function _resetDbCache(): void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
import * as schema from "./schema.js";
|
|
4
|
+
let cached;
|
|
5
|
+
export function getDb(connectionString) {
|
|
6
|
+
if (cached)
|
|
7
|
+
return cached;
|
|
8
|
+
const url = connectionString ?? process.env.DATABASE_URL;
|
|
9
|
+
if (!url)
|
|
10
|
+
throw new Error("DATABASE_URL is not set");
|
|
11
|
+
const pool = new pg.Pool({ connectionString: url });
|
|
12
|
+
cached = drizzle(pool, { schema });
|
|
13
|
+
return cached;
|
|
14
|
+
}
|
|
15
|
+
/** For tests / scripts: reset the cached client. */
|
|
16
|
+
export function _resetDbCache() {
|
|
17
|
+
cached = undefined;
|
|
18
|
+
}
|