@pay-normalize/core 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 +15 -0
- package/README.md +123 -0
- package/dist/index.d.ts +353 -0
- package/dist/index.js +258 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pay-normalize contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @pay-normalize/core
|
|
2
|
+
|
|
3
|
+
The contract. Every connector depends on this package and nothing else; hosts import the shared types, the money helpers, and the ordering/dedupe primitives from here.
|
|
4
|
+
|
|
5
|
+
- Data model & design rationale: [../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md)
|
|
6
|
+
- Host wiring: [../../docs/INTEGRATION.md](../../docs/INTEGRATION.md)
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import {
|
|
10
|
+
validateTransaction, type StandardizedTransaction,
|
|
11
|
+
parseNairaDecimalString, parseKoboInteger, asKobo, koboToNairaString, type Kobo,
|
|
12
|
+
shouldApplyStatusTransition, STATUS_RANK, statusRank, type TransactionStatus,
|
|
13
|
+
type PaymentChannel,
|
|
14
|
+
composeDedupeKey,
|
|
15
|
+
toRawBody, rawBodyFromString, type RawBody,
|
|
16
|
+
type Connector, type ParseResult, type SettlementFileParseResult, summarizeRows,
|
|
17
|
+
NormalizationError, MalformedPayloadError, AmountParseError,
|
|
18
|
+
SignatureVerificationError, UnsupportedFileFormatError, type NormalizationErrorCode,
|
|
19
|
+
} from "@pay-normalize/core";
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Schema
|
|
25
|
+
|
|
26
|
+
### `StandardizedTransaction`
|
|
27
|
+
The single output shape of every connector. Full field table in [ARCHITECTURE.md](../../docs/ARCHITECTURE.md#the-output-standardizedtransaction). Key invariant, enforced at runtime: `netAmountInKobo === amountInKobo - feeInKobo`.
|
|
28
|
+
|
|
29
|
+
### `validateTransaction(input: unknown): StandardizedTransaction`
|
|
30
|
+
Runtime gate (Zod) every connector output passes through before leaving the library. Throws a `ZodError` if the shape or the money invariant is violated — this is an internal guard, so a connector bug surfaces loudly rather than emitting a bad row.
|
|
31
|
+
|
|
32
|
+
### `StandardizedTransactionSchema`
|
|
33
|
+
The Zod schema itself, exported for hosts that want to re-validate at their own boundary.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Money
|
|
38
|
+
|
|
39
|
+
Integer minor units only; conversion happens once, at the connector boundary, with BigInt/string math. `Kobo` is a branded `number` so unconverted amounts can't leak.
|
|
40
|
+
|
|
41
|
+
| Export | Signature | Notes |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `asKobo` | `(value: number) => Kobo` | Assert an already-integer, non-negative value is `Kobo`. Throws `AmountParseError` otherwise. |
|
|
44
|
+
| `parseKoboInteger` | `(input: number \| string) => Kobo` | For providers that send kobo integers. Rejects decimals and values over `MAX_SAFE_INTEGER`. |
|
|
45
|
+
| `parseNairaDecimalString` | `(input: string \| number) => Kobo` | For main-unit decimals (`"5,000.00"`, `5000`, `1500.25`, `"₦5000"`). Rejects negatives, >2dp, scientific notation, and over-long input. |
|
|
46
|
+
| `koboToNairaString` | `(kobo: Kobo) => string` | Display only. Never feed back into parsing. |
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
parseKoboInteger(10000); // 10000 (already kobo)
|
|
50
|
+
parseNairaDecimalString(120); // 12000
|
|
51
|
+
parseNairaDecimalString("1500.25");// 150025 (no float drift)
|
|
52
|
+
asKobo(0); // 0
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Status ordering
|
|
58
|
+
|
|
59
|
+
`TransactionStatus = "PENDING" | "FAILED" | "SUCCESSFUL" | "REVERSED"`, ranked `0 < 1 < 2 < 3`.
|
|
60
|
+
|
|
61
|
+
| Export | Signature | Notes |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `STATUS_RANK` | `Readonly<Record<TransactionStatus, number>>` | Frozen rank map. |
|
|
64
|
+
| `statusRank` | `(status) => number` | Rank lookup. |
|
|
65
|
+
| `shouldApplyStatusTransition` | `(current, incoming) => boolean` | `true` iff `rank(incoming) > rank(current)`. Equal rank → `false` (idempotent redelivery). |
|
|
66
|
+
|
|
67
|
+
Use it in your upsert to make out-of-order and duplicate delivery safe. See [INTEGRATION.md §4b](../../docs/INTEGRATION.md#4b-a-rank-guard-on-status--for-out-of-order-delivery).
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Dedupe identity
|
|
72
|
+
|
|
73
|
+
| Export | Signature | Notes |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `composeDedupeKey` | `(provider: string, reference: string) => string` | Returns `${provider}:${reference}` (provider lowercased, both trimmed). Throws on empty input. |
|
|
76
|
+
|
|
77
|
+
Connectors namespace the reference by family (`charge:…`, `transfer:…`, `transaction:…`) so a charge and its refund, or a webhook and its API-record twin, share one key. The host enforces uniqueness with a DB index.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Raw body
|
|
82
|
+
|
|
83
|
+
| Export | Signature | Notes |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `RawBody` | `Buffer & { __brand: "RawBody" }` | Branded — only way to satisfy `verifyWebhookSignature`. |
|
|
86
|
+
| `toRawBody` | `(buf: Buffer) => RawBody` | Production path. Throws `TypeError` if not a `Buffer`. |
|
|
87
|
+
| `rawBodyFromString` | `(s: string, enc?) => RawBody` | Tests / fixture replay only. |
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Connector & results
|
|
92
|
+
|
|
93
|
+
| Export | Notes |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `Connector` | The interface every provider package implements. See [ARCHITECTURE.md](../../docs/ARCHITECTURE.md#the-connector-interface). |
|
|
96
|
+
| `ParseResult` | `transaction \| unknown_event \| parse_error`. Never `null`, never a throw. |
|
|
97
|
+
| `SettlementFileParseResult` | Row-isolated file result with a `summary` of counts. |
|
|
98
|
+
| `summarizeRows` | `(rows: ParseResult[]) => summary` | Counts by outcome. |
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Errors
|
|
103
|
+
|
|
104
|
+
All extend `NormalizationError` and carry a stable `code: NormalizationErrorCode` — **route on `code`, not `message`.**
|
|
105
|
+
|
|
106
|
+
| Class | `code` |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `SignatureVerificationError` | `ERR_SIGNATURE_VERIFICATION` |
|
|
109
|
+
| `MalformedPayloadError` | `ERR_MALFORMED_PAYLOAD` (or narrowed `ERR_TIMESTAMP_MISSING`) |
|
|
110
|
+
| `AmountParseError` | `ERR_AMOUNT_PARSE` |
|
|
111
|
+
| `UnsupportedFileFormatError` | `ERR_UNSUPPORTED_FILE_FORMAT` |
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
if (result.kind === "parse_error" && result.error.code === "ERR_TIMESTAMP_MISSING") {
|
|
115
|
+
// retry via the fetch-time overload
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Dependencies
|
|
122
|
+
|
|
123
|
+
`zod` (runtime validation). Node 18+, ESM.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Kobo — the ONLY representation of money inside this library.
|
|
5
|
+
*
|
|
6
|
+
* Branded type: a plain `number` will not typecheck where `Kobo` is required,
|
|
7
|
+
* so unconverted provider amounts cannot leak past the connector boundary.
|
|
8
|
+
*
|
|
9
|
+
* Rules (NOT_DOING.md §9):
|
|
10
|
+
* - Integers only. Floats are banned; all parsing below uses string/BigInt math,
|
|
11
|
+
* so `"5000.10"` can never become `500009.999...` via IEEE-754.
|
|
12
|
+
* - Conversion happens exactly ONCE, at the connector boundary, via the
|
|
13
|
+
* parsers in this file. Core and hosts only ever see Kobo.
|
|
14
|
+
*/
|
|
15
|
+
type Kobo = number & {
|
|
16
|
+
readonly __brand: "Kobo";
|
|
17
|
+
};
|
|
18
|
+
/** Assert an already-integer amount is valid Kobo (e.g. Paystack's `amount` field). */
|
|
19
|
+
declare function asKobo(value: number): Kobo;
|
|
20
|
+
/**
|
|
21
|
+
* Parse a kobo amount that arrives as an integer-looking value.
|
|
22
|
+
* Accepts `500000` or `"500000"`. Rejects decimals — if a provider sends
|
|
23
|
+
* decimals, it is a naira amount and MUST go through parseNairaDecimalString.
|
|
24
|
+
*/
|
|
25
|
+
declare function parseKoboInteger(input: number | string): Kobo;
|
|
26
|
+
/**
|
|
27
|
+
* Parse naira expressed as a decimal string or number into Kobo, using
|
|
28
|
+
* string math only (never parseFloat).
|
|
29
|
+
*
|
|
30
|
+
* Accepts the shapes actually seen across Nigerian providers:
|
|
31
|
+
* "5000" -> 500000
|
|
32
|
+
* "5000.00" -> 500000
|
|
33
|
+
* "5000.5" -> 500050 (one decimal place = tens of kobo)
|
|
34
|
+
* "5,000.00" -> 500000 (thousands separators)
|
|
35
|
+
* 5000 -> 500000 (numeric naira, e.g. Monnify amounts)
|
|
36
|
+
* "₦5,000.00" -> 500000
|
|
37
|
+
*
|
|
38
|
+
* Rejects: negatives, >2 decimal places (no such thing as half a kobo),
|
|
39
|
+
* empty strings, scientific notation, over-long input, and anything ambiguous.
|
|
40
|
+
*/
|
|
41
|
+
declare function parseNairaDecimalString(input: string | number): Kobo;
|
|
42
|
+
/** Display helper only. Never feed its output back into parsing logic. */
|
|
43
|
+
declare function koboToNairaString(kobo: Kobo): string;
|
|
44
|
+
|
|
45
|
+
declare const StandardizedTransactionSchema: z.ZodObject<{
|
|
46
|
+
dedupeKey: z.ZodString;
|
|
47
|
+
provider: z.ZodString;
|
|
48
|
+
providerReference: z.ZodString;
|
|
49
|
+
providerEventId: z.ZodOptional<z.ZodString>;
|
|
50
|
+
amountInKobo: z.ZodNumber;
|
|
51
|
+
feeInKobo: z.ZodNumber;
|
|
52
|
+
netAmountInKobo: z.ZodNumber;
|
|
53
|
+
currency: z.ZodString;
|
|
54
|
+
status: z.ZodEnum<{
|
|
55
|
+
PENDING: "PENDING";
|
|
56
|
+
FAILED: "FAILED";
|
|
57
|
+
SUCCESSFUL: "SUCCESSFUL";
|
|
58
|
+
REVERSED: "REVERSED";
|
|
59
|
+
}>;
|
|
60
|
+
channel: z.ZodEnum<{
|
|
61
|
+
card: "card";
|
|
62
|
+
bank_transfer: "bank_transfer";
|
|
63
|
+
ussd: "ussd";
|
|
64
|
+
qr: "qr";
|
|
65
|
+
wallet: "wallet";
|
|
66
|
+
pos: "pos";
|
|
67
|
+
unknown: "unknown";
|
|
68
|
+
}>;
|
|
69
|
+
direction: z.ZodEnum<{
|
|
70
|
+
credit: "credit";
|
|
71
|
+
debit: "debit";
|
|
72
|
+
}>;
|
|
73
|
+
occurredAt: z.ZodDate;
|
|
74
|
+
occurredAtRaw: z.ZodOptional<z.ZodString>;
|
|
75
|
+
settlementDate: z.ZodNullable<z.ZodDate>;
|
|
76
|
+
rawProviderPayload: z.ZodUnknown;
|
|
77
|
+
}, z.core.$strict>;
|
|
78
|
+
type StandardizedTransaction = Omit<z.infer<typeof StandardizedTransactionSchema>, "amountInKobo" | "feeInKobo" | "netAmountInKobo"> & {
|
|
79
|
+
amountInKobo: Kobo;
|
|
80
|
+
feeInKobo: Kobo;
|
|
81
|
+
netAmountInKobo: Kobo;
|
|
82
|
+
};
|
|
83
|
+
/** Runtime gate every connector output passes through before leaving the library. */
|
|
84
|
+
declare function validateTransaction(input: unknown): StandardizedTransaction;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The four terminal-ish states every provider vocabulary maps down to.
|
|
88
|
+
* Connectors own the mapping table (e.g. OPay "SUCCESS", Paystack "success",
|
|
89
|
+
* Monnify "PAID" -> SUCCESSFUL) — explicitly, per NOT_DOING.md §9:
|
|
90
|
+
* no status inference from amount presence or absence of error fields.
|
|
91
|
+
*/
|
|
92
|
+
declare const TransactionStatusSchema: z.ZodEnum<{
|
|
93
|
+
PENDING: "PENDING";
|
|
94
|
+
FAILED: "FAILED";
|
|
95
|
+
SUCCESSFUL: "SUCCESSFUL";
|
|
96
|
+
REVERSED: "REVERSED";
|
|
97
|
+
}>;
|
|
98
|
+
type TransactionStatus = z.infer<typeof TransactionStatusSchema>;
|
|
99
|
+
/**
|
|
100
|
+
* STATUS_RANK — the ordering that makes out-of-order webhook delivery safe.
|
|
101
|
+
*
|
|
102
|
+
* Why this exists (distributed-systems reality, not theory):
|
|
103
|
+
* - Providers deliver webhooks with retries + exponential backoff. Retries mean
|
|
104
|
+
* DUPLICATES; parallel delivery paths + network partitions mean REORDERING.
|
|
105
|
+
* A "pending" emitted at T0 can arrive AFTER the "successful" emitted at T1.
|
|
106
|
+
* - You cannot fix this with timestamps: provider clocks skew, and several
|
|
107
|
+
* providers send unlabeled local-time (WAT) strings. Ordering by rank is
|
|
108
|
+
* deterministic; ordering by clock is a race condition wearing a watch.
|
|
109
|
+
*
|
|
110
|
+
* REVERSED outranks SUCCESSFUL deliberately: success->reversal is a legitimate
|
|
111
|
+
* forward progression (refunds, chargebacks). PENDING can never overwrite anything.
|
|
112
|
+
* FAILED -> SUCCESSFUL is allowed (rank 1 -> 2): some providers emit a failure
|
|
113
|
+
* then succeed on an internal retry under the same reference.
|
|
114
|
+
*/
|
|
115
|
+
declare const STATUS_RANK: Readonly<Record<TransactionStatus, number>>;
|
|
116
|
+
declare function statusRank(status: TransactionStatus): number;
|
|
117
|
+
/**
|
|
118
|
+
* The one-line correctness rule for host applications:
|
|
119
|
+
*
|
|
120
|
+
* if (!shouldApplyStatusTransition(stored.status, incoming.status)) skip;
|
|
121
|
+
*
|
|
122
|
+
* Equal rank returns false — re-delivery of the same status is a no-op
|
|
123
|
+
* (idempotency). Host must pair this with:
|
|
124
|
+
* 1. a UNIQUE index on dedupeKey (hard idempotency at the storage layer), and
|
|
125
|
+
* 2. optimistic locking (version column / compare-and-swap) or
|
|
126
|
+
* SELECT ... FOR UPDATE when applying the transition, because two webhook
|
|
127
|
+
* deliveries for the same transaction WILL race on concurrent workers.
|
|
128
|
+
* This library is stateless by design (NOT_DOING.md §6) — it hands you the
|
|
129
|
+
* rule; your database enforces it.
|
|
130
|
+
*/
|
|
131
|
+
declare function shouldApplyStatusTransition(current: TransactionStatus, incoming: TransactionStatus): boolean;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* How the money moved. 'unknown' is a legitimate value — bank statement rows
|
|
135
|
+
* frequently do not disclose the channel, and guessing violates NOT_DOING.md §9.
|
|
136
|
+
* 'pos' covers agent/terminal collections (OPay, PalmPay, Moniepoint territory).
|
|
137
|
+
*/
|
|
138
|
+
declare const PaymentChannelSchema: z.ZodEnum<{
|
|
139
|
+
card: "card";
|
|
140
|
+
bank_transfer: "bank_transfer";
|
|
141
|
+
ussd: "ussd";
|
|
142
|
+
qr: "qr";
|
|
143
|
+
wallet: "wallet";
|
|
144
|
+
pos: "pos";
|
|
145
|
+
unknown: "unknown";
|
|
146
|
+
}>;
|
|
147
|
+
type PaymentChannel = z.infer<typeof PaymentChannelSchema>;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* RawBody — a branded Buffer that connectors require for signature verification.
|
|
151
|
+
*
|
|
152
|
+
* THE #1 INTEGRATION BUG THIS PREVENTS:
|
|
153
|
+
* Providers compute webhook signatures (HMAC-SHA512 for Paystack, provider-specific
|
|
154
|
+
* schemes elsewhere) over the EXACT BYTES they sent. If a host runs the request
|
|
155
|
+
* through `express.json()` and later re-serializes (`JSON.stringify(req.body)`),
|
|
156
|
+
* key order, whitespace, and unicode escaping can change — the HMAC no longer
|
|
157
|
+
* matches, and the host spends a night debugging "invalid signature" on
|
|
158
|
+
* perfectly valid webhooks.
|
|
159
|
+
*
|
|
160
|
+
* By requiring `RawBody` (constructible only from a Buffer via `toRawBody`),
|
|
161
|
+
* a host that only has parsed JSON gets a COMPILE ERROR instead of a 2am mystery.
|
|
162
|
+
*
|
|
163
|
+
* Express hosts: mount `express.raw({ type: "application/json" })` on webhook
|
|
164
|
+
* routes (route-scoped, before any json middleware), then `toRawBody(req.body)`.
|
|
165
|
+
* Parse to JSON only AFTER verification, from these same bytes.
|
|
166
|
+
*/
|
|
167
|
+
type RawBody = Buffer & {
|
|
168
|
+
readonly __brand: "RawBody";
|
|
169
|
+
};
|
|
170
|
+
declare function toRawBody(buf: Buffer): RawBody;
|
|
171
|
+
/**
|
|
172
|
+
* Escape hatch for tests and fixture replay ONLY. Constructing a RawBody from a
|
|
173
|
+
* string in production risks re-serialization drift — fixtures store the
|
|
174
|
+
* original bytes precisely so this stays faithful.
|
|
175
|
+
*/
|
|
176
|
+
declare function rawBodyFromString(s: string, encoding?: BufferEncoding): RawBody;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Typed errors so hosts can route failures without string-matching messages.
|
|
180
|
+
* Connectors throw these ONLY inside their own boundary; parseWebhook /
|
|
181
|
+
* parseSettlementFile catch them and return `parse_error` results instead
|
|
182
|
+
* (see connector/result.ts) — a malformed payload from one provider must never
|
|
183
|
+
* crash a host's webhook endpoint (that would turn one bad payload into an
|
|
184
|
+
* availability incident: the provider retries, you 500, backoff kicks in,
|
|
185
|
+
* and every later event for that endpoint queues behind the poison message).
|
|
186
|
+
*/
|
|
187
|
+
/**
|
|
188
|
+
* Machine-readable error codes — stable API, unlike messages, which may be
|
|
189
|
+
* reworded in a PATCH release. Route on `error.code` (or instanceof), never
|
|
190
|
+
* on `error.message`.
|
|
191
|
+
*/
|
|
192
|
+
type NormalizationErrorCode = "ERR_SIGNATURE_VERIFICATION" | "ERR_MALFORMED_PAYLOAD" | "ERR_AMOUNT_PARSE" | "ERR_UNSUPPORTED_FILE_FORMAT" | "ERR_TIMESTAMP_MISSING";
|
|
193
|
+
declare class NormalizationError extends Error {
|
|
194
|
+
readonly code: NormalizationErrorCode;
|
|
195
|
+
constructor(message: string, code: NormalizationErrorCode);
|
|
196
|
+
}
|
|
197
|
+
/** Signature header missing, malformed, or HMAC mismatch. Host should respond 401 and NOT process. */
|
|
198
|
+
declare class SignatureVerificationError extends NormalizationError {
|
|
199
|
+
readonly provider?: string | undefined;
|
|
200
|
+
constructor(message: string, provider?: string | undefined);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Payload structurally unusable: not JSON, missing required fields, wrong
|
|
204
|
+
* shapes. `code` may be narrowed (e.g. ERR_TIMESTAMP_MISSING) when the defect
|
|
205
|
+
* is specific enough for a host — or a caller — to act on programmatically.
|
|
206
|
+
*/
|
|
207
|
+
declare class MalformedPayloadError extends NormalizationError {
|
|
208
|
+
readonly raw?: unknown | undefined;
|
|
209
|
+
constructor(message: string, raw?: unknown | undefined, code?: NormalizationErrorCode);
|
|
210
|
+
}
|
|
211
|
+
/** Amount could not be converted to Kobo safely. Carries the offending input for logs. */
|
|
212
|
+
declare class AmountParseError extends NormalizationError {
|
|
213
|
+
readonly input?: unknown | undefined;
|
|
214
|
+
constructor(message: string, input?: unknown | undefined);
|
|
215
|
+
}
|
|
216
|
+
/** Settlement/statement file is not a format this connector version supports. */
|
|
217
|
+
declare class UnsupportedFileFormatError extends NormalizationError {
|
|
218
|
+
readonly provider?: string | undefined;
|
|
219
|
+
constructor(message: string, provider?: string | undefined);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Every parse returns one of these — connectors NEVER throw on weird input and
|
|
224
|
+
* NEVER return null. The type system enforces NOT_DOING.md §9:
|
|
225
|
+
* "no swallowing unknown events."
|
|
226
|
+
*
|
|
227
|
+
* Host handling guidance:
|
|
228
|
+
* - 'transaction' -> idempotent upsert (unique dedupeKey + STATUS_RANK guard), ACK 200.
|
|
229
|
+
* - 'unknown_event' -> log + store raw, ACK 200. Unknown ≠ error: providers add
|
|
230
|
+
* event types without notice; NACKing them makes the provider
|
|
231
|
+
* retry forever (their backoff, your log spam).
|
|
232
|
+
* - 'parse_error' -> the poison-message case. ACK 200 (do NOT make the provider
|
|
233
|
+
* retry a payload that will fail identically), park the raw
|
|
234
|
+
* payload in your own dead-letter store, alert. This library
|
|
235
|
+
* ships no queue (NOT_DOING.md §6) — the DLQ is yours.
|
|
236
|
+
*/
|
|
237
|
+
type ParseResult = {
|
|
238
|
+
kind: "transaction";
|
|
239
|
+
transaction: StandardizedTransaction;
|
|
240
|
+
} | {
|
|
241
|
+
kind: "unknown_event";
|
|
242
|
+
provider: string;
|
|
243
|
+
/** Provider's own event-type string when discoverable, else "unrecognized". */
|
|
244
|
+
eventType: string;
|
|
245
|
+
raw: unknown;
|
|
246
|
+
} | {
|
|
247
|
+
kind: "parse_error";
|
|
248
|
+
provider: string;
|
|
249
|
+
error: MalformedPayloadError | AmountParseError;
|
|
250
|
+
raw: unknown;
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* Result of parsing a settlement/statement file. Row-level isolation: one
|
|
254
|
+
* mangled row (bank exports love those) yields one parse_error result — the
|
|
255
|
+
* other 4,999 rows still normalize. All-or-nothing file parsing punishes the
|
|
256
|
+
* host for the provider's data quality.
|
|
257
|
+
*/
|
|
258
|
+
interface SettlementFileParseResult {
|
|
259
|
+
provider: string;
|
|
260
|
+
/** e.g. 'nomba-settlement-csv-v1' — which fixture-tested format matched. */
|
|
261
|
+
format: string;
|
|
262
|
+
rows: ParseResult[];
|
|
263
|
+
/** Row counts by outcome, so hosts can alert on anomaly ratios cheaply. */
|
|
264
|
+
summary: {
|
|
265
|
+
transactions: number;
|
|
266
|
+
unknown: number;
|
|
267
|
+
errors: number;
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
declare function summarizeRows(rows: ParseResult[]): SettlementFileParseResult["summary"];
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Connector — the constitution. One implementation per provider, in its own
|
|
274
|
+
* package (@scope/nomba, @scope/paystack, ...), each independently semver'd:
|
|
275
|
+
* a Paystack format change is a paystack@2.0.0, and nobody's Nomba code moves.
|
|
276
|
+
*
|
|
277
|
+
* Design constraints (enforced by NOT_DOING.md, restated here because this is
|
|
278
|
+
* the file contributors actually read):
|
|
279
|
+
* - PURE. No HTTP calls, no polling, no provider SDKs, no env vars, no clocks
|
|
280
|
+
* (timestamps come from payloads). Same input, same output, forever.
|
|
281
|
+
* - STATELESS. Dedupe enforcement, status transitions, retries, backpressure,
|
|
282
|
+
* and queueing are the host's (or the future hosted product's) concern.
|
|
283
|
+
* - SECRETS ARE ARGUMENTS. Passed in per call, never read, stored, or logged.
|
|
284
|
+
*/
|
|
285
|
+
interface Connector {
|
|
286
|
+
/** Lowercase identifier matching StandardizedTransaction.provider: 'nomba', 'paystack'... */
|
|
287
|
+
readonly provider: string;
|
|
288
|
+
/** Semver of this connector implementation, surfaced for host diagnostics/logging. */
|
|
289
|
+
readonly version: string;
|
|
290
|
+
/**
|
|
291
|
+
* Verify the webhook came from the provider. MUST compute over the raw bytes
|
|
292
|
+
* (RawBody brand exists precisely because express.json() destroys them) and
|
|
293
|
+
* MUST use a constant-time comparison (crypto.timingSafeEqual) — string `===`
|
|
294
|
+
* on HMACs leaks timing.
|
|
295
|
+
*
|
|
296
|
+
* Returns boolean rather than throwing: "unverified" is a routine hostile-
|
|
297
|
+
* internet event (host responds 401), not an exceptional program state.
|
|
298
|
+
*/
|
|
299
|
+
verifyWebhookSignature(input: {
|
|
300
|
+
/** Node-style header map; connectors do case-insensitive lookups internally. */
|
|
301
|
+
headers: Record<string, string | string[] | undefined>;
|
|
302
|
+
rawBody: RawBody;
|
|
303
|
+
/** The provider webhook secret. An argument, never an env read. */
|
|
304
|
+
secret: string;
|
|
305
|
+
}): boolean;
|
|
306
|
+
/**
|
|
307
|
+
* Normalize one webhook delivery. Total function over arbitrary bytes:
|
|
308
|
+
* malformed input returns { kind: 'parse_error' }, never throws.
|
|
309
|
+
* Call ONLY after verifyWebhookSignature returned true — parsing unverified
|
|
310
|
+
* payloads is processing attacker-controlled input by choice.
|
|
311
|
+
*/
|
|
312
|
+
parseWebhook(rawBody: RawBody): ParseResult;
|
|
313
|
+
/**
|
|
314
|
+
* Normalize a settlement/statement export (CSV/XLSX bytes). Row-isolated:
|
|
315
|
+
* bad rows become parse_error results, good rows still flow. Throws
|
|
316
|
+
* UnsupportedFileFormatError only when the FILE ITSELF is not a format this
|
|
317
|
+
* connector version knows (wrong provider's file, new column layout) —
|
|
318
|
+
* that is a configuration/version problem, not a data problem.
|
|
319
|
+
*/
|
|
320
|
+
parseSettlementFile(file: Buffer): SettlementFileParseResult;
|
|
321
|
+
/**
|
|
322
|
+
* Identity for idempotent ingestion. Default composition is
|
|
323
|
+
* `${provider}:${providerReference}` via composeDedupeKey; connectors
|
|
324
|
+
* override when a provider's reference is not 1:1 with a transaction —
|
|
325
|
+
* a quirk that must be documented and fixture-proven in the connector.
|
|
326
|
+
*/
|
|
327
|
+
dedupeKey(txn: StandardizedTransaction): string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Dedupe keys — idempotency for a world where providers retry webhooks.
|
|
332
|
+
*
|
|
333
|
+
* Webhook delivery is at-least-once everywhere: providers retry on timeout with
|
|
334
|
+
* exponential backoff, and some (OPay callbacks, observed) simply double-fire.
|
|
335
|
+
* The ONLY reliable defense is idempotent ingestion:
|
|
336
|
+
*
|
|
337
|
+
* CREATE UNIQUE INDEX ux_txn_dedupe ON transactions (dedupe_key);
|
|
338
|
+
*
|
|
339
|
+
* then INSERT ... ON CONFLICT DO NOTHING (or the equivalent upsert guarded by
|
|
340
|
+
* shouldApplyStatusTransition for status updates). Enforcing uniqueness in
|
|
341
|
+
* application code alone is a race condition — two workers processing the same
|
|
342
|
+
* redelivered webhook concurrently will both pass an "does it exist?" check.
|
|
343
|
+
* The database constraint is the lock.
|
|
344
|
+
*
|
|
345
|
+
* Key shape: `${provider}:${providerReference}` — namespaced so Paystack ref
|
|
346
|
+
* "TX123" and Nomba ref "TX123" never collide. Connectors may override
|
|
347
|
+
* dedupeKey() when a provider's reference is not unique per transaction
|
|
348
|
+
* (e.g. one reference spanning multiple settlement rows) — that quirk knowledge
|
|
349
|
+
* lives in the connector, backed by fixtures.
|
|
350
|
+
*/
|
|
351
|
+
declare function composeDedupeKey(provider: string, providerReference: string): string;
|
|
352
|
+
|
|
353
|
+
export { AmountParseError, type Connector, type Kobo, MalformedPayloadError, NormalizationError, type NormalizationErrorCode, type ParseResult, type PaymentChannel, PaymentChannelSchema, type RawBody, STATUS_RANK, type SettlementFileParseResult, SignatureVerificationError, type StandardizedTransaction, StandardizedTransactionSchema, type TransactionStatus, TransactionStatusSchema, UnsupportedFileFormatError, asKobo, composeDedupeKey, koboToNairaString, parseKoboInteger, parseNairaDecimalString, rawBodyFromString, shouldApplyStatusTransition, statusRank, summarizeRows, toRawBody, validateTransaction };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// src/schema/transaction.ts
|
|
2
|
+
import { z as z3 } from "zod";
|
|
3
|
+
|
|
4
|
+
// src/schema/status.ts
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
var TransactionStatusSchema = z.enum([
|
|
7
|
+
"PENDING",
|
|
8
|
+
"FAILED",
|
|
9
|
+
"SUCCESSFUL",
|
|
10
|
+
"REVERSED"
|
|
11
|
+
]);
|
|
12
|
+
var STATUS_RANK = Object.freeze({
|
|
13
|
+
PENDING: 0,
|
|
14
|
+
FAILED: 1,
|
|
15
|
+
SUCCESSFUL: 2,
|
|
16
|
+
REVERSED: 3
|
|
17
|
+
});
|
|
18
|
+
function statusRank(status) {
|
|
19
|
+
return STATUS_RANK[status];
|
|
20
|
+
}
|
|
21
|
+
function shouldApplyStatusTransition(current, incoming) {
|
|
22
|
+
return STATUS_RANK[incoming] > STATUS_RANK[current];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/schema/channel.ts
|
|
26
|
+
import { z as z2 } from "zod";
|
|
27
|
+
var PaymentChannelSchema = z2.enum([
|
|
28
|
+
"card",
|
|
29
|
+
"bank_transfer",
|
|
30
|
+
"ussd",
|
|
31
|
+
"qr",
|
|
32
|
+
"wallet",
|
|
33
|
+
"pos",
|
|
34
|
+
"unknown"
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// src/schema/transaction.ts
|
|
38
|
+
var KoboSchema = z3.number().int("money must be integer kobo \u2014 parse at the connector boundary, never float").nonnegative();
|
|
39
|
+
var StandardizedTransactionSchema = z3.object({
|
|
40
|
+
/** Deterministic identity for idempotency: `${provider}:${providerReference}` (see util/dedupe). */
|
|
41
|
+
dedupeKey: z3.string().min(3),
|
|
42
|
+
/** Connector identifier, lowercase: 'nomba' | 'paystack' | 'monnify' | 'opay' | 'palmpay' | 'flutterwave' | ... open set by design — new providers must not require a core release. */
|
|
43
|
+
provider: z3.string().min(1).regex(/^[a-z0-9_-]+$/),
|
|
44
|
+
/** The provider's own transaction reference (txref / transactionReference / orderNo...). */
|
|
45
|
+
providerReference: z3.string().min(1),
|
|
46
|
+
/** Provider's per-DELIVERY event id when one exists — distinguishes redelivery of one event from two events about one transaction. */
|
|
47
|
+
providerEventId: z3.string().min(1).optional(),
|
|
48
|
+
/** Money — integer kobo only. Branded at the type level. */
|
|
49
|
+
amountInKobo: KoboSchema,
|
|
50
|
+
feeInKobo: KoboSchema,
|
|
51
|
+
/** MUST equal amountInKobo - feeInKobo; enforced below so no connector can drift. */
|
|
52
|
+
netAmountInKobo: KoboSchema,
|
|
53
|
+
/** ISO-4217 uppercase. Non-NGN is tagged and passed through untouched — no FX, no conversion (NOT_DOING.md §10). */
|
|
54
|
+
currency: z3.string().regex(/^[A-Z]{3}$/),
|
|
55
|
+
status: TransactionStatusSchema,
|
|
56
|
+
channel: PaymentChannelSchema,
|
|
57
|
+
/** credit = money in (collections, VA funding); debit = money out rows encountered when parsing bank statements. Parsing debits ≠ initiating them. */
|
|
58
|
+
direction: z3.enum(["credit", "debit"]),
|
|
59
|
+
/**
|
|
60
|
+
* When the provider says it happened. CLOCK SKEW WARNING: provider clocks
|
|
61
|
+
* are not your clock, and several send unlabeled WAT strings. Connectors
|
|
62
|
+
* apply an explicit, fixture-tested timezone rule per provider. Hosts must
|
|
63
|
+
* NEVER use this for ordering decisions — that is what STATUS_RANK is for.
|
|
64
|
+
*/
|
|
65
|
+
occurredAt: z3.date(),
|
|
66
|
+
/** The provider's original timestamp string, preserved verbatim for audit/debugging. */
|
|
67
|
+
occurredAtRaw: z3.string().optional(),
|
|
68
|
+
/** Settlement date when the payload/file carries one; null when unknown. Crucial for recon + accounting. */
|
|
69
|
+
settlementDate: z3.date().nullable(),
|
|
70
|
+
/**
|
|
71
|
+
* The untouched provider payload (or file row). The escape hatch that makes
|
|
72
|
+
* the schema non-lossy (NOT_DOING.md §9). Contains PII — hosts own
|
|
73
|
+
* encryption-at-rest and retention policy for whatever column stores this.
|
|
74
|
+
*/
|
|
75
|
+
rawProviderPayload: z3.unknown()
|
|
76
|
+
}).strict().superRefine((t, ctx) => {
|
|
77
|
+
if (t.netAmountInKobo !== t.amountInKobo - t.feeInKobo) {
|
|
78
|
+
ctx.addIssue({
|
|
79
|
+
code: z3.ZodIssueCode.custom,
|
|
80
|
+
path: ["netAmountInKobo"],
|
|
81
|
+
message: `netAmountInKobo (${t.netAmountInKobo}) must equal amountInKobo (${t.amountInKobo}) - feeInKobo (${t.feeInKobo})`
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
function validateTransaction(input) {
|
|
86
|
+
return StandardizedTransactionSchema.parse(input);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/errors.ts
|
|
90
|
+
var NormalizationError = class extends Error {
|
|
91
|
+
constructor(message, code) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.code = code;
|
|
94
|
+
this.name = new.target.name;
|
|
95
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
96
|
+
}
|
|
97
|
+
code;
|
|
98
|
+
};
|
|
99
|
+
var SignatureVerificationError = class extends NormalizationError {
|
|
100
|
+
constructor(message, provider) {
|
|
101
|
+
super(message, "ERR_SIGNATURE_VERIFICATION");
|
|
102
|
+
this.provider = provider;
|
|
103
|
+
}
|
|
104
|
+
provider;
|
|
105
|
+
};
|
|
106
|
+
var MalformedPayloadError = class extends NormalizationError {
|
|
107
|
+
constructor(message, raw, code = "ERR_MALFORMED_PAYLOAD") {
|
|
108
|
+
super(message, code);
|
|
109
|
+
this.raw = raw;
|
|
110
|
+
}
|
|
111
|
+
raw;
|
|
112
|
+
};
|
|
113
|
+
var AmountParseError = class extends NormalizationError {
|
|
114
|
+
constructor(message, input) {
|
|
115
|
+
super(message, "ERR_AMOUNT_PARSE");
|
|
116
|
+
this.input = input;
|
|
117
|
+
}
|
|
118
|
+
input;
|
|
119
|
+
};
|
|
120
|
+
var UnsupportedFileFormatError = class extends NormalizationError {
|
|
121
|
+
constructor(message, provider) {
|
|
122
|
+
super(message, "ERR_UNSUPPORTED_FILE_FORMAT");
|
|
123
|
+
this.provider = provider;
|
|
124
|
+
}
|
|
125
|
+
provider;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// src/schema/money.ts
|
|
129
|
+
var MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
130
|
+
var MAX_AMOUNT_LEN = 40;
|
|
131
|
+
function asKobo(value) {
|
|
132
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
133
|
+
throw new AmountParseError(
|
|
134
|
+
`Expected a non-negative safe integer kobo amount, got: ${String(value)}`,
|
|
135
|
+
value
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
function parseKoboInteger(input) {
|
|
141
|
+
if (typeof input === "number") return asKobo(input);
|
|
142
|
+
const trimmed = input.trim();
|
|
143
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
144
|
+
throw new AmountParseError(
|
|
145
|
+
`Expected integer kobo string, got: "${input}". If this is a naira decimal, use parseNairaDecimalString().`,
|
|
146
|
+
input
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const big = BigInt(trimmed);
|
|
150
|
+
if (big > MAX_SAFE) {
|
|
151
|
+
throw new AmountParseError(`Kobo amount exceeds MAX_SAFE_INTEGER: "${input}"`, input);
|
|
152
|
+
}
|
|
153
|
+
return Number(big);
|
|
154
|
+
}
|
|
155
|
+
function parseNairaDecimalString(input) {
|
|
156
|
+
const s = typeof input === "number" ? numberToPlainString(input) : input;
|
|
157
|
+
if (s.length > MAX_AMOUNT_LEN) {
|
|
158
|
+
throw new AmountParseError(
|
|
159
|
+
`Naira amount string too long (${s.length} chars); no real amount exceeds ${MAX_AMOUNT_LEN}`,
|
|
160
|
+
input
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const t = s.trim();
|
|
164
|
+
const m = /^(?:₦|NGN)?\s*(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d{1,2}))?$/.exec(t);
|
|
165
|
+
const wholeGroup = m?.[1];
|
|
166
|
+
if (!m || wholeGroup === void 0) {
|
|
167
|
+
throw new AmountParseError(
|
|
168
|
+
`Cannot parse naira amount: "${String(input)}"`,
|
|
169
|
+
input
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const whole = BigInt(wholeGroup.replace(/,/g, ""));
|
|
173
|
+
const fracRaw = m[2] ?? "";
|
|
174
|
+
const frac = BigInt(fracRaw.padEnd(2, "0") || "0");
|
|
175
|
+
const kobo = whole * 100n + frac;
|
|
176
|
+
if (kobo > MAX_SAFE) {
|
|
177
|
+
throw new AmountParseError(`Amount exceeds MAX_SAFE_INTEGER: "${String(input)}"`, input);
|
|
178
|
+
}
|
|
179
|
+
return Number(kobo);
|
|
180
|
+
}
|
|
181
|
+
function koboToNairaString(kobo) {
|
|
182
|
+
const whole = Math.trunc(kobo / 100);
|
|
183
|
+
const frac = kobo % 100;
|
|
184
|
+
return `${whole.toLocaleString("en-NG")}.${String(frac).padStart(2, "0")}`;
|
|
185
|
+
}
|
|
186
|
+
function numberToPlainString(n) {
|
|
187
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
188
|
+
throw new AmountParseError(`Cannot parse naira amount: ${String(n)}`, n);
|
|
189
|
+
}
|
|
190
|
+
if (Number.isInteger(n)) return String(n);
|
|
191
|
+
const fixed = n.toFixed(2);
|
|
192
|
+
if (Math.abs(Number(fixed) - n) > Number.EPSILON * Math.max(1, Math.abs(n))) {
|
|
193
|
+
throw new AmountParseError(
|
|
194
|
+
`Naira amount has more than 2 decimal places: ${String(n)}`,
|
|
195
|
+
n
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return fixed;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/connector/result.ts
|
|
202
|
+
function summarizeRows(rows) {
|
|
203
|
+
const summary = { transactions: 0, unknown: 0, errors: 0 };
|
|
204
|
+
for (const r of rows) {
|
|
205
|
+
if (r.kind === "transaction") summary.transactions++;
|
|
206
|
+
else if (r.kind === "unknown_event") summary.unknown++;
|
|
207
|
+
else summary.errors++;
|
|
208
|
+
}
|
|
209
|
+
return summary;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/util/raw-body.ts
|
|
213
|
+
function toRawBody(buf) {
|
|
214
|
+
if (!Buffer.isBuffer(buf)) {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
"toRawBody expects a Buffer of the exact request bytes. If you only have a parsed object, your middleware consumed the raw body \u2014 use express.raw() (or your framework's equivalent) on webhook routes."
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return buf;
|
|
220
|
+
}
|
|
221
|
+
function rawBodyFromString(s, encoding = "utf8") {
|
|
222
|
+
return Buffer.from(s, encoding);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/util/dedupe.ts
|
|
226
|
+
function composeDedupeKey(provider, providerReference) {
|
|
227
|
+
const p = provider.trim().toLowerCase();
|
|
228
|
+
const r = providerReference.trim();
|
|
229
|
+
if (!p || !r) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`composeDedupeKey requires non-empty provider and reference, got provider="${provider}" reference="${providerReference}"`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return `${p}:${r}`;
|
|
235
|
+
}
|
|
236
|
+
export {
|
|
237
|
+
AmountParseError,
|
|
238
|
+
MalformedPayloadError,
|
|
239
|
+
NormalizationError,
|
|
240
|
+
PaymentChannelSchema,
|
|
241
|
+
STATUS_RANK,
|
|
242
|
+
SignatureVerificationError,
|
|
243
|
+
StandardizedTransactionSchema,
|
|
244
|
+
TransactionStatusSchema,
|
|
245
|
+
UnsupportedFileFormatError,
|
|
246
|
+
asKobo,
|
|
247
|
+
composeDedupeKey,
|
|
248
|
+
koboToNairaString,
|
|
249
|
+
parseKoboInteger,
|
|
250
|
+
parseNairaDecimalString,
|
|
251
|
+
rawBodyFromString,
|
|
252
|
+
shouldApplyStatusTransition,
|
|
253
|
+
statusRank,
|
|
254
|
+
summarizeRows,
|
|
255
|
+
toRawBody,
|
|
256
|
+
validateTransaction
|
|
257
|
+
};
|
|
258
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/schema/transaction.ts","../src/schema/status.ts","../src/schema/channel.ts","../src/errors.ts","../src/schema/money.ts","../src/connector/result.ts","../src/util/raw-body.ts","../src/util/dedupe.ts"],"sourcesContent":["import { z } from \"zod\";\r\nimport { TransactionStatusSchema } from \"./status\";\r\nimport { PaymentChannelSchema } from \"./channel\";\r\nimport type { Kobo } from \"./money\";\r\n\r\n/**\r\n * StandardizedTransaction v1 — the single output shape of every connector.\r\n *\r\n * SCHEMA VERSIONING CONTRACT (NOT_DOING.md §9): v1 fields are frozen.\r\n * Additions ship as OPTIONAL fields in minor versions; renames/removals are v2\r\n * and a new major of every package (semver is the API contract here — hosts\r\n * pin `@scope/core@^1` and must never be broken by a minor).\r\n */\r\n\r\nconst KoboSchema = z\r\n .number()\r\n .int(\"money must be integer kobo — parse at the connector boundary, never float\")\r\n .nonnegative();\r\n\r\nexport const StandardizedTransactionSchema = z\r\n .object({\r\n /** Deterministic identity for idempotency: `${provider}:${providerReference}` (see util/dedupe). */\r\n dedupeKey: z.string().min(3),\r\n\r\n /** Connector identifier, lowercase: 'nomba' | 'paystack' | 'monnify' | 'opay' | 'palmpay' | 'flutterwave' | ... open set by design — new providers must not require a core release. */\r\n provider: z.string().min(1).regex(/^[a-z0-9_-]+$/),\r\n\r\n /** The provider's own transaction reference (txref / transactionReference / orderNo...). */\r\n providerReference: z.string().min(1),\r\n\r\n /** Provider's per-DELIVERY event id when one exists — distinguishes redelivery of one event from two events about one transaction. */\r\n providerEventId: z.string().min(1).optional(),\r\n\r\n /** Money — integer kobo only. Branded at the type level. */\r\n amountInKobo: KoboSchema,\r\n feeInKobo: KoboSchema,\r\n /** MUST equal amountInKobo - feeInKobo; enforced below so no connector can drift. */\r\n netAmountInKobo: KoboSchema,\r\n\r\n /** ISO-4217 uppercase. Non-NGN is tagged and passed through untouched — no FX, no conversion (NOT_DOING.md §10). */\r\n currency: z.string().regex(/^[A-Z]{3}$/),\r\n\r\n status: TransactionStatusSchema,\r\n channel: PaymentChannelSchema,\r\n\r\n /** credit = money in (collections, VA funding); debit = money out rows encountered when parsing bank statements. Parsing debits ≠ initiating them. */\r\n direction: z.enum([\"credit\", \"debit\"]),\r\n\r\n /**\r\n * When the provider says it happened. CLOCK SKEW WARNING: provider clocks\r\n * are not your clock, and several send unlabeled WAT strings. Connectors\r\n * apply an explicit, fixture-tested timezone rule per provider. Hosts must\r\n * NEVER use this for ordering decisions — that is what STATUS_RANK is for.\r\n */\r\n occurredAt: z.date(),\r\n /** The provider's original timestamp string, preserved verbatim for audit/debugging. */\r\n occurredAtRaw: z.string().optional(),\r\n\r\n /** Settlement date when the payload/file carries one; null when unknown. Crucial for recon + accounting. */\r\n settlementDate: z.date().nullable(),\r\n\r\n /**\r\n * The untouched provider payload (or file row). The escape hatch that makes\r\n * the schema non-lossy (NOT_DOING.md §9). Contains PII — hosts own\r\n * encryption-at-rest and retention policy for whatever column stores this.\r\n */\r\n rawProviderPayload: z.unknown(),\r\n })\r\n .strict()\r\n .superRefine((t, ctx) => {\r\n if (t.netAmountInKobo !== t.amountInKobo - t.feeInKobo) {\r\n ctx.addIssue({\r\n code: z.ZodIssueCode.custom,\r\n path: [\"netAmountInKobo\"],\r\n message: `netAmountInKobo (${t.netAmountInKobo}) must equal amountInKobo (${t.amountInKobo}) - feeInKobo (${t.feeInKobo})`,\r\n });\r\n }\r\n });\r\n\r\nexport type StandardizedTransaction = Omit<\r\n z.infer<typeof StandardizedTransactionSchema>,\r\n \"amountInKobo\" | \"feeInKobo\" | \"netAmountInKobo\"\r\n> & {\r\n amountInKobo: Kobo;\r\n feeInKobo: Kobo;\r\n netAmountInKobo: Kobo;\r\n};\r\n\r\n/** Runtime gate every connector output passes through before leaving the library. */\r\nexport function validateTransaction(input: unknown): StandardizedTransaction {\r\n return StandardizedTransactionSchema.parse(input) as StandardizedTransaction;\r\n}\r\n","import { z } from \"zod\";\r\n\r\n/**\r\n * The four terminal-ish states every provider vocabulary maps down to.\r\n * Connectors own the mapping table (e.g. OPay \"SUCCESS\", Paystack \"success\",\r\n * Monnify \"PAID\" -> SUCCESSFUL) — explicitly, per NOT_DOING.md §9:\r\n * no status inference from amount presence or absence of error fields.\r\n */\r\nexport const TransactionStatusSchema = z.enum([\r\n \"PENDING\",\r\n \"FAILED\",\r\n \"SUCCESSFUL\",\r\n \"REVERSED\",\r\n]);\r\nexport type TransactionStatus = z.infer<typeof TransactionStatusSchema>;\r\n\r\n/**\r\n * STATUS_RANK — the ordering that makes out-of-order webhook delivery safe.\r\n *\r\n * Why this exists (distributed-systems reality, not theory):\r\n * - Providers deliver webhooks with retries + exponential backoff. Retries mean\r\n * DUPLICATES; parallel delivery paths + network partitions mean REORDERING.\r\n * A \"pending\" emitted at T0 can arrive AFTER the \"successful\" emitted at T1.\r\n * - You cannot fix this with timestamps: provider clocks skew, and several\r\n * providers send unlabeled local-time (WAT) strings. Ordering by rank is\r\n * deterministic; ordering by clock is a race condition wearing a watch.\r\n *\r\n * REVERSED outranks SUCCESSFUL deliberately: success->reversal is a legitimate\r\n * forward progression (refunds, chargebacks). PENDING can never overwrite anything.\r\n * FAILED -> SUCCESSFUL is allowed (rank 1 -> 2): some providers emit a failure\r\n * then succeed on an internal retry under the same reference.\r\n */\r\nexport const STATUS_RANK: Readonly<Record<TransactionStatus, number>> = Object.freeze({\r\n PENDING: 0,\r\n FAILED: 1,\r\n SUCCESSFUL: 2,\r\n REVERSED: 3,\r\n});\r\n\r\nexport function statusRank(status: TransactionStatus): number {\r\n return STATUS_RANK[status];\r\n}\r\n\r\n/**\r\n * The one-line correctness rule for host applications:\r\n *\r\n * if (!shouldApplyStatusTransition(stored.status, incoming.status)) skip;\r\n *\r\n * Equal rank returns false — re-delivery of the same status is a no-op\r\n * (idempotency). Host must pair this with:\r\n * 1. a UNIQUE index on dedupeKey (hard idempotency at the storage layer), and\r\n * 2. optimistic locking (version column / compare-and-swap) or\r\n * SELECT ... FOR UPDATE when applying the transition, because two webhook\r\n * deliveries for the same transaction WILL race on concurrent workers.\r\n * This library is stateless by design (NOT_DOING.md §6) — it hands you the\r\n * rule; your database enforces it.\r\n */\r\nexport function shouldApplyStatusTransition(\r\n current: TransactionStatus,\r\n incoming: TransactionStatus\r\n): boolean {\r\n return STATUS_RANK[incoming] > STATUS_RANK[current];\r\n}\r\n","import { z } from \"zod\";\r\n\r\n/**\r\n * How the money moved. 'unknown' is a legitimate value — bank statement rows\r\n * frequently do not disclose the channel, and guessing violates NOT_DOING.md §9.\r\n * 'pos' covers agent/terminal collections (OPay, PalmPay, Moniepoint territory).\r\n */\r\nexport const PaymentChannelSchema = z.enum([\r\n \"card\",\r\n \"bank_transfer\",\r\n \"ussd\",\r\n \"qr\",\r\n \"wallet\",\r\n \"pos\",\r\n \"unknown\",\r\n]);\r\nexport type PaymentChannel = z.infer<typeof PaymentChannelSchema>;\r\n","/**\n * Typed errors so hosts can route failures without string-matching messages.\n * Connectors throw these ONLY inside their own boundary; parseWebhook /\n * parseSettlementFile catch them and return `parse_error` results instead\n * (see connector/result.ts) — a malformed payload from one provider must never\n * crash a host's webhook endpoint (that would turn one bad payload into an\n * availability incident: the provider retries, you 500, backoff kicks in,\n * and every later event for that endpoint queues behind the poison message).\n */\n\n/**\n * Machine-readable error codes — stable API, unlike messages, which may be\n * reworded in a PATCH release. Route on `error.code` (or instanceof), never\n * on `error.message`.\n */\nexport type NormalizationErrorCode =\n | \"ERR_SIGNATURE_VERIFICATION\"\n | \"ERR_MALFORMED_PAYLOAD\"\n | \"ERR_AMOUNT_PARSE\"\n | \"ERR_UNSUPPORTED_FILE_FORMAT\"\n | \"ERR_TIMESTAMP_MISSING\";\n\nexport class NormalizationError extends Error {\n constructor(message: string, public readonly code: NormalizationErrorCode) {\n super(message);\n this.name = new.target.name;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n\n/** Signature header missing, malformed, or HMAC mismatch. Host should respond 401 and NOT process. */\nexport class SignatureVerificationError extends NormalizationError {\n constructor(message: string, public readonly provider?: string) {\n super(message, \"ERR_SIGNATURE_VERIFICATION\");\n }\n}\n\n/**\n * Payload structurally unusable: not JSON, missing required fields, wrong\n * shapes. `code` may be narrowed (e.g. ERR_TIMESTAMP_MISSING) when the defect\n * is specific enough for a host — or a caller — to act on programmatically.\n */\nexport class MalformedPayloadError extends NormalizationError {\n constructor(\n message: string,\n public readonly raw?: unknown,\n code: NormalizationErrorCode = \"ERR_MALFORMED_PAYLOAD\"\n ) {\n super(message, code);\n }\n}\n\n/** Amount could not be converted to Kobo safely. Carries the offending input for logs. */\nexport class AmountParseError extends NormalizationError {\n constructor(message: string, public readonly input?: unknown) {\n super(message, \"ERR_AMOUNT_PARSE\");\n }\n}\n\n/** Settlement/statement file is not a format this connector version supports. */\nexport class UnsupportedFileFormatError extends NormalizationError {\n constructor(message: string, public readonly provider?: string) {\n super(message, \"ERR_UNSUPPORTED_FILE_FORMAT\");\n }\n}\n","import { AmountParseError } from \"../errors\";\r\n\r\n/**\r\n * Kobo — the ONLY representation of money inside this library.\r\n *\r\n * Branded type: a plain `number` will not typecheck where `Kobo` is required,\r\n * so unconverted provider amounts cannot leak past the connector boundary.\r\n *\r\n * Rules (NOT_DOING.md §9):\r\n * - Integers only. Floats are banned; all parsing below uses string/BigInt math,\r\n * so `\"5000.10\"` can never become `500009.999...` via IEEE-754.\r\n * - Conversion happens exactly ONCE, at the connector boundary, via the\r\n * parsers in this file. Core and hosts only ever see Kobo.\r\n */\r\nexport type Kobo = number & { readonly __brand: \"Kobo\" };\r\n\r\nconst MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);\r\n\r\n/**\r\n * Hard length ceiling for amount strings. The largest representable value —\r\n * MAX_SAFE_INTEGER kobo (~90 trillion naira) with thousands separators, two\r\n * decimals, and a currency prefix — is ~24 chars. 40 leaves generous slack\r\n * while making any hostile megabyte-long string an O(1) rejection BEFORE it\r\n * reaches the regex below (defense against ReDoS; see parseNairaDecimalString).\r\n */\r\nconst MAX_AMOUNT_LEN = 40;\r\n\r\n/** Assert an already-integer amount is valid Kobo (e.g. Paystack's `amount` field). */\r\nexport function asKobo(value: number): Kobo {\r\n if (!Number.isSafeInteger(value) || value < 0) {\r\n throw new AmountParseError(\r\n `Expected a non-negative safe integer kobo amount, got: ${String(value)}`,\r\n value\r\n );\r\n }\r\n return value as Kobo;\r\n}\r\n\r\n/**\r\n * Parse a kobo amount that arrives as an integer-looking value.\r\n * Accepts `500000` or `\"500000\"`. Rejects decimals — if a provider sends\r\n * decimals, it is a naira amount and MUST go through parseNairaDecimalString.\r\n */\r\nexport function parseKoboInteger(input: number | string): Kobo {\r\n if (typeof input === \"number\") return asKobo(input);\r\n const trimmed = input.trim();\r\n if (!/^\\d+$/.test(trimmed)) {\r\n throw new AmountParseError(\r\n `Expected integer kobo string, got: \"${input}\". ` +\r\n `If this is a naira decimal, use parseNairaDecimalString().`,\r\n input\r\n );\r\n }\r\n const big = BigInt(trimmed);\r\n if (big > MAX_SAFE) {\r\n throw new AmountParseError(`Kobo amount exceeds MAX_SAFE_INTEGER: \"${input}\"`, input);\r\n }\r\n return Number(big) as Kobo;\r\n}\r\n\r\n/**\r\n * Parse naira expressed as a decimal string or number into Kobo, using\r\n * string math only (never parseFloat).\r\n *\r\n * Accepts the shapes actually seen across Nigerian providers:\r\n * \"5000\" -> 500000\r\n * \"5000.00\" -> 500000\r\n * \"5000.5\" -> 500050 (one decimal place = tens of kobo)\r\n * \"5,000.00\" -> 500000 (thousands separators)\r\n * 5000 -> 500000 (numeric naira, e.g. Monnify amounts)\r\n * \"₦5,000.00\" -> 500000\r\n *\r\n * Rejects: negatives, >2 decimal places (no such thing as half a kobo),\r\n * empty strings, scientific notation, over-long input, and anything ambiguous.\r\n */\r\nexport function parseNairaDecimalString(input: string | number): Kobo {\r\n const s = typeof input === \"number\" ? numberToPlainString(input) : input;\r\n if (s.length > MAX_AMOUNT_LEN) {\r\n throw new AmountParseError(\r\n `Naira amount string too long (${s.length} chars); no real amount exceeds ${MAX_AMOUNT_LEN}`,\r\n input\r\n );\r\n }\r\n // ReDoS-safe matching: trim once so the pattern carries a SINGLE internal\r\n // \\s* run. The prior `^\\s*…\\s*$` form let leading and trailing whitespace\r\n // runs trade off against each other, backtracking O(n²) on inputs like\r\n // \" … x\". Trimming + one \\s* keeps it linear; the length cap above is\r\n // belt-and-suspenders.\r\n const t = s.trim();\r\n const m = /^(?:₦|NGN)?\\s*(\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.(\\d{1,2}))?$/.exec(t);\r\n const wholeGroup = m?.[1];\r\n if (!m || wholeGroup === undefined) {\r\n throw new AmountParseError(\r\n `Cannot parse naira amount: \"${String(input)}\"`,\r\n input\r\n );\r\n }\r\n const whole = BigInt(wholeGroup.replace(/,/g, \"\"));\r\n const fracRaw = m[2] ?? \"\";\r\n const frac = BigInt(fracRaw.padEnd(2, \"0\") || \"0\");\r\n const kobo = whole * 100n + frac;\r\n if (kobo > MAX_SAFE) {\r\n throw new AmountParseError(`Amount exceeds MAX_SAFE_INTEGER: \"${String(input)}\"`, input);\r\n }\r\n return Number(kobo) as Kobo;\r\n}\r\n\r\n/** Display helper only. Never feed its output back into parsing logic. */\r\nexport function koboToNairaString(kobo: Kobo): string {\r\n const whole = Math.trunc(kobo / 100);\r\n const frac = kobo % 100;\r\n return `${whole.toLocaleString(\"en-NG\")}.${String(frac).padStart(2, \"0\")}`;\r\n}\r\n\r\n/**\r\n * Numeric naira input (e.g. Monnify sends `amount: 5000.5`) is converted to a\r\n * plain decimal string first so that BigInt string math — not float math —\r\n * performs the kobo conversion. toFixed(2) here is safe: provider amounts are\r\n * far below the ~2^53/100 threshold where representation error could shift a kobo,\r\n * and >2dp inputs are rejected as invalid money anyway.\r\n */\r\nfunction numberToPlainString(n: number): string {\r\n if (!Number.isFinite(n) || n < 0) {\r\n throw new AmountParseError(`Cannot parse naira amount: ${String(n)}`, n);\r\n }\r\n if (Number.isInteger(n)) return String(n);\r\n const fixed = n.toFixed(2);\r\n // Guard: reject inputs with >2dp of real precision (e.g. 5000.505)\r\n if (Math.abs(Number(fixed) - n) > Number.EPSILON * Math.max(1, Math.abs(n))) {\r\n throw new AmountParseError(\r\n `Naira amount has more than 2 decimal places: ${String(n)}`,\r\n n\r\n );\r\n }\r\n return fixed;\r\n}\r\n","import type { StandardizedTransaction } from \"../schema/transaction\";\r\nimport type { MalformedPayloadError, AmountParseError } from \"../errors\";\r\n\r\n/**\r\n * Every parse returns one of these — connectors NEVER throw on weird input and\r\n * NEVER return null. The type system enforces NOT_DOING.md §9:\r\n * \"no swallowing unknown events.\"\r\n *\r\n * Host handling guidance:\r\n * - 'transaction' -> idempotent upsert (unique dedupeKey + STATUS_RANK guard), ACK 200.\r\n * - 'unknown_event' -> log + store raw, ACK 200. Unknown ≠ error: providers add\r\n * event types without notice; NACKing them makes the provider\r\n * retry forever (their backoff, your log spam).\r\n * - 'parse_error' -> the poison-message case. ACK 200 (do NOT make the provider\r\n * retry a payload that will fail identically), park the raw\r\n * payload in your own dead-letter store, alert. This library\r\n * ships no queue (NOT_DOING.md §6) — the DLQ is yours.\r\n */\r\nexport type ParseResult =\r\n | { kind: \"transaction\"; transaction: StandardizedTransaction }\r\n | {\r\n kind: \"unknown_event\";\r\n provider: string;\r\n /** Provider's own event-type string when discoverable, else \"unrecognized\". */\r\n eventType: string;\r\n raw: unknown;\r\n }\r\n | {\r\n kind: \"parse_error\";\r\n provider: string;\r\n error: MalformedPayloadError | AmountParseError;\r\n raw: unknown;\r\n };\r\n\r\n/**\r\n * Result of parsing a settlement/statement file. Row-level isolation: one\r\n * mangled row (bank exports love those) yields one parse_error result — the\r\n * other 4,999 rows still normalize. All-or-nothing file parsing punishes the\r\n * host for the provider's data quality.\r\n */\r\nexport interface SettlementFileParseResult {\r\n provider: string;\r\n /** e.g. 'nomba-settlement-csv-v1' — which fixture-tested format matched. */\r\n format: string;\r\n rows: ParseResult[];\r\n /** Row counts by outcome, so hosts can alert on anomaly ratios cheaply. */\r\n summary: { transactions: number; unknown: number; errors: number };\r\n}\r\n\r\nexport function summarizeRows(rows: ParseResult[]): SettlementFileParseResult[\"summary\"] {\r\n const summary = { transactions: 0, unknown: 0, errors: 0 };\r\n for (const r of rows) {\r\n if (r.kind === \"transaction\") summary.transactions++;\r\n else if (r.kind === \"unknown_event\") summary.unknown++;\r\n else summary.errors++;\r\n }\r\n return summary;\r\n}\r\n","/**\r\n * RawBody — a branded Buffer that connectors require for signature verification.\r\n *\r\n * THE #1 INTEGRATION BUG THIS PREVENTS:\r\n * Providers compute webhook signatures (HMAC-SHA512 for Paystack, provider-specific\r\n * schemes elsewhere) over the EXACT BYTES they sent. If a host runs the request\r\n * through `express.json()` and later re-serializes (`JSON.stringify(req.body)`),\r\n * key order, whitespace, and unicode escaping can change — the HMAC no longer\r\n * matches, and the host spends a night debugging \"invalid signature\" on\r\n * perfectly valid webhooks.\r\n *\r\n * By requiring `RawBody` (constructible only from a Buffer via `toRawBody`),\r\n * a host that only has parsed JSON gets a COMPILE ERROR instead of a 2am mystery.\r\n *\r\n * Express hosts: mount `express.raw({ type: \"application/json\" })` on webhook\r\n * routes (route-scoped, before any json middleware), then `toRawBody(req.body)`.\r\n * Parse to JSON only AFTER verification, from these same bytes.\r\n */\r\nexport type RawBody = Buffer & { readonly __brand: \"RawBody\" };\r\n\r\nexport function toRawBody(buf: Buffer): RawBody {\r\n if (!Buffer.isBuffer(buf)) {\r\n throw new TypeError(\r\n \"toRawBody expects a Buffer of the exact request bytes. \" +\r\n \"If you only have a parsed object, your middleware consumed the raw body — \" +\r\n \"use express.raw() (or your framework's equivalent) on webhook routes.\"\r\n );\r\n }\r\n return buf as RawBody;\r\n}\r\n\r\n/**\r\n * Escape hatch for tests and fixture replay ONLY. Constructing a RawBody from a\r\n * string in production risks re-serialization drift — fixtures store the\r\n * original bytes precisely so this stays faithful.\r\n */\r\nexport function rawBodyFromString(s: string, encoding: BufferEncoding = \"utf8\"): RawBody {\r\n return Buffer.from(s, encoding) as RawBody;\r\n}\r\n","/**\r\n * Dedupe keys — idempotency for a world where providers retry webhooks.\r\n *\r\n * Webhook delivery is at-least-once everywhere: providers retry on timeout with\r\n * exponential backoff, and some (OPay callbacks, observed) simply double-fire.\r\n * The ONLY reliable defense is idempotent ingestion:\r\n *\r\n * CREATE UNIQUE INDEX ux_txn_dedupe ON transactions (dedupe_key);\r\n *\r\n * then INSERT ... ON CONFLICT DO NOTHING (or the equivalent upsert guarded by\r\n * shouldApplyStatusTransition for status updates). Enforcing uniqueness in\r\n * application code alone is a race condition — two workers processing the same\r\n * redelivered webhook concurrently will both pass an \"does it exist?\" check.\r\n * The database constraint is the lock.\r\n *\r\n * Key shape: `${provider}:${providerReference}` — namespaced so Paystack ref\r\n * \"TX123\" and Nomba ref \"TX123\" never collide. Connectors may override\r\n * dedupeKey() when a provider's reference is not unique per transaction\r\n * (e.g. one reference spanning multiple settlement rows) — that quirk knowledge\r\n * lives in the connector, backed by fixtures.\r\n */\r\nexport function composeDedupeKey(provider: string, providerReference: string): string {\r\n const p = provider.trim().toLowerCase();\r\n const r = providerReference.trim();\r\n if (!p || !r) {\r\n throw new Error(\r\n `composeDedupeKey requires non-empty provider and reference, got provider=\"${provider}\" reference=\"${providerReference}\"`\r\n );\r\n }\r\n return `${p}:${r}`;\r\n}\r\n"],"mappings":"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","names":["z","z","z"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pay-normalize/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"pay-normalize-source": "./src/index.ts",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format esm --dts --clean --sourcemap",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"typecheck": "tsc --noEmit -p .",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [],
|
|
25
|
+
"author": "",
|
|
26
|
+
"license": "ISC",
|
|
27
|
+
"description": "The contract for pay-normalize: schema, money (Kobo), status ordering, dedupe, errors, and the Connector interface.",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/lordisrael1/pay-normalize.git",
|
|
31
|
+
"directory": "packages/core"
|
|
32
|
+
},
|
|
33
|
+
"bugs": "https://github.com/lordisrael1/pay-normalize/issues",
|
|
34
|
+
"homepage": "https://github.com/lordisrael1/pay-normalize/tree/main/packages/core#readme",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "26.1.1",
|
|
40
|
+
"typescript": "6.0.3"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"zod": "4.4.3"
|
|
44
|
+
}
|
|
45
|
+
}
|