@fin-integrity/node 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 +162 -0
- package/dist/index.cjs +476 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +269 -0
- package/dist/index.d.ts +269 -0
- package/dist/index.js +443 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fin-integrity
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# @fin-integrity/node
|
|
2
|
+
|
|
3
|
+
**Reconciliation-as-you-code.** Capture your Stripe (and any processor) events _and_ your internal ledger entries from your backend, and fin·integrity continuously matches them — surfacing missing entries, duplicates, missing refunds, and amount/currency mismatches as incidents, in real time.
|
|
4
|
+
|
|
5
|
+
Think **"Sentry for money movement"**: add a few lines, keep your own ledger, and catch every payment that doesn't reconcile before your customers, finance team, or auditors do.
|
|
6
|
+
|
|
7
|
+
- 🪶 Tiny, zero-runtime-dependency, dual **ESM + CJS**, fully typed
|
|
8
|
+
- 🔌 **Processor-agnostic** core (`processor.record` / `ledger.record`) with a Stripe adapter
|
|
9
|
+
- 🧯 **Fail-open by design** — the SDK can never throw into or block your money path
|
|
10
|
+
- ⚡ Async, batched, non-blocking capture with graceful serverless flush
|
|
11
|
+
- 🔑 Server-side secret keys; you send transaction metadata, never card data
|
|
12
|
+
|
|
13
|
+
> Status: early (`0.1.x`). The API surface below is stable; internals may change.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @fin-integrity/node
|
|
19
|
+
# Stripe auto-instrumentation is an optional peer dependency:
|
|
20
|
+
npm install stripe
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import Stripe from "stripe";
|
|
27
|
+
import { init, instrumentStripe } from "@fin-integrity/node";
|
|
28
|
+
|
|
29
|
+
const fi = init({ apiKey: process.env.FIN_INTEGRITY_KEY! });
|
|
30
|
+
|
|
31
|
+
// 1) Auto-capture the processor side by wrapping your Stripe client
|
|
32
|
+
const stripe = instrumentStripe(new Stripe(process.env.STRIPE_KEY!), fi);
|
|
33
|
+
const charge = await stripe.charges.create({ amount: 4999, currency: "usd", metadata: { reference: "order_10432" } });
|
|
34
|
+
|
|
35
|
+
// 2) Report the ledger side where you write to your own books
|
|
36
|
+
fi.ledger.record({
|
|
37
|
+
type: "payment",
|
|
38
|
+
reference: "order_10432", // the shared key both sides agree on
|
|
39
|
+
external_id: journalEntry.id,
|
|
40
|
+
amount: { minor: 4999, currency: "usd" },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// fin·integrity matches the two by `reference` and flags any mismatch.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That's it. Any processor works — for non-Stripe sources, capture the processor side explicitly:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
fi.processor.record({
|
|
50
|
+
type: "payment",
|
|
51
|
+
source: "adyen",
|
|
52
|
+
reference: "order_10432",
|
|
53
|
+
external_id: "8536214598321",
|
|
54
|
+
amount: { minor: 4999, currency: "usd" },
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## The model: two sides, one reference
|
|
59
|
+
|
|
60
|
+
fin·integrity reconciles two streams:
|
|
61
|
+
|
|
62
|
+
| Side | What it is | You send it with |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| **processor** | The money actually moved (Stripe/Adyen/bank …) | `fi.processor.record(...)` (or `instrumentStripe`) |
|
|
65
|
+
| **ledger** | Your internal books / system of record | `fi.ledger.record(...)` |
|
|
66
|
+
|
|
67
|
+
Both sides carry a shared **`reference`** (an order id, invoice id, whatever both systems agree on). fin·integrity **matches on `reference` + `type`**, then **compares** `amount` and `currency` — so a wrong amount surfaces as an incident instead of silently failing to match.
|
|
68
|
+
|
|
69
|
+
## Money
|
|
70
|
+
|
|
71
|
+
Always integer **minor units** + an ISO-4217 currency code — never floats.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
{ minor: 4999, currency: "usd" } // $49.99
|
|
75
|
+
{ minor: 1000, currency: "jpy" } // ¥1000 (zero-decimal)
|
|
76
|
+
{ minor: 10000, currency: "bhd", exponent: 3 } // 10.000 BHD
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`minor` accepts a `number` or a `bigint` (for very large values). Non-integer amounts are rejected (routed to `onError`, never thrown).
|
|
80
|
+
|
|
81
|
+
## API
|
|
82
|
+
|
|
83
|
+
### `init(config?) → FinIntegrityClient`
|
|
84
|
+
Creates the client and stores it as a module singleton (also returned).
|
|
85
|
+
|
|
86
|
+
| Option | Default | Description |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `apiKey` | `env FIN_INTEGRITY_KEY` | Secret key `fi_sk_live_…` / `fi_sk_test_…`. |
|
|
89
|
+
| `endpoint` | hosted default | Ingest base URL. Point at your own for self-host/local. |
|
|
90
|
+
| `environment` | `NODE_ENV` | Tag events with an environment. |
|
|
91
|
+
| `idempotency` | `"deterministic"` | `"deterministic"` (content hash, retry-safe) or `"uuid"`. |
|
|
92
|
+
| `batch` | `{ maxSize: 50, flushMs: 2000 }` | Flush on size **or** interval. |
|
|
93
|
+
| `maxQueueSize` | `1000` | Bounded queue; oldest events drop first (counted + reported). |
|
|
94
|
+
| `retries` | `3` | Network retry attempts (exp backoff; honors `Retry-After`). |
|
|
95
|
+
| `sampleRate` | `1.0` | Keep everything by default — never silently drop money events. |
|
|
96
|
+
| `beforeSend` | — | Mutate or drop (`return null`) each envelope, e.g. redact PII. |
|
|
97
|
+
| `debug` | `false` | Log transport activity. |
|
|
98
|
+
| `dryRun` | `false` | Build/validate but never hit the network; inspect via `inspect()`. |
|
|
99
|
+
| `onError` | warns in debug | Called on any internal/transport error. The SDK never throws into your code. |
|
|
100
|
+
|
|
101
|
+
### `fi.processor.record(input)` / `fi.ledger.record(input)`
|
|
102
|
+
```ts
|
|
103
|
+
interface RecordInput {
|
|
104
|
+
type: "payment" | "refund";
|
|
105
|
+
source?: string; // e.g. "stripe", "adyen"; defaults per side
|
|
106
|
+
reference: string; // shared cross-side key
|
|
107
|
+
external_id: string; // this side's native id
|
|
108
|
+
amount: { minor: number | bigint; currency: string; exponent?: number };
|
|
109
|
+
occurred_at?: string | Date; // defaults to now
|
|
110
|
+
status?: string;
|
|
111
|
+
direction?: "credit" | "debit";
|
|
112
|
+
metadata?: Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `fi.capture(input & { side })`
|
|
117
|
+
Low-level escape hatch used by the adapters — full control over `side`.
|
|
118
|
+
|
|
119
|
+
### `instrumentStripe(stripe, fi) → stripe`
|
|
120
|
+
Wraps the **exact Stripe instance you pass in** so `charges.create`, `paymentIntents.create`, and `refunds.create` are captured automatically. No global monkey-patching, so it's ESM/bundler-safe. Capture runs off the hot path and **never blocks or alters your Stripe call**. Set `metadata.reference` on your Stripe calls to control the reconciliation key.
|
|
121
|
+
|
|
122
|
+
### `await fi.flush()` / `await fi.shutdown()`
|
|
123
|
+
`flush()` sends everything queued now. `shutdown()` drains and stops the client. In **serverless**, flush before the function returns:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
export const handler = async (event) => {
|
|
127
|
+
try { return await doWork(event); }
|
|
128
|
+
finally { await fi.flush(); } // don't let the runtime freeze before the batch sends
|
|
129
|
+
};
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `fi.inspect() → EventEnvelope[]`
|
|
133
|
+
Returns captured envelopes (in `dryRun` or with a custom transport) — the easiest way to **unit-test** that your integration emits the right events, no HTTP mocking:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const fi = init({ dryRun: true });
|
|
137
|
+
placeOrder();
|
|
138
|
+
expect(fi.inspect()).toContainEqual(expect.objectContaining({ reference: "order_10432" }));
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Reliability & safety
|
|
142
|
+
|
|
143
|
+
- **Fail-open:** every internal path is wrapped; SDK errors degrade to `onError` + a no-op. Your payment code is never affected.
|
|
144
|
+
- **Bounded, observable:** the queue is capped (drop-oldest); dropped counts ride along on the next batch so loss is never silent.
|
|
145
|
+
- **Idempotent:** each event carries an `Idempotency-Key`; retries never create duplicate rows. The default deterministic key means even a crash-then-retry collapses to one event.
|
|
146
|
+
- **Backpressure-aware:** honors `429` + `Retry-After` instead of retry-storming.
|
|
147
|
+
|
|
148
|
+
## Security & data
|
|
149
|
+
|
|
150
|
+
Send **transaction metadata** — ids, amounts, currency, timestamps, status, your own `metadata`. **Do not send card numbers / PANs or secrets.** Use `beforeSend` to redact anything sensitive before it leaves your process. Keys are server-side secrets — never ship them to a browser.
|
|
151
|
+
|
|
152
|
+
## Local development
|
|
153
|
+
|
|
154
|
+
Point the SDK at a local ingest endpoint:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const fi = init({ apiKey: "fi_sk_test_…", endpoint: "http://localhost:3005" });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
[MIT](./LICENSE) © fin-integrity
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ConfigError: () => ConfigError,
|
|
24
|
+
FinIntegrityClient: () => FinIntegrityClient,
|
|
25
|
+
FinIntegrityError: () => FinIntegrityError,
|
|
26
|
+
RejectedEventsError: () => RejectedEventsError,
|
|
27
|
+
getClient: () => getClient,
|
|
28
|
+
init: () => init,
|
|
29
|
+
instrumentStripe: () => instrumentStripe
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/environment.ts
|
|
34
|
+
var MAX_LEN = 64;
|
|
35
|
+
var INVALID = /[\s/]/;
|
|
36
|
+
function cleanEnvironment(raw) {
|
|
37
|
+
if (typeof raw !== "string") return void 0;
|
|
38
|
+
const v = raw.trim();
|
|
39
|
+
if (!v || v.length > MAX_LEN || INVALID.test(v) || v.toLowerCase() === "none") return void 0;
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/errors.ts
|
|
44
|
+
var FinIntegrityError = class extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "FinIntegrityError";
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
var ConfigError = class extends FinIntegrityError {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "ConfigError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// src/idempotency.ts
|
|
58
|
+
var import_node_crypto = require("crypto");
|
|
59
|
+
function deterministicKey(e) {
|
|
60
|
+
const basis = [
|
|
61
|
+
e.source,
|
|
62
|
+
e.side,
|
|
63
|
+
e.external_id,
|
|
64
|
+
e.event_type,
|
|
65
|
+
// Environment is part of identity: the same external_id in staging vs
|
|
66
|
+
// production are distinct facts that must not collapse to one row.
|
|
67
|
+
e.environment ?? "",
|
|
68
|
+
// Mutable state. Absent fields collapse to "" so unused ones cost nothing.
|
|
69
|
+
e.status ?? "",
|
|
70
|
+
e.amount?.minor ?? "",
|
|
71
|
+
e.current_period_end ?? "",
|
|
72
|
+
e.arrival_at ?? ""
|
|
73
|
+
].join(":");
|
|
74
|
+
return "fi_" + (0, import_node_crypto.createHash)("sha256").update(basis).digest("hex").slice(0, 40);
|
|
75
|
+
}
|
|
76
|
+
function uuidKey() {
|
|
77
|
+
return "fi_" + (0, import_node_crypto.randomUUID)();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/transport.ts
|
|
81
|
+
var HttpTransport = class {
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
this.opts = opts;
|
|
84
|
+
}
|
|
85
|
+
opts;
|
|
86
|
+
async send(batch, meta) {
|
|
87
|
+
const url = this.opts.endpoint.replace(/\/+$/, "") + "/v1/events";
|
|
88
|
+
const body = JSON.stringify({ sent_at: (/* @__PURE__ */ new Date()).toISOString(), dropped: meta.dropped, events: batch });
|
|
89
|
+
for (let attempt = 0; ; attempt++) {
|
|
90
|
+
let res;
|
|
91
|
+
try {
|
|
92
|
+
res = await fetch(url, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
authorization: `Bearer ${this.opts.apiKey}`,
|
|
97
|
+
"idempotency-key": batch[0]?.idempotency_key ?? ""
|
|
98
|
+
},
|
|
99
|
+
body
|
|
100
|
+
});
|
|
101
|
+
} catch (netErr) {
|
|
102
|
+
if (attempt >= this.opts.retries) throw netErr;
|
|
103
|
+
await sleep(backoff(attempt));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (res.ok) {
|
|
107
|
+
const rejected = await rejectedFrom(res);
|
|
108
|
+
if (rejected.length > 0) {
|
|
109
|
+
throw new RejectedEventsError(rejected, batch.length);
|
|
110
|
+
}
|
|
111
|
+
if (this.opts.debug) console.log(`[fin-integrity] delivered ${batch.length} event(s)`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (res.status === 429 || res.status >= 500) {
|
|
115
|
+
if (attempt >= this.opts.retries) throw new Error(`fin-integrity ingest ${res.status}`);
|
|
116
|
+
const ra = Number(res.headers.get("retry-after"));
|
|
117
|
+
await sleep(Number.isFinite(ra) && ra > 0 ? ra * 1e3 : backoff(attempt));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`fin-integrity ingest ${res.status}: ${await safeText(res)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
var MemoryTransport = class {
|
|
125
|
+
sent = [];
|
|
126
|
+
async send(batch) {
|
|
127
|
+
this.sent.push(...batch);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
function backoff(n) {
|
|
131
|
+
return Math.min(1e3 * 2 ** n, 15e3) + Math.random() * 250;
|
|
132
|
+
}
|
|
133
|
+
function sleep(ms) {
|
|
134
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
135
|
+
}
|
|
136
|
+
var RejectedEventsError = class extends FinIntegrityError {
|
|
137
|
+
constructor(rejected, batchSize) {
|
|
138
|
+
const detail = rejected.map((r) => `${r.event_id}: ${r.error}`).join("; ");
|
|
139
|
+
super(`fin-integrity: ingest rejected ${rejected.length}/${batchSize} event(s) \u2014 ${detail}`);
|
|
140
|
+
this.rejected = rejected;
|
|
141
|
+
this.batchSize = batchSize;
|
|
142
|
+
this.name = "RejectedEventsError";
|
|
143
|
+
}
|
|
144
|
+
rejected;
|
|
145
|
+
batchSize;
|
|
146
|
+
};
|
|
147
|
+
async function rejectedFrom(res) {
|
|
148
|
+
try {
|
|
149
|
+
const body = await res.clone().json();
|
|
150
|
+
if (!Array.isArray(body?.results)) return [];
|
|
151
|
+
return body.results.filter((r) => r?.status === "rejected").map((r) => ({ event_id: r.event_id ?? "unknown", error: r.error ?? "unknown error" }));
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function safeText(res) {
|
|
157
|
+
try {
|
|
158
|
+
return await res.text();
|
|
159
|
+
} catch {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/client.ts
|
|
165
|
+
var DEFAULT_ENDPOINT = "https://ingest.fin-integrity.com";
|
|
166
|
+
var FinIntegrityClient = class {
|
|
167
|
+
apiKey;
|
|
168
|
+
endpoint;
|
|
169
|
+
idempotencyMode;
|
|
170
|
+
maxSize;
|
|
171
|
+
flushMs;
|
|
172
|
+
maxQueueSize;
|
|
173
|
+
sampleRate;
|
|
174
|
+
environment;
|
|
175
|
+
beforeSend;
|
|
176
|
+
debug;
|
|
177
|
+
onError;
|
|
178
|
+
transport;
|
|
179
|
+
memory;
|
|
180
|
+
queue = [];
|
|
181
|
+
dropped = 0;
|
|
182
|
+
timer;
|
|
183
|
+
/** Capture money-movement observed from a payment processor (Stripe, Adyen, bank, …). */
|
|
184
|
+
processor;
|
|
185
|
+
/** Capture entries from your own internal ledger / books. */
|
|
186
|
+
ledger;
|
|
187
|
+
constructor(config = {}) {
|
|
188
|
+
const dryRun = config.dryRun ?? false;
|
|
189
|
+
const apiKey = config.apiKey ?? process.env.FIN_INTEGRITY_KEY;
|
|
190
|
+
if (!apiKey && !dryRun && !config.transport) {
|
|
191
|
+
throw new ConfigError(
|
|
192
|
+
"fin-integrity: apiKey is required (pass config.apiKey or set FIN_INTEGRITY_KEY). Use { dryRun: true } to test without a key."
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
this.apiKey = apiKey ?? "dry-run";
|
|
196
|
+
this.endpoint = config.endpoint ?? process.env.FIN_INTEGRITY_ENDPOINT ?? DEFAULT_ENDPOINT;
|
|
197
|
+
this.idempotencyMode = config.idempotency ?? "deterministic";
|
|
198
|
+
this.maxSize = config.batch?.maxSize ?? 50;
|
|
199
|
+
this.flushMs = config.batch?.flushMs ?? 2e3;
|
|
200
|
+
this.maxQueueSize = config.maxQueueSize ?? 1e3;
|
|
201
|
+
this.sampleRate = config.sampleRate ?? 1;
|
|
202
|
+
this.environment = cleanEnvironment(config.environment ?? process.env.NODE_ENV);
|
|
203
|
+
this.beforeSend = config.beforeSend;
|
|
204
|
+
this.debug = config.debug ?? false;
|
|
205
|
+
this.onError = config.onError ?? ((e) => {
|
|
206
|
+
if (this.debug) console.warn("[fin-integrity]", e);
|
|
207
|
+
});
|
|
208
|
+
if (config.transport) {
|
|
209
|
+
this.transport = config.transport;
|
|
210
|
+
} else if (dryRun) {
|
|
211
|
+
this.memory = new MemoryTransport();
|
|
212
|
+
this.transport = this.memory;
|
|
213
|
+
} else {
|
|
214
|
+
this.transport = new HttpTransport({
|
|
215
|
+
endpoint: this.endpoint,
|
|
216
|
+
apiKey: this.apiKey,
|
|
217
|
+
retries: config.retries ?? 3,
|
|
218
|
+
debug: this.debug
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
this.processor = {
|
|
222
|
+
record: (input) => this.record("processor", input),
|
|
223
|
+
recordPayout: (input) => this.recordPayout(input),
|
|
224
|
+
recordSubscription: (input) => this.recordSubscription(input)
|
|
225
|
+
};
|
|
226
|
+
this.ledger = { record: (input) => this.record("ledger", input) };
|
|
227
|
+
this.timer = setInterval(() => void this.flush(), this.flushMs);
|
|
228
|
+
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
229
|
+
process.once("beforeExit", () => void this.flush());
|
|
230
|
+
process.once("SIGTERM", () => this.onSigterm());
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Drain on SIGTERM, then hand the process back its normal fate.
|
|
234
|
+
*
|
|
235
|
+
* Node disables its default terminate-on-SIGTERM as soon as ANY listener is
|
|
236
|
+
* registered, so simply attaching one made this library silently decide that
|
|
237
|
+
* SIGTERM no longer kills the host process — a bare script would ignore
|
|
238
|
+
* `docker stop` and hang until SIGKILL, which is also when the queued events
|
|
239
|
+
* we were trying to protect get dropped anyway.
|
|
240
|
+
*
|
|
241
|
+
* So: flush, then exit ourselves — but only if nothing else is listening. If
|
|
242
|
+
* the app registered its own handler it owns the shutdown sequence, and a
|
|
243
|
+
* library must not exit out from under it. `once` has already removed our own
|
|
244
|
+
* listener by the time this runs, so any remaining count is the app's.
|
|
245
|
+
*/
|
|
246
|
+
onSigterm() {
|
|
247
|
+
const appHandlesIt = process.listenerCount("SIGTERM") > 0;
|
|
248
|
+
void this.flush().finally(() => {
|
|
249
|
+
if (!appHandlesIt) process.exit(143);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/** Low-level escape hatch: capture a fully-specified event (both adapters use this). */
|
|
253
|
+
capture(input) {
|
|
254
|
+
this.record(input.side, input);
|
|
255
|
+
}
|
|
256
|
+
record(side, input) {
|
|
257
|
+
try {
|
|
258
|
+
const env = {
|
|
259
|
+
schema_version: "1.0",
|
|
260
|
+
event_id: uuidKey(),
|
|
261
|
+
idempotency_key: "",
|
|
262
|
+
side,
|
|
263
|
+
source: input.source ?? (side === "ledger" ? "ledger.internal" : "custom"),
|
|
264
|
+
event_type: input.type,
|
|
265
|
+
reference: input.reference,
|
|
266
|
+
external_id: input.external_id,
|
|
267
|
+
amount: {
|
|
268
|
+
minor: toMinorString(input.amount.minor),
|
|
269
|
+
currency: input.amount.currency.toLowerCase(),
|
|
270
|
+
...input.amount.exponent != null ? { exponent: input.amount.exponent } : {}
|
|
271
|
+
},
|
|
272
|
+
...input.fee != null ? { fee: { minor: toMinorString(input.fee.minor), currency: input.fee.currency.toLowerCase() } } : {},
|
|
273
|
+
...input.traceId != null ? { trace_id: input.traceId } : {},
|
|
274
|
+
...input.payoutId != null ? { payout_id: input.payoutId } : {},
|
|
275
|
+
...input.subscriptionId != null ? { subscription_id: input.subscriptionId } : {},
|
|
276
|
+
...input.parentExternalId != null ? { parent_external_id: input.parentExternalId } : {},
|
|
277
|
+
occurred_at: toIso(input.occurred_at),
|
|
278
|
+
captured_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
279
|
+
...input.status != null ? { status: input.status } : {},
|
|
280
|
+
...input.direction != null ? { direction: input.direction } : {},
|
|
281
|
+
...this.envFields(input.environment),
|
|
282
|
+
...input.metadata != null ? { metadata: input.metadata } : {}
|
|
283
|
+
};
|
|
284
|
+
env.idempotency_key = this.idempotencyMode === "uuid" ? uuidKey() : deterministicKey(env);
|
|
285
|
+
this.enqueue(env);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
this.onError(err);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Capture a recurring billing container. Not money movement — this is what a
|
|
292
|
+
* charge is expected to arrive in, which is what lets reconciliation catch a
|
|
293
|
+
* billing period that produced no charge at all.
|
|
294
|
+
*
|
|
295
|
+
* Send it whenever the subscription changes (created, renewed, status change)
|
|
296
|
+
* so `currentPeriodEnd` stays current.
|
|
297
|
+
*/
|
|
298
|
+
recordSubscription(input) {
|
|
299
|
+
try {
|
|
300
|
+
const env = {
|
|
301
|
+
schema_version: "1.0",
|
|
302
|
+
event_id: uuidKey(),
|
|
303
|
+
idempotency_key: "",
|
|
304
|
+
side: "processor",
|
|
305
|
+
source: input.source ?? "custom",
|
|
306
|
+
event_type: "subscription",
|
|
307
|
+
reference: input.external_id,
|
|
308
|
+
external_id: input.external_id,
|
|
309
|
+
amount: {
|
|
310
|
+
minor: toMinorString(input.amount.minor),
|
|
311
|
+
currency: input.amount.currency.toLowerCase(),
|
|
312
|
+
...input.amount.exponent != null ? { exponent: input.amount.exponent } : {}
|
|
313
|
+
},
|
|
314
|
+
status: input.status,
|
|
315
|
+
...input.interval != null ? { interval: input.interval } : {},
|
|
316
|
+
...input.currentPeriodStart != null ? { current_period_start: toIso(input.currentPeriodStart) } : {},
|
|
317
|
+
...input.currentPeriodEnd != null ? { current_period_end: toIso(input.currentPeriodEnd) } : {},
|
|
318
|
+
...input.traceId != null ? { trace_id: input.traceId } : {},
|
|
319
|
+
occurred_at: toIso(input.occurred_at),
|
|
320
|
+
captured_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
321
|
+
...this.envFields(input.environment),
|
|
322
|
+
...input.metadata != null ? { metadata: input.metadata } : {}
|
|
323
|
+
};
|
|
324
|
+
env.idempotency_key = this.idempotencyMode === "uuid" ? uuidKey() : deterministicKey(env);
|
|
325
|
+
this.enqueue(env);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
this.onError(err);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Capture a processor payout (processor → bank). Stored separately; links to
|
|
331
|
+
* transactions via their payoutId. */
|
|
332
|
+
recordPayout(input) {
|
|
333
|
+
try {
|
|
334
|
+
const env = {
|
|
335
|
+
schema_version: "1.0",
|
|
336
|
+
event_id: uuidKey(),
|
|
337
|
+
idempotency_key: "",
|
|
338
|
+
side: "processor",
|
|
339
|
+
source: input.source ?? "custom",
|
|
340
|
+
event_type: "payout",
|
|
341
|
+
reference: input.external_id,
|
|
342
|
+
external_id: input.external_id,
|
|
343
|
+
amount: {
|
|
344
|
+
minor: toMinorString(input.amount.minor),
|
|
345
|
+
currency: input.amount.currency.toLowerCase(),
|
|
346
|
+
...input.amount.exponent != null ? { exponent: input.amount.exponent } : {}
|
|
347
|
+
},
|
|
348
|
+
...input.traceId != null ? { trace_id: input.traceId } : {},
|
|
349
|
+
...input.arrivalAt != null ? { arrival_at: toIso(input.arrivalAt) } : {},
|
|
350
|
+
occurred_at: toIso(input.occurred_at),
|
|
351
|
+
captured_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
352
|
+
...input.status != null ? { status: input.status } : {},
|
|
353
|
+
...this.envFields(input.environment),
|
|
354
|
+
...input.metadata != null ? { metadata: input.metadata } : {}
|
|
355
|
+
};
|
|
356
|
+
env.idempotency_key = this.idempotencyMode === "uuid" ? uuidKey() : deterministicKey(env);
|
|
357
|
+
this.enqueue(env);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
this.onError(err);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Per-event override wins over the client default; an invalid value falls back
|
|
363
|
+
* to the default (and, if that's absent too, the server defaults to production). */
|
|
364
|
+
envFields(perEvent) {
|
|
365
|
+
const environment = cleanEnvironment(perEvent) ?? this.environment;
|
|
366
|
+
return environment != null ? { environment } : {};
|
|
367
|
+
}
|
|
368
|
+
enqueue(env) {
|
|
369
|
+
if (this.sampleRate < 1 && Math.random() > this.sampleRate) return;
|
|
370
|
+
let out = env;
|
|
371
|
+
if (this.beforeSend) out = this.beforeSend(env);
|
|
372
|
+
if (!out) return;
|
|
373
|
+
if (this.queue.length >= this.maxQueueSize) {
|
|
374
|
+
this.queue.shift();
|
|
375
|
+
this.dropped++;
|
|
376
|
+
}
|
|
377
|
+
this.queue.push(out);
|
|
378
|
+
if (this.queue.length >= this.maxSize) void this.flush();
|
|
379
|
+
}
|
|
380
|
+
/** Send all queued events now. Safe to await (e.g. in a serverless `finally`). */
|
|
381
|
+
async flush() {
|
|
382
|
+
if (this.queue.length === 0) return;
|
|
383
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
384
|
+
const dropped = this.dropped;
|
|
385
|
+
this.dropped = 0;
|
|
386
|
+
try {
|
|
387
|
+
await this.transport.send(batch, { dropped });
|
|
388
|
+
} catch (err) {
|
|
389
|
+
this.onError(err);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** Drain and stop the client (call before a long-lived process exits). */
|
|
393
|
+
async shutdown() {
|
|
394
|
+
if (this.timer) clearInterval(this.timer);
|
|
395
|
+
await this.flush();
|
|
396
|
+
}
|
|
397
|
+
/** Envelopes captured so far (dryRun / MemoryTransport only). Great for tests. */
|
|
398
|
+
inspect() {
|
|
399
|
+
return this.memory ? [...this.memory.sent, ...this.queue] : [...this.queue];
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
function toMinorString(m) {
|
|
403
|
+
if (typeof m === "bigint") return m.toString();
|
|
404
|
+
if (!Number.isInteger(m)) {
|
|
405
|
+
throw new FinIntegrityError(`amount.minor must be an integer in minor units, got ${m}`);
|
|
406
|
+
}
|
|
407
|
+
return String(m);
|
|
408
|
+
}
|
|
409
|
+
function toIso(v) {
|
|
410
|
+
if (!v) return (/* @__PURE__ */ new Date()).toISOString();
|
|
411
|
+
return v instanceof Date ? v.toISOString() : v;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/stripe.ts
|
|
415
|
+
function instrumentStripe(stripe, fi) {
|
|
416
|
+
const s = stripe;
|
|
417
|
+
wrap(s?.charges, "create", fi, "payment");
|
|
418
|
+
wrap(s?.paymentIntents, "create", fi, "payment");
|
|
419
|
+
wrap(s?.refunds, "create", fi, "refund");
|
|
420
|
+
return stripe;
|
|
421
|
+
}
|
|
422
|
+
function wrap(resource, method, fi, type) {
|
|
423
|
+
if (!resource || typeof resource[method] !== "function") return;
|
|
424
|
+
const original = resource[method];
|
|
425
|
+
if (original.__fiWrapped) return;
|
|
426
|
+
const wrapped = async function(...args) {
|
|
427
|
+
const result = await original.apply(this, args);
|
|
428
|
+
queueMicrotask(() => {
|
|
429
|
+
try {
|
|
430
|
+
captureStripe(fi, type, result);
|
|
431
|
+
} catch {
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
return result;
|
|
435
|
+
};
|
|
436
|
+
wrapped.__fiWrapped = true;
|
|
437
|
+
resource[method] = wrapped;
|
|
438
|
+
}
|
|
439
|
+
function captureStripe(fi, type, obj) {
|
|
440
|
+
if (!obj || typeof obj !== "object" || typeof obj.id !== "string") return;
|
|
441
|
+
const amount = type === "refund" ? obj.amount : obj.amount_received ?? obj.amount;
|
|
442
|
+
const reference = obj.metadata?.reference ?? obj.metadata?.order_id ?? obj.metadata?.reconciliation_id ?? (type === "refund" ? obj.charge ?? obj.payment_intent ?? obj.id : obj.id);
|
|
443
|
+
fi.capture({
|
|
444
|
+
side: "processor",
|
|
445
|
+
type,
|
|
446
|
+
source: "stripe",
|
|
447
|
+
reference: String(reference),
|
|
448
|
+
external_id: obj.id,
|
|
449
|
+
amount: { minor: typeof amount === "number" ? amount : 0, currency: String(obj.currency ?? "") },
|
|
450
|
+
...obj.status ? { status: String(obj.status) } : {},
|
|
451
|
+
...typeof obj.created === "number" ? { occurred_at: new Date(obj.created * 1e3) } : {},
|
|
452
|
+
metadata: { stripe_object: obj.object }
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/index.ts
|
|
457
|
+
var current;
|
|
458
|
+
function init(config) {
|
|
459
|
+
current = new FinIntegrityClient(config);
|
|
460
|
+
return current;
|
|
461
|
+
}
|
|
462
|
+
function getClient() {
|
|
463
|
+
if (!current) throw new Error("fin-integrity: call init() before getClient()");
|
|
464
|
+
return current;
|
|
465
|
+
}
|
|
466
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
467
|
+
0 && (module.exports = {
|
|
468
|
+
ConfigError,
|
|
469
|
+
FinIntegrityClient,
|
|
470
|
+
FinIntegrityError,
|
|
471
|
+
RejectedEventsError,
|
|
472
|
+
getClient,
|
|
473
|
+
init,
|
|
474
|
+
instrumentStripe
|
|
475
|
+
});
|
|
476
|
+
//# sourceMappingURL=index.cjs.map
|