b2b-platform-utils 1.1.97 → 1.1.99

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/README.md CHANGED
@@ -36,3 +36,15 @@ process instance identity, deployment version, phase, and timing fields.
36
36
  ```bash
37
37
  # make sure your .npmrc has a valid auth token for npmjs.org
38
38
  npm i platform-utils
39
+ # Exact financial admission helpers
40
+
41
+ `financialAdmissionMath` is the existing Payment precision arithmetic, shared
42
+ without changing its decimal, rational, posting or wire-format rules.
43
+ `financialAdmissionPlan` provides versioned canonical encoding, deterministic plan
44
+ fingerprints and exact before/after arithmetic for normalized accounting legs.
45
+ RG debit measures actual real-wallet BET debit; bonus balances remain separate.
46
+
47
+ These helpers do not grant wallet ownership or authorize session identity, funding
48
+ allocation, bonus attribution, reversal eligibility or RG. Payment retains those
49
+ decisions and must verify the plan against its issued context and settlement facts.
50
+ No provider is enrolled or activated by importing either helper.
package/errorsMap.js CHANGED
@@ -1830,6 +1830,18 @@ const STATIC_ERRORS = [
1830
1830
  httpCode: 422,
1831
1831
  description: "New password must differ from the current password.",
1832
1832
  },
1833
+ {
1834
+ errorCode: 1298,
1835
+ errorKey: "withdrawalKycRequired",
1836
+ httpCode: 409,
1837
+ description: "Verified KYC is required before withdrawal.",
1838
+ },
1839
+ {
1840
+ errorCode: 1299,
1841
+ errorKey: "invalidRiskPermissionConditions",
1842
+ httpCode: 422,
1843
+ description: "Risk permission conditions contain an unsupported permission or condition.",
1844
+ },
1833
1845
  ];
1834
1846
 
1835
1847
  const STATIC_BY_KEY = Object.fromEntries(
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ // Fractions are accounting minor units unless explicitly projected to wire major units.
4
+ const LIMIT = 4096;
5
+ function integer(value) {
6
+ const s = String(value);
7
+ if (!/^-?(0|[1-9]\d*)$/.test(s) || s.replace('-', '').length > LIMIT)
8
+ throw new TypeError('precision_integer_invalid');
9
+ return BigInt(s);
10
+ }
11
+ function rational(n, d = 1n) {
12
+ n = integer(n);
13
+ d = integer(d);
14
+ if (d <= 0n) throw new TypeError('precision_denominator_invalid');
15
+ let a = n < 0n ? -n : n;
16
+ let b = d;
17
+ while (b) [a, b] = [b, a % b];
18
+ return { n: n / a, d: d / a };
19
+ }
20
+ function decimal(value) {
21
+ if (typeof value !== 'string' || value.length > LIMIT || !/^-?(0|[1-9]\d*)(\.\d+)?$/.test(value))
22
+ throw new TypeError('precision_decimal_invalid');
23
+ const [whole, frac = ''] = value.split('.');
24
+ return rational(BigInt(whole + frac), 10n ** BigInt(frac.length));
25
+ }
26
+ const add = (a, b) => rational(a.n * b.d + b.n * a.d, a.d * b.d);
27
+ const neg = (a) => rational(-a.n, a.d);
28
+ const mul = (a, b) => rational(a.n * b.n, a.d * b.d);
29
+ function div(a, b) {
30
+ if (b.n <= 0n) throw new TypeError('precision_divisor_invalid');
31
+ return rational(a.n * b.d, a.d * b.n);
32
+ }
33
+ const floor = (a) => (a.n >= 0n ? a.n / a.d : -((-a.n + a.d - 1n) / a.d));
34
+ function scaleFactor(scale) {
35
+ if (!Number.isInteger(scale) || scale < 0 || scale > 255)
36
+ throw new TypeError('precision_scale_invalid');
37
+ return rational(10n ** BigInt(scale));
38
+ }
39
+ function minor(value) {
40
+ const n = integer(value);
41
+ if ((n < 0n ? -n : n).toString().length > 36) throw new TypeError('precision_ledger_overflow');
42
+ return n;
43
+ }
44
+ function post(c, r, delta) {
45
+ const x = add(r, delta);
46
+ const m = floor(x);
47
+ return { integerDelta: minor(m), balance: minor(c + m), remainder: add(x, rational(-m)) };
48
+ }
49
+ function convert(wire, session) {
50
+ return div(
51
+ mul(
52
+ mul(decimal(wire), rational(session.wire_multiplier)),
53
+ scaleFactor(Number(session.accounting_scale)),
54
+ ),
55
+ rational(session.fx_numerator, session.fx_denominator),
56
+ );
57
+ }
58
+ function project(c, r, session) {
59
+ return div(
60
+ mul(
61
+ div(add(rational(c), r), scaleFactor(Number(session.accounting_scale))),
62
+ rational(session.fx_numerator, session.fx_denominator),
63
+ ),
64
+ rational(session.wire_multiplier),
65
+ );
66
+ }
67
+ function roundHalfUp(a, scale) {
68
+ const scaled = mul(a, scaleFactor(scale));
69
+ const sign = scaled.n < 0n ? -1n : 1n;
70
+ const magnitude = scaled.n * sign;
71
+ return rational(sign * ((2n * magnitude + scaled.d) / (2n * scaled.d)), 10n ** BigInt(scale));
72
+ }
73
+ function exactDecimal(a, allowRationalOnly = false) {
74
+ let d = a.d;
75
+ let twos = 0;
76
+ let fives = 0;
77
+ while (d % 2n === 0n) {
78
+ d /= 2n;
79
+ twos++;
80
+ }
81
+ while (d % 5n === 0n) {
82
+ d /= 5n;
83
+ fives++;
84
+ }
85
+ if (d !== 1n) {
86
+ if (allowRationalOnly) return '';
87
+ throw new TypeError('precision_wire_policy_required');
88
+ }
89
+ const places = Math.max(twos, fives);
90
+ if (places > LIMIT) {
91
+ if (allowRationalOnly) return '';
92
+ throw new TypeError('precision_wire_policy_required');
93
+ }
94
+ const n = a.n * (10n ** BigInt(places) / a.d);
95
+ const digits = (n < 0n ? -n : n).toString().padStart(places + 1, '0');
96
+ const text = places
97
+ ? `${digits.slice(0, -places)}.${digits.slice(-places)}`.replace(/0+$/, '').replace(/\.$/, '')
98
+ : digits;
99
+ return (n < 0n ? '-' : '') + text;
100
+ }
101
+ // Protobuf transports decimal strings, not JavaScript numbers. Repeating values
102
+ // use the existing exact numerator/denominator; only game formats the wire response.
103
+ function serializeWire(a, policy) {
104
+ if (policy !== 'exact_json_v1') throw new TypeError('precision_wire_policy_required');
105
+ return exactDecimal(a, true);
106
+ }
107
+
108
+ module.exports = {
109
+ rational,
110
+ decimal,
111
+ add,
112
+ neg,
113
+ mul,
114
+ div,
115
+ floor,
116
+ minor,
117
+ post,
118
+ convert,
119
+ project,
120
+ exactDecimal,
121
+ serializeWire,
122
+ roundHalfUp,
123
+ };
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ const { createHash } = require('node:crypto');
4
+ const A = require('./financialAdmissionMath');
5
+ const failure = (code) => Object.assign(new TypeError(code), { code });
6
+ const sequence = (v) => typeof v === 'string' && /^(0|[1-9]\d{0,35})$/.test(v);
7
+ const minor = (v) => typeof v === 'string' && v !== '-0' && /^-?(0|[1-9]\d{0,35})$/.test(v);
8
+ const id = (v) => typeof v === 'string' && v.length > 0 && v.length <= 200 && !/[\x00-\x1f\x7f]/.test(v);
9
+ const fingerprint = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
10
+
11
+ function encode(value, depth = 0) {
12
+ if (depth > 32) throw failure('financial_admission_canonical_invalid');
13
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
14
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return JSON.stringify(value);
15
+ if (!value || typeof value !== 'object') throw failure('financial_admission_canonical_invalid');
16
+ if (Array.isArray(value)) {
17
+ for (let i = 0; i < value.length; i++) if (!Object.hasOwn(value, i)) throw failure('financial_admission_canonical_invalid');
18
+ return `[${value.map((item) => encode(item, depth + 1)).join(',')}]`;
19
+ }
20
+ if (Object.getPrototypeOf(value) !== Object.prototype) throw failure('financial_admission_canonical_invalid');
21
+ const descriptors = Object.getOwnPropertyDescriptors(value);
22
+ if (Object.values(descriptors).some((d) => !Object.hasOwn(d, 'value')) || Object.getOwnPropertySymbols(value).length)
23
+ throw failure('financial_admission_canonical_invalid');
24
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${encode(value[key], depth + 1)}`).join(',')}}`;
25
+ }
26
+
27
+ function canonical(value) {
28
+ const text = encode(value);
29
+ if (Buffer.byteLength(text) > 262144) throw failure('financial_admission_canonical_too_large');
30
+ return text;
31
+ }
32
+
33
+ function digest(value) {
34
+ return createHash('sha256').update(canonical(value)).digest('hex');
35
+ }
36
+
37
+ function fraction(n, d) {
38
+ if (typeof n !== 'string' || typeof d !== 'string') throw failure('financial_admission_fraction_invalid');
39
+ const r = A.rational(n, d);
40
+ if (String(r.n) !== n || String(r.d) !== d) throw failure('financial_admission_fraction_invalid');
41
+ return r;
42
+ }
43
+
44
+ const STATE_FIELDS = ['balanceRealMinor', 'balanceBonusMinor', 'cashRemainderNumerator',
45
+ 'cashRemainderDenominator', 'cashPrecisionVersion', 'bonusRemainderNumerator',
46
+ 'bonusRemainderDenominator', 'bonusPrecisionVersion'];
47
+
48
+ function state(value) {
49
+ if (!value || !minor(value.balanceRealMinor) || !minor(value.balanceBonusMinor))
50
+ throw failure('financial_admission_state_invalid');
51
+ for (const prefix of ['cash', 'bonus']) {
52
+ const r = fraction(value[`${prefix}RemainderNumerator`], value[`${prefix}RemainderDenominator`]);
53
+ if (!sequence(value[`${prefix}PrecisionVersion`]) || (prefix === 'cash' && (r.n < 0n || r.n >= r.d)))
54
+ throw failure('financial_admission_state_invalid');
55
+ }
56
+ return Object.fromEntries(STATE_FIELDS.map((field) => [field, value[field]]));
57
+ }
58
+
59
+ /**
60
+ * Shared exact arithmetic for normalized, already-authorized accounting legs.
61
+ * This does not authorize allocations, sessions, reversals, bonus attribution or
62
+ * RG. Payment must independently validate those facts before settlement. No
63
+ * caller should treat a matching SHA-256 fingerprint as an authorization token.
64
+ */
65
+ function preparePlan(input) {
66
+ if (!input || input.protocolVersion !== 1 || !id(input.scope) ||
67
+ typeof input.walletReference !== 'string' || typeof input.ownershipEpoch !== 'string' ||
68
+ !/^[a-zA-Z0-9_-]{1,128}$/.test(input.walletReference || '') ||
69
+ !/^[a-zA-Z0-9_-]{1,128}$/.test(input.ownershipEpoch || '') ||
70
+ !sequence(input.previousWalletSequence) || !id(input.sessionId) ||
71
+ !id(input.commandId) || !fingerprint(input.requestFingerprint) ||
72
+ !fingerprint(input.contextFingerprint) || !id(input.currency) ||
73
+ !Number.isInteger(input.currencyScale) || input.currencyScale < 0 || input.currencyScale > 255 ||
74
+ !Array.isArray(input.legs) || !input.legs.length || input.legs.length > 32)
75
+ throw failure('financial_admission_plan_invalid');
76
+ const before = state(input.before);
77
+ const after = { ...before };
78
+ const legs = [];
79
+ const seen = new Set();
80
+ const originals = new Set();
81
+ let requestedDebit = 0n;
82
+ let creditSeen = false;
83
+ for (const leg of input.legs) {
84
+ if (!leg || !['bet', 'win', 'refund', 'reversal', 'adjustment', 'end_round'].includes(leg.action) ||
85
+ !id(leg.transactionId)) throw failure('financial_admission_leg_invalid');
86
+ const key = canonical([leg.action, leg.transactionId]);
87
+ if (seen.has(key)) throw failure('financial_admission_duplicate_leg');
88
+ seen.add(key);
89
+ if (leg.action === 'bet' && creditSeen) throw failure('financial_admission_bet_after_credit');
90
+ if (['win', 'refund'].includes(leg.action)) creditSeen = true;
91
+ if (leg.action === 'reversal' && (!id(leg.originalTransactionId) ||
92
+ !['bet', 'win', 'refund', 'adjustment'].includes(leg.originalAction)))
93
+ throw failure('financial_admission_original_required');
94
+ if (leg.action === 'reversal') {
95
+ const original = canonical([leg.originalAction, leg.originalTransactionId]);
96
+ if (originals.has(original)) throw failure('financial_admission_duplicate_original');
97
+ originals.add(original);
98
+ }
99
+ const planned = { action: leg.action, transactionId: leg.transactionId,
100
+ originalTransactionId: leg.action === 'reversal' ? leg.originalTransactionId : '',
101
+ originalAction: leg.action === 'reversal' ? leg.originalAction : '' };
102
+ for (const [bucket, balanceField] of [['cash', 'balanceRealMinor'], ['bonus', 'balanceBonusMinor']]) {
103
+ const delta = fraction(leg[`${bucket}DeltaNumerator`], leg[`${bucket}DeltaDenominator`]);
104
+ if ((leg.action === 'bet' && delta.n > 0n) ||
105
+ (['win', 'refund'].includes(leg.action) && delta.n < 0n) ||
106
+ (leg.action === 'end_round' && delta.n !== 0n))
107
+ throw failure('financial_admission_leg_sign_invalid');
108
+ const c = A.minor(after[balanceField]);
109
+ const r = fraction(after[`${bucket}RemainderNumerator`], after[`${bucket}RemainderDenominator`]);
110
+ // Preserve existing precision accounting: debit reversals/adjustments need
111
+ // funds too; a compound credit cannot fund an earlier full BET.
112
+ if (delta.n < 0n && A.add(A.add(A.rational(c), r), delta).n < 0n)
113
+ throw failure('financial_admission_insufficient_funds');
114
+ const next = delta.n === 0n ? { integerDelta: 0n, balance: c, remainder: r } : A.post(c, r, delta);
115
+ const dr = A.add(next.remainder, A.neg(r));
116
+ after[balanceField] = String(next.balance);
117
+ after[`${bucket}RemainderNumerator`] = String(next.remainder.n);
118
+ after[`${bucket}RemainderDenominator`] = String(next.remainder.d);
119
+ after[`${bucket}PrecisionVersion`] = String(BigInt(after[`${bucket}PrecisionVersion`]) + (dr.n === 0n ? 0n : 1n));
120
+ if (!sequence(after[`${bucket}PrecisionVersion`])) throw failure('financial_admission_version_exhausted');
121
+ // Existing RG eligibility is based on actual real-wallet debit, not bonus.
122
+ // Freespin/free-bet/session eligibility still belongs to the caller's
123
+ // established policy checks, independently verified by Payment.
124
+ if (leg.action === 'bet' && bucket === 'cash') requestedDebit -= next.integerDelta;
125
+ Object.assign(planned, { [`${bucket}DeltaNumerator`]: String(delta.n), [`${bucket}DeltaDenominator`]: String(delta.d),
126
+ [`${bucket}IntegerDeltaMinor`]: String(next.integerDelta),
127
+ [`${bucket}RemainderDeltaNumerator`]: String(dr.n), [`${bucket}RemainderDeltaDenominator`]: String(dr.d) });
128
+ }
129
+ legs.push(planned);
130
+ }
131
+ const walletSequence = String(BigInt(input.previousWalletSequence) + 1n);
132
+ if (!sequence(walletSequence) || !sequence(String(requestedDebit))) throw failure('financial_admission_version_exhausted');
133
+ const plan = { protocolVersion: 1, scope: input.scope, walletReference: input.walletReference,
134
+ ownershipEpoch: input.ownershipEpoch, previousWalletSequence: input.previousWalletSequence,
135
+ walletSequence, sessionId: input.sessionId, commandId: input.commandId,
136
+ requestFingerprint: input.requestFingerprint, contextFingerprint: input.contextFingerprint,
137
+ currency: input.currency, currencyScale: input.currencyScale, before, after, legs,
138
+ requestedDebitMinor: String(requestedDebit) };
139
+ return { plan, fingerprint: digest(['financial-admission-plan-v1', plan]) };
140
+ }
141
+
142
+ function verifyPlan(plan, expectedFingerprint) {
143
+ if (!fingerprint(expectedFingerprint)) throw failure('financial_admission_plan_fingerprint_invalid');
144
+ const computed = preparePlan(plan);
145
+ if (canonical(computed.plan) !== canonical(plan) || computed.fingerprint !== expectedFingerprint)
146
+ throw failure('financial_admission_plan_conflict');
147
+ return computed.plan;
148
+ }
149
+
150
+ module.exports = { canonical, digest, preparePlan, verifyPlan, state, STATE_FIELDS };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "b2b-platform-utils",
3
- "version": "1.1.97",
3
+ "version": "1.1.99",
4
4
  "description": "Shared utilities for Node.js microservices: errors map, local cache, logger, numbers, dates, filesystem, media optimization, paginator, slugger, crypto wrapper, sanitize HTML, sorting.",
5
5
  "type": "commonjs",
6
6
  "license": "KingSizer",
7
7
  "private": false,
8
8
  "scripts": {
9
9
  "publish-update": "npm version patch --no-git-tag-version && npm publish",
10
- "test": "node test/slugger.test.js && node test/lifecycle.test.js"
10
+ "test": "node test/slugger.test.js && node test/lifecycle.test.js && node test/financialAdmissionPlan.test.js"
11
11
  },
12
12
  "files": [
13
13
  "*.js",