@kasufinance/kasu-sdk 2.4.2 → 2.5.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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -253
  3. package/dist/bundle.cjs.js +4775 -1794
  4. package/dist/bundle.esm.js +4764 -1816
  5. package/dist/domain/deposit-bounds.d.ts +62 -0
  6. package/dist/domain/deposit-bounds.js +84 -0
  7. package/dist/domain/deposit-bounds.js.map +1 -0
  8. package/dist/domain/deposit-bounds.test.js +135 -0
  9. package/dist/domain/deposit-bounds.test.js.map +1 -0
  10. package/dist/domain/index.d.ts +26 -0
  11. package/dist/domain/index.js +23 -0
  12. package/dist/domain/index.js.map +1 -0
  13. package/dist/domain/partners.d.ts +34 -0
  14. package/dist/domain/partners.js +43 -0
  15. package/dist/domain/partners.js.map +1 -0
  16. package/dist/domain/partners.test.d.ts +1 -0
  17. package/dist/domain/partners.test.js +53 -0
  18. package/dist/domain/partners.test.js.map +1 -0
  19. package/dist/domain/pools.d.ts +70 -0
  20. package/dist/domain/pools.js +134 -0
  21. package/dist/domain/pools.js.map +1 -0
  22. package/dist/domain/pools.test.d.ts +1 -0
  23. package/dist/domain/pools.test.js +184 -0
  24. package/dist/domain/pools.test.js.map +1 -0
  25. package/dist/domain/rates.d.ts +83 -0
  26. package/dist/domain/rates.js +120 -0
  27. package/dist/domain/rates.js.map +1 -0
  28. package/dist/domain/rates.test.d.ts +1 -0
  29. package/dist/domain/rates.test.js +181 -0
  30. package/dist/domain/rates.test.js.map +1 -0
  31. package/dist/domain/tranche-display-name.d.ts +17 -0
  32. package/dist/domain/tranche-display-name.js +30 -0
  33. package/dist/domain/tranche-display-name.js.map +1 -0
  34. package/dist/domain/tranche-display-name.test.d.ts +1 -0
  35. package/dist/domain/tranche-display-name.test.js +58 -0
  36. package/dist/domain/tranche-display-name.test.js.map +1 -0
  37. package/dist/domain/tranches.d.ts +89 -0
  38. package/dist/domain/tranches.js +150 -0
  39. package/dist/domain/tranches.js.map +1 -0
  40. package/dist/domain/tranches.test.d.ts +1 -0
  41. package/dist/domain/tranches.test.js +206 -0
  42. package/dist/domain/tranches.test.js.map +1 -0
  43. package/dist/facade/chain-configs.js +54 -0
  44. package/dist/facade/chain-configs.js.map +1 -1
  45. package/dist/facade/config.test.d.ts +1 -0
  46. package/dist/facade/config.test.js +216 -0
  47. package/dist/facade/config.test.js.map +1 -0
  48. package/dist/facade/deposits.d.ts +22 -1
  49. package/dist/facade/deposits.js +24 -1
  50. package/dist/facade/deposits.js.map +1 -1
  51. package/dist/facade/facade.test.d.ts +1 -0
  52. package/dist/{tests → facade}/facade.test.js +10 -5
  53. package/dist/facade/facade.test.js.map +1 -0
  54. package/dist/facade/index.d.ts +2 -1
  55. package/dist/facade/index.js +2 -0
  56. package/dist/facade/index.js.map +1 -1
  57. package/dist/facade/kasu.d.ts +49 -8
  58. package/dist/facade/kasu.js +117 -13
  59. package/dist/facade/kasu.js.map +1 -1
  60. package/dist/facade/strategies.d.ts +25 -0
  61. package/dist/facade/strategies.js +36 -0
  62. package/dist/facade/strategies.js.map +1 -1
  63. package/dist/facade/types.d.ts +57 -2
  64. package/dist/facade/unused-pool-ids.d.ts +31 -0
  65. package/dist/facade/unused-pool-ids.js +57 -0
  66. package/dist/facade/unused-pool-ids.js.map +1 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +5 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/sdk-config.d.ts +11 -0
  71. package/dist/sdk-config.js +8 -1
  72. package/dist/sdk-config.js.map +1 -1
  73. package/dist/services/DataService/data-service.d.ts +5 -0
  74. package/dist/services/DataService/data-service.js +7 -2
  75. package/dist/services/DataService/data-service.js.map +1 -1
  76. package/dist/services/Locking/calculate-apy.test.d.ts +1 -0
  77. package/dist/services/Locking/calculate-apy.test.js +41 -0
  78. package/dist/services/Locking/calculate-apy.test.js.map +1 -0
  79. package/dist/services/Locking/locking.d.ts +14 -0
  80. package/dist/services/Locking/locking.js +16 -2
  81. package/dist/services/Locking/locking.js.map +1 -1
  82. package/dist/tests/sample.test.js +46 -25
  83. package/dist/tests/sample.test.js.map +1 -1
  84. package/package.json +43 -12
  85. package/src/domain/deposit-bounds.test.ts +200 -0
  86. package/src/domain/deposit-bounds.ts +118 -0
  87. package/src/domain/index.ts +70 -0
  88. package/src/domain/partners.test.ts +83 -0
  89. package/src/domain/partners.ts +66 -0
  90. package/src/domain/pools.test.ts +260 -0
  91. package/src/domain/pools.ts +150 -0
  92. package/src/domain/rates.test.ts +254 -0
  93. package/src/domain/rates.ts +120 -0
  94. package/src/domain/tranche-display-name.test.ts +96 -0
  95. package/src/domain/tranche-display-name.ts +34 -0
  96. package/src/domain/tranches.test.ts +300 -0
  97. package/src/domain/tranches.ts +177 -0
  98. package/src/facade/chain-configs.ts +55 -0
  99. package/src/facade/config.test.ts +265 -0
  100. package/src/facade/deposits.ts +25 -0
  101. package/src/{tests → facade}/facade.test.ts +10 -5
  102. package/src/facade/index.ts +4 -0
  103. package/src/facade/kasu.ts +155 -12
  104. package/src/facade/strategies.ts +36 -0
  105. package/src/facade/types.ts +60 -2
  106. package/src/facade/unused-pool-ids.ts +59 -0
  107. package/src/index.ts +6 -0
  108. package/src/sdk-config.ts +19 -1
  109. package/src/services/DataService/data-service.ts +7 -2
  110. package/src/services/Locking/calculate-apy.test.ts +52 -0
  111. package/src/services/Locking/locking.ts +16 -2
  112. package/src/tests/sample.test.ts +54 -33
  113. package/.eslintignore +0 -3
  114. package/.eslintrc.json +0 -90
  115. package/.github/workflows/ci.yml +0 -39
  116. package/.github/workflows/gitleaks.yml +0 -22
  117. package/.github/workflows/main.yml +0 -41
  118. package/.gitleaksignore +0 -19
  119. package/.prettierignore +0 -3
  120. package/.prettierrc +0 -28
  121. package/CLAUDE.md +0 -410
  122. package/copy-abi.ps1 +0 -15
  123. package/dist/tests/facade.test.js.map +0 -1
  124. package/index.ts +0 -2
  125. package/jest-config.ts +0 -9
  126. package/jest.config.js +0 -8
  127. package/rollup.config.js +0 -45
  128. package/tsconfig.json +0 -27
  129. /package/dist/{tests/facade.test.d.ts → domain/deposit-bounds.test.d.ts} +0 -0
@@ -0,0 +1,200 @@
1
+ import * as fc from 'fast-check';
2
+
3
+ import { TrancheData } from '../services/DataService/types';
4
+
5
+ import {
6
+ ceilToCents,
7
+ floorToCents,
8
+ isBelowMinimumCapacity,
9
+ MAX_LENDING_AMOUNT_FALLBACK,
10
+ MIN_LENDING_AMOUNT_FALLBACK,
11
+ parseTrancheBound,
12
+ resolveBoundShortcuts,
13
+ resolveDepositBounds,
14
+ } from './deposit-bounds';
15
+
16
+ /**
17
+ * Ported from kasu-ui `src/features/lending/lib/tranche-bounds.property.test.ts`,
18
+ * with the `resolveDepositBounds` / `parseTrancheBound` cases the property file
19
+ * did not cover.
20
+ *
21
+ * The Min/Max shortcuts (clickable bound labels on a lend form) must never
22
+ * write an amount the form rejects, so the snapping is checked as invariants
23
+ * over the whole input domain rather than at a few hand-picked values.
24
+ */
25
+
26
+ function tranche(over: Partial<TrancheData>): TrancheData {
27
+ return {
28
+ minimumDeposit: '500',
29
+ maximumDeposit: '500000',
30
+ poolCapacity: '250000',
31
+ ...over,
32
+ } as unknown as TrancheData;
33
+ }
34
+
35
+ const amountArb = fc.double({
36
+ min: 0,
37
+ max: 1e12,
38
+ noNaN: true,
39
+ noDefaultInfinity: true,
40
+ });
41
+ /** `String(k / 100)` prints at most two decimals for any integer `k` in range. */
42
+ const AT_MOST_TWO_DP = /^\d+(\.\d{1,2})?$/;
43
+
44
+ describe('parseTrancheBound', () => {
45
+ it('accepts a positive finite bound from a string or a number', () => {
46
+ expect(parseTrancheBound('500')).toBe(500);
47
+ expect(parseTrancheBound(500)).toBe(500);
48
+ });
49
+
50
+ it('rejects anything that is not a usable bound', () => {
51
+ expect(parseTrancheBound(null)).toBeNull();
52
+ expect(parseTrancheBound(undefined)).toBeNull();
53
+ expect(parseTrancheBound('')).toBeNull();
54
+ expect(parseTrancheBound('0')).toBeNull();
55
+ expect(parseTrancheBound('-1')).toBeNull();
56
+ expect(parseTrancheBound('not-a-number')).toBeNull();
57
+ expect(parseTrancheBound(Infinity)).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe('resolveDepositBounds', () => {
62
+ it('clamps the max by the tranche’s REMAINING capacity', () => {
63
+ // A tranche can fill mid-epoch while `maximumDeposit` stays put.
64
+ expect(
65
+ resolveDepositBounds(
66
+ tranche({ maximumDeposit: '500000', poolCapacity: '25285.86' }),
67
+ ),
68
+ ).toEqual({ minDeposit: 500, maxDeposit: 25285.86 });
69
+ });
70
+
71
+ it('keeps the configured max when capacity is larger', () => {
72
+ expect(
73
+ resolveDepositBounds(
74
+ tranche({ maximumDeposit: '500000', poolCapacity: '900000' }),
75
+ ),
76
+ ).toEqual({ minDeposit: 500, maxDeposit: 500000 });
77
+ });
78
+
79
+ it('falls back when a "coming soon" pool has no bounds configured', () => {
80
+ expect(
81
+ resolveDepositBounds(
82
+ tranche({
83
+ minimumDeposit: '0',
84
+ maximumDeposit: '0',
85
+ poolCapacity: '900000',
86
+ }),
87
+ ),
88
+ ).toEqual({
89
+ minDeposit: MIN_LENDING_AMOUNT_FALLBACK,
90
+ maxDeposit: MAX_LENDING_AMOUNT_FALLBACK,
91
+ });
92
+ });
93
+
94
+ it('uses both fallbacks and skips the capacity clamp with no tranche at all', () => {
95
+ expect(resolveDepositBounds(undefined)).toEqual({
96
+ minDeposit: MIN_LENDING_AMOUNT_FALLBACK,
97
+ maxDeposit: MAX_LENDING_AMOUNT_FALLBACK,
98
+ });
99
+ });
100
+
101
+ it('treats unparseable or negative capacity as zero', () => {
102
+ expect(
103
+ resolveDepositBounds(tranche({ poolCapacity: 'not-a-number' }))
104
+ .maxDeposit,
105
+ ).toBe(0);
106
+ expect(
107
+ resolveDepositBounds(tranche({ poolCapacity: '-5' })).maxDeposit,
108
+ ).toBe(0);
109
+ });
110
+ });
111
+
112
+ describe('cent snapping', () => {
113
+ it('floorToCents never exceeds its input, stays within a cent of it, and has ≤ 2dp', () => {
114
+ fc.assert(
115
+ fc.property(amountArb, (n) => {
116
+ const snapped = floorToCents(n);
117
+ expect(snapped).toBeLessThanOrEqual(n);
118
+ expect(n - snapped).toBeLessThan(0.02);
119
+ expect(String(snapped)).toMatch(AT_MOST_TWO_DP);
120
+ }),
121
+ );
122
+ });
123
+
124
+ it('ceilToCents never undershoots its input, stays within a cent of it, and has ≤ 2dp', () => {
125
+ fc.assert(
126
+ fc.property(amountArb, (n) => {
127
+ const snapped = ceilToCents(n);
128
+ expect(snapped).toBeGreaterThanOrEqual(n);
129
+ expect(snapped - n).toBeLessThan(0.02);
130
+ expect(String(snapped)).toMatch(AT_MOST_TWO_DP);
131
+ }),
132
+ );
133
+ });
134
+ });
135
+
136
+ describe('resolveBoundShortcuts', () => {
137
+ it('keeps both shortcuts inside the validated range whenever they are enabled', () => {
138
+ fc.assert(
139
+ fc.property(amountArb, amountArb, (minDeposit, maxDeposit) => {
140
+ const { min, max, enabled } = resolveBoundShortcuts(
141
+ minDeposit,
142
+ maxDeposit,
143
+ );
144
+ if (!enabled) return;
145
+ expect(min).toBeGreaterThanOrEqual(minDeposit);
146
+ expect(max).toBeLessThanOrEqual(maxDeposit);
147
+ expect(max).toBeGreaterThanOrEqual(min);
148
+ expect(max).toBeGreaterThan(0);
149
+ }),
150
+ );
151
+ });
152
+
153
+ it('disables the pair whenever the snapped range is empty', () => {
154
+ // Tranche at capacity (max clamps to 0), sub-cent dust the SDK's float
155
+ // subtraction leaves behind, remaining capacity below the tranche
156
+ // minimum, and an AU floor above the capacity.
157
+ expect(resolveBoundShortcuts(500, 0).enabled).toBe(false);
158
+ expect(
159
+ resolveBoundShortcuts(500, 1.4551915228366852e-11).enabled,
160
+ ).toBe(false);
161
+ expect(resolveBoundShortcuts(500, 137).enabled).toBe(false);
162
+ expect(resolveBoundShortcuts(240_000, 100_000).enabled).toBe(false);
163
+ });
164
+
165
+ it('snaps SDK float noise onto cents without crossing the bound', () => {
166
+ expect(resolveBoundShortcuts(500, 249999.99999999997)).toEqual({
167
+ min: 500,
168
+ max: 249999.99,
169
+ enabled: true,
170
+ });
171
+ expect(resolveBoundShortcuts(500, 25285.86)).toEqual({
172
+ min: 500,
173
+ max: 25285.86,
174
+ enabled: true,
175
+ });
176
+ // An AU remainder with more precision than the field shows rounds UP.
177
+ expect(resolveBoundShortcuts(358765.432109, 500_000).min).toBe(
178
+ 358765.44,
179
+ );
180
+ });
181
+ });
182
+
183
+ describe('isBelowMinimumCapacity', () => {
184
+ it('is exactly the negation of the shortcuts being enabled', () => {
185
+ fc.assert(
186
+ fc.property(amountArb, amountArb, (minDeposit, maxDeposit) => {
187
+ // The "nothing to click" and "nothing to type" states must
188
+ // never be able to disagree.
189
+ expect(isBelowMinimumCapacity(minDeposit, maxDeposit)).toBe(
190
+ !resolveBoundShortcuts(minDeposit, maxDeposit).enabled,
191
+ );
192
+ }),
193
+ );
194
+ });
195
+
196
+ it('flags a capacity that has fallen under the tranche minimum', () => {
197
+ expect(isBelowMinimumCapacity(500, 137)).toBe(true);
198
+ expect(isBelowMinimumCapacity(500, 25285.86)).toBe(false);
199
+ });
200
+ });
@@ -0,0 +1,118 @@
1
+ import { TrancheData } from '../services/DataService/types';
2
+
3
+ // Fallbacks if the selected tranche's on-chain min/max are 0 / NaN (some
4
+ // "coming soon" pools haven't been configured yet).
5
+ export const MIN_LENDING_AMOUNT_FALLBACK = 500;
6
+ export const MAX_LENDING_AMOUNT_FALLBACK = 500_000;
7
+
8
+ export function parseTrancheBound(
9
+ raw: string | number | null | undefined,
10
+ ): number | null {
11
+ if (raw == null) return null;
12
+ const n = typeof raw === 'string' ? parseFloat(raw) : raw;
13
+ return Number.isFinite(n) && n > 0 ? n : null;
14
+ }
15
+
16
+ export interface DepositBounds {
17
+ minDeposit: number;
18
+ maxDeposit: number;
19
+ }
20
+
21
+ /**
22
+ * Min + max lending amount (in whole stable units) for a tranche. The max is
23
+ * clamped by the tranche's *remaining* capacity: a tranche can fill mid-epoch
24
+ * while `maximumDeposit` stays at its configured value, so without the clamp
25
+ * a form would accept an amount the contract will reject. Mirrors
26
+ * kasu-fe-next's `calculateDepositMinMax` (`trancheMax = min(max, capacity)`).
27
+ */
28
+ export function resolveDepositBounds(
29
+ tranche: TrancheData | undefined,
30
+ ): DepositBounds {
31
+ const minDeposit =
32
+ parseTrancheBound(tranche?.minimumDeposit) ??
33
+ MIN_LENDING_AMOUNT_FALLBACK;
34
+ const configuredMax =
35
+ parseTrancheBound(tranche?.maximumDeposit) ??
36
+ MAX_LENDING_AMOUNT_FALLBACK;
37
+ if (!tranche) return { minDeposit, maxDeposit: configuredMax };
38
+ const remainingCapacity = Math.max(
39
+ 0,
40
+ parseFloat(tranche.poolCapacity) || 0,
41
+ );
42
+ return { minDeposit, maxDeposit: Math.min(configuredMax, remainingCapacity) };
43
+ }
44
+
45
+ export interface BoundShortcuts {
46
+ min: number;
47
+ max: number;
48
+ enabled: boolean;
49
+ }
50
+
51
+ /**
52
+ * The values a lend form's clickable Min/Max labels write into the amount
53
+ * field. Derived from the validated bounds but snapped to whole cents
54
+ * CONSERVATIVELY — the min rounds UP, the max rounds DOWN — so a shortcut can
55
+ * never fill an amount the form's own `min ≤ amount ≤ max` check rejects, and
56
+ * never carries the SDK's float noise into the field (`targetDrawAmount -
57
+ * pendingDeposits` routinely yields `249999.99999999997`, which would make the
58
+ * label, the field's 2dp display and the signed request label all disagree).
59
+ *
60
+ * `enabled` is false when the snapped range is empty: a tranche at capacity
61
+ * (max clamps to 0 or to sub-cent dust), a remaining capacity below the
62
+ * tranche minimum, or a jurisdiction floor above the capacity. Nothing either
63
+ * shortcut could write would validate, so both render inert.
64
+ */
65
+ export function resolveBoundShortcuts(
66
+ minDeposit: number,
67
+ maxDeposit: number,
68
+ ): BoundShortcuts {
69
+ const min = ceilToCents(minDeposit);
70
+ const max = floorToCents(maxDeposit);
71
+ const enabled =
72
+ Number.isFinite(min) && Number.isFinite(max) && max > 0 && max >= min;
73
+ return { min, max, enabled };
74
+ }
75
+
76
+ /**
77
+ * True when no amount exists that satisfies both bounds — the minimum sits
78
+ * ABOVE the maximum, so the range a form would otherwise print ("Min 360,000 ·
79
+ * Max 25,285") is a contradiction, not an instruction. Happens when a
80
+ * tranche's remaining capacity falls under its own configured minimum, when it
81
+ * is at capacity (max 0 / sub-cent dust), or when the AU wholesale cumulative
82
+ * floor lands above the capacity that is left.
83
+ *
84
+ * Defined as the negation of `resolveBoundShortcuts().enabled` on purpose: the
85
+ * "there is nothing to click" and "there is nothing to type" states must never
86
+ * be able to disagree.
87
+ *
88
+ * This is UX only. The binding minimum is enforced by kasu-backend (403
89
+ * `AU_WHOLESALE_MINIMUM_NOT_MET`) and by the contract's own bounds; nothing
90
+ * here may be used to relax either.
91
+ */
92
+ export function isBelowMinimumCapacity(
93
+ minDeposit: number,
94
+ maxDeposit: number,
95
+ ): boolean {
96
+ return !resolveBoundShortcuts(minDeposit, maxDeposit).enabled;
97
+ }
98
+
99
+ const CENTS = 100;
100
+
101
+ /**
102
+ * Largest whole-cent value ≤ `n`. Float multiplication can round `n * 100` UP
103
+ * onto an integer (`1.15 * 100 === 114.99999999999999` is the usual direction,
104
+ * but the other happens too), so the result is re-checked against `n` and
105
+ * stepped down a cent if it overshot — the contract is "never above `n`".
106
+ */
107
+ export function floorToCents(n: number): number {
108
+ const k = Math.floor(n * CENTS);
109
+ const snapped = k / CENTS;
110
+ return snapped > n ? (k - 1) / CENTS : snapped;
111
+ }
112
+
113
+ /** Smallest whole-cent value ≥ `n`. Mirror of `floorToCents`: never below `n`. */
114
+ export function ceilToCents(n: number): number {
115
+ const k = Math.ceil(n * CENTS);
116
+ const snapped = k / CENTS;
117
+ return snapped < n ? (k + 1) / CENTS : snapped;
118
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The shared domain layer: the rules every Kasu frontend needs to agree on,
3
+ * in one place instead of three.
4
+ *
5
+ * Two rules govern what may live here.
6
+ *
7
+ * 1. **Numbers and codes only.** No copy, no locale, no `Intl`, no string a
8
+ * user reads — `netEffectiveApy` returns `0.196`, never `'19.60% p.a.'`,
9
+ * and `derivePoolStatus` returns the code `'Full'`, not a sentence. The
10
+ * formatters that turn these into text stay in each app, where the design
11
+ * system and the visitor's locale are. The one exception is
12
+ * `getTrancheDisplayName`, which exists precisely because that rename must
13
+ * NOT drift between apps.
14
+ * 2. **Pure.** No network, no clock, no environment. Anything with I/O belongs
15
+ * in `facade/` (see `fetchUnusedPoolIds`).
16
+ */
17
+ export {
18
+ apyToEpochRate,
19
+ epochRateToApy,
20
+ EPOCHS_IN_YEAR,
21
+ netEffectiveApy,
22
+ } from './rates';
23
+
24
+ export {
25
+ ceilToCents,
26
+ floorToCents,
27
+ isBelowMinimumCapacity,
28
+ MAX_LENDING_AMOUNT_FALLBACK,
29
+ MIN_LENDING_AMOUNT_FALLBACK,
30
+ parseTrancheBound,
31
+ resolveBoundShortcuts,
32
+ resolveDepositBounds,
33
+ } from './deposit-bounds';
34
+ export type { BoundShortcuts, DepositBounds } from './deposit-bounds';
35
+
36
+ export {
37
+ APXIUM,
38
+ getCreditOriginator,
39
+ getInstitutionalLender,
40
+ INVOICEMATE,
41
+ RIXON_CAPITAL,
42
+ } from './partners';
43
+ export type { PoolNameSignal, StrategyPartner } from './partners';
44
+
45
+ export {
46
+ maxNetRateCeiling,
47
+ pickHighestYieldTranche,
48
+ poolMaxApy,
49
+ selectVisiblePools,
50
+ } from './pools';
51
+ export type { BestTranche } from './pools';
52
+
53
+ export { getTrancheDisplayName, UPPER_MEZZANINE } from './tranche-display-name';
54
+
55
+ export {
56
+ compareTrancheSeniority,
57
+ derivePoolStatus,
58
+ MIN_TRANCHE_CAPACITY,
59
+ netTrancheApyBounds,
60
+ pickDefaultTrancheId,
61
+ poolAllTranchesFull,
62
+ trancheApyBounds,
63
+ trancheHasCapacity,
64
+ trancheRiskRank,
65
+ } from './tranches';
66
+ export type {
67
+ ApyBounds,
68
+ PoolStatus,
69
+ TrancheCapacitySignal,
70
+ } from './tranches';
@@ -0,0 +1,83 @@
1
+ import {
2
+ APXIUM,
3
+ getCreditOriginator,
4
+ getInstitutionalLender,
5
+ INVOICEMATE,
6
+ RIXON_CAPITAL,
7
+ } from './partners';
8
+
9
+ // Ported from kasu-ui `src/features/lending/lib/strategy-partners.test.ts`.
10
+
11
+ describe('getCreditOriginator', () => {
12
+ it('maps "Payment Finance (PayFi)" to InvoiceMate', () => {
13
+ expect(getCreditOriginator({ poolName: 'Payment Finance (PayFi)' })).toBe(
14
+ INVOICEMATE,
15
+ );
16
+ });
17
+
18
+ it('keeps Apxium receivables pools as Apxium (regression: do not match "invoice")', () => {
19
+ // The bug: matching the asset class ("invoice financing") wrongly
20
+ // classified Apxium receivables pools as InvoiceMate. Name-only
21
+ // matching fixes it — none of these names contain payfi/payment
22
+ // finance.
23
+ expect(getCreditOriginator({ poolName: 'Professional Fee Funding' })).toBe(
24
+ APXIUM,
25
+ );
26
+ expect(getCreditOriginator({ poolName: 'Whole Ledger Funding' })).toBe(
27
+ APXIUM,
28
+ );
29
+ expect(
30
+ getCreditOriginator({ poolName: 'Taxation Funding (Tax Pay)' }),
31
+ ).toBe(APXIUM);
32
+ });
33
+
34
+ it('is case-insensitive and matches the "payfi" / "payment finance" name', () => {
35
+ expect(getCreditOriginator({ poolName: 'payment finance' })).toBe(
36
+ INVOICEMATE,
37
+ );
38
+ expect(getCreditOriginator({ poolName: 'PAYFI Strategy' })).toBe(
39
+ INVOICEMATE,
40
+ );
41
+ });
42
+
43
+ it('defaults to Apxium for empty / missing names', () => {
44
+ expect(getCreditOriginator({ poolName: '' })).toBe(APXIUM);
45
+ expect(getCreditOriginator({})).toBe(APXIUM);
46
+ });
47
+ });
48
+
49
+ describe('getCreditOriginator — the widened PoolNameSignal', () => {
50
+ it('reads the facade Strategy shape (`name`)', () => {
51
+ expect(getCreditOriginator({ name: 'Payment Finance (PayFi)' })).toBe(
52
+ INVOICEMATE,
53
+ );
54
+ expect(getCreditOriginator({ name: 'Taxation Funding' })).toBe(APXIUM);
55
+ expect(getCreditOriginator({ name: null })).toBe(APXIUM);
56
+ });
57
+
58
+ it('reads a bare string', () => {
59
+ expect(getCreditOriginator('Payment Finance (PayFi)')).toBe(INVOICEMATE);
60
+ expect(getCreditOriginator('Whole Ledger Funding')).toBe(APXIUM);
61
+ expect(getCreditOriginator('')).toBe(APXIUM);
62
+ });
63
+
64
+ it('prefers poolName when an object carries both', () => {
65
+ const both = { poolName: 'Taxation Funding', name: 'Payment Finance' };
66
+ expect(getCreditOriginator(both)).toBe(APXIUM);
67
+ });
68
+
69
+ it('treats an explicitly null poolName as missing, not as a match', () => {
70
+ expect(getCreditOriginator({ poolName: null })).toBe(APXIUM);
71
+ expect(getCreditOriginator({ poolName: undefined })).toBe(APXIUM);
72
+ });
73
+ });
74
+
75
+ describe('getInstitutionalLender', () => {
76
+ it('gives Apxium pools Rixon Capital', () => {
77
+ expect(getInstitutionalLender(APXIUM)).toBe(RIXON_CAPITAL);
78
+ });
79
+
80
+ it('gives InvoiceMate pools no senior lender', () => {
81
+ expect(getInstitutionalLender(INVOICEMATE)).toBeNull();
82
+ });
83
+ });
@@ -0,0 +1,66 @@
1
+ // Credit Originator + Institutional (senior) Lender are not exposed as Directus
2
+ // or subgraph pool fields, so we derive them from the pool name. Confirmed
3
+ // mapping (2026-06): the only InvoiceMate strategy is "Payment Finance
4
+ // (PayFi)"; every other pool — Whole Ledger / Taxation / Professional Fee
5
+ // Funding — is Apxium. Apxium → Rixon Capital (senior lender); InvoiceMate →
6
+ // none. Replace this with a real Directus field if/when one ships.
7
+ export interface StrategyPartner {
8
+ name: string;
9
+ url: string;
10
+ }
11
+
12
+ export const APXIUM: StrategyPartner = {
13
+ name: 'Apxium',
14
+ url: 'https://www.apxium.com/',
15
+ };
16
+ export const INVOICEMATE: StrategyPartner = {
17
+ name: 'InvoiceMate',
18
+ url: 'https://invoicemate.net/',
19
+ };
20
+ export const RIXON_CAPITAL: StrategyPartner = {
21
+ name: 'Rixon Capital',
22
+ url: 'https://rixon.capital/',
23
+ };
24
+
25
+ /**
26
+ * Anything a pool name can arrive as. The raw `PoolOverview` carries
27
+ * `poolName`, the facade's `Strategy` carries `name`, and a caller who already
28
+ * has the string can pass it directly — one rule, whichever shape reaches it.
29
+ * `poolName` wins when an object carries both and it is set.
30
+ */
31
+ export type PoolNameSignal =
32
+ | string
33
+ | { poolName?: string | null }
34
+ | { name?: string | null };
35
+
36
+ function poolNameOf(pool: PoolNameSignal): string {
37
+ if (typeof pool === 'string') return pool;
38
+ const signal: { poolName?: string | null; name?: string | null } = pool;
39
+ return signal.poolName ?? signal.name ?? '';
40
+ }
41
+
42
+ /**
43
+ * Credit-originator inference by pool NAME only. The sole InvoiceMate
44
+ * strategy is "Payment Finance (PayFi)" (matched by "payfi" / "payment
45
+ * finance"); every other pool — Whole Ledger / Taxation / Professional Fee
46
+ * Funding — is Apxium.
47
+ *
48
+ * We deliberately do NOT match on asset class / subheading: Apxium's pools
49
+ * are receivables/invoice financing, so their asset class contains
50
+ * "invoice" and would be misclassified as InvoiceMate.
51
+ */
52
+ export function getCreditOriginator(pool: PoolNameSignal): StrategyPartner {
53
+ const name = poolNameOf(pool).toLowerCase();
54
+ if (/payfi|payment finance/.test(name)) return INVOICEMATE;
55
+ return APXIUM;
56
+ }
57
+
58
+ /**
59
+ * Apxium pools carry an institutional senior lender (Rixon Capital);
60
+ * InvoiceMate pools have none.
61
+ */
62
+ export function getInstitutionalLender(
63
+ originator: StrategyPartner,
64
+ ): StrategyPartner | null {
65
+ return originator.name === APXIUM.name ? RIXON_CAPITAL : null;
66
+ }