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