@provex/utils 1.11.0 → 2.0.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 (80) hide show
  1. package/dist/buy/browsePanelDerivations.d.ts +212 -0
  2. package/dist/buy/browsePanelDerivations.js +269 -0
  3. package/dist/buy/browsePanelDerivations.test.d.ts +9 -0
  4. package/dist/buy/browsePanelDerivations.test.js +552 -0
  5. package/dist/buy/fillabilityNotice.d.ts +214 -0
  6. package/dist/buy/fillabilityNotice.js +162 -0
  7. package/dist/buy/fillabilityNotice.test.d.ts +8 -0
  8. package/dist/buy/fillabilityNotice.test.js +246 -0
  9. package/dist/buy/formatCurrencyBaseUnits.d.ts +30 -0
  10. package/dist/buy/formatCurrencyBaseUnits.js +34 -0
  11. package/dist/buy/formatCurrencyBaseUnits.test.d.ts +7 -0
  12. package/dist/buy/formatCurrencyBaseUnits.test.js +34 -0
  13. package/dist/buy/fulfillTransactionHash.d.ts +29 -0
  14. package/dist/buy/fulfillTransactionHash.js +27 -0
  15. package/dist/buy/fulfillTransactionHash.test.d.ts +7 -0
  16. package/dist/buy/fulfillTransactionHash.test.js +46 -0
  17. package/dist/buy/index.d.ts +17 -0
  18. package/dist/buy/index.js +17 -0
  19. package/dist/buy/orderCap.d.ts +29 -0
  20. package/dist/buy/orderCap.js +35 -0
  21. package/dist/buy/orderCap.test.d.ts +8 -0
  22. package/dist/buy/orderCap.test.js +35 -0
  23. package/dist/buy/postFeeAmount.d.ts +39 -0
  24. package/dist/buy/postFeeAmount.js +36 -0
  25. package/dist/buy/postFeeAmount.test.d.ts +9 -0
  26. package/dist/buy/postFeeAmount.test.js +82 -0
  27. package/dist/buy/providerSelectionMode.d.ts +5 -0
  28. package/dist/buy/providerSelectionMode.js +4 -0
  29. package/dist/buy/settlementReconciliation.d.ts +48 -0
  30. package/dist/buy/settlementReconciliation.js +65 -0
  31. package/dist/buy/settlementReconciliation.test.d.ts +9 -0
  32. package/dist/buy/settlementReconciliation.test.js +164 -0
  33. package/dist/buy/submitStepDerivations.d.ts +122 -0
  34. package/dist/buy/submitStepDerivations.js +140 -0
  35. package/dist/buy/submitStepDerivations.test.d.ts +1 -0
  36. package/dist/buy/submitStepDerivations.test.js +305 -0
  37. package/dist/buy/verifiableBanks.d.ts +29 -0
  38. package/dist/buy/verifiableBanks.js +28 -0
  39. package/dist/buy/verifiableBanks.test.d.ts +4 -0
  40. package/dist/buy/verifiableBanks.test.js +31 -0
  41. package/dist/consent.js +0 -2
  42. package/dist/consent.storage.test.d.ts +8 -0
  43. package/dist/consent.storage.test.js +111 -0
  44. package/dist/consent.test.js +60 -1
  45. package/dist/contracts.js +3 -1
  46. package/dist/contracts.test.js +17 -1
  47. package/dist/conversionRates.test.js +18 -0
  48. package/dist/currencies.d.ts +6 -0
  49. package/dist/currencies.js +1 -1
  50. package/dist/currencies.test.js +29 -2
  51. package/dist/fees.js +0 -3
  52. package/dist/gating.test.js +16 -1
  53. package/dist/ids.test.js +48 -0
  54. package/dist/payment.d.ts +50 -0
  55. package/dist/payment.js +72 -51
  56. package/dist/payment.lookup.test.d.ts +5 -0
  57. package/dist/payment.lookup.test.js +169 -0
  58. package/dist/payment.providerLists.test.d.ts +12 -0
  59. package/dist/payment.providerLists.test.js +40 -0
  60. package/dist/payment.sendLinks.test.d.ts +9 -0
  61. package/dist/payment.sendLinks.test.js +91 -0
  62. package/dist/peer-types.d.ts +5 -78
  63. package/dist/peer-types.js +5 -31
  64. package/dist/peer-types.test.js +1 -31
  65. package/dist/profit.d.ts +8 -0
  66. package/dist/profit.js +16 -3
  67. package/dist/profit.test.js +12 -1
  68. package/dist/reputation.js +4 -6
  69. package/dist/reputation.test.js +8 -0
  70. package/dist/time.test.js +26 -5
  71. package/dist/tokens.d.ts +2 -2
  72. package/dist/transports.d.ts +53 -2
  73. package/dist/transports.debug.test.d.ts +9 -0
  74. package/dist/transports.debug.test.js +95 -0
  75. package/dist/transports.js +26 -10
  76. package/dist/transports.test.js +5 -1
  77. package/dist/units.d.ts +19 -0
  78. package/dist/units.js +21 -0
  79. package/dist/units.test.js +31 -1
  80. package/package.json +6 -2
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @fileoverview What the buy panel tells the buyer about provider fillability.
3
+ *
4
+ * One pure decision turns the fillability summaries (from
5
+ * `useProviderFillability`) and the buyer's state into at most one notice.
6
+ * BrowsePanel renders what this returns and decides nothing itself.
7
+ *
8
+ * The rules, from the product owner:
9
+ * 1. A zero or empty amount never shows an error. There is nothing to fill yet.
10
+ * 2. Auto mode with no amount cycles through the providers that have sellers
11
+ * (`carousel`). Display only. It ends when the buyer types an amount. With
12
+ * reduced motion, or with fewer than two providers, nothing cycles.
13
+ * 3. Auto mode with an amount follows the auto pick. The panel does not fall
14
+ * back to the volume leader when nothing fills (see `nearestAmount`).
15
+ * 4. A manual pick that cannot fill the amount is kept. If another provider
16
+ * can, offer a one-tap switch to the best-rate one (`switchProvider`).
17
+ * Never override the pick.
18
+ * 5. If no provider can fill the amount, suggest the nearest amount that works
19
+ * (`nearestAmount`). The suggestion comes from the provider the buyer is on:
20
+ * the selected one in manual mode, the one auto names otherwise. A manual
21
+ * pick never gets another provider's amount; that is the switch segment's job.
22
+ * 6. "Can fill" includes the buyer's per-order cap. A provider whose cap is
23
+ * below the amount does not count, and a suggestion never exceeds the cap.
24
+ */
25
+ import type { ProviderKey } from '../payment.js';
26
+ import type { ProviderSelectionMode } from './providerSelectionMode.js';
27
+ /**
28
+ * What the decisions below read from one provider's fillability summary.
29
+ *
30
+ * The indexer client's `ProviderFillability` satisfies this shape. It is
31
+ * declared here because the indexer client depends on this package, so this
32
+ * package cannot import its type. Deposits are opaque: the decisions only
33
+ * count them and compare them by identity.
34
+ */
35
+ export interface FillabilitySummary {
36
+ providerKey: ProviderKey;
37
+ /** Deposits that fill the buyer amount, best rate first. */
38
+ fillingDeposits: readonly unknown[];
39
+ /** One range per viable deposit, carrying the rate that deposit offers. */
40
+ fillableRanges: readonly {
41
+ deposit: unknown;
42
+ rate: bigint;
43
+ }[];
44
+ }
45
+ /**
46
+ * The closest amount a buyer could enter instead, when their amount fills
47
+ * nowhere. `raise` means the buyer must enter more, `lower` means less.
48
+ * `reason` says which limit the amount sits on.
49
+ *
50
+ * Mirrors `NearestWorkableAmount` in the indexer client, which produces it.
51
+ * A type test in the ui package fails when the two drift apart.
52
+ */
53
+ export type NearestWorkableAmount = {
54
+ direction: 'raise';
55
+ reason: 'sellerMinimum';
56
+ amountInCurrency: bigint;
57
+ providerKey: ProviderKey;
58
+ } | {
59
+ direction: 'lower';
60
+ reason: 'buyerCap' | 'sellerFunds' | 'sellerOrderMaximum';
61
+ amountInCurrency: bigint;
62
+ providerKey: ProviderKey;
63
+ };
64
+ /** At most one notice the panel shows about fillability. */
65
+ export type FillabilityNotice = {
66
+ kind: 'none';
67
+ } | {
68
+ kind: 'carousel';
69
+ providerKeys: readonly ProviderKey[];
70
+ } | {
71
+ kind: 'switchProvider';
72
+ providerKey: ProviderKey;
73
+ amountInCurrency: bigint;
74
+ } | ({
75
+ kind: 'nearestAmount';
76
+ } & NearestWorkableAmount);
77
+ export declare const NO_NOTICE: FillabilityNotice;
78
+ export interface DecideFillabilityNoticeInput {
79
+ mode: ProviderSelectionMode;
80
+ /** Buyer amount in currency base units. Null or 0n means "no amount yet". */
81
+ amountInCurrency: bigint | null;
82
+ selectedProvider: ProviderKey;
83
+ summaries: readonly FillabilitySummary[];
84
+ /**
85
+ * The closest amount the given provider can fill, or the closest across all
86
+ * providers when given null. Null result: nothing to suggest.
87
+ */
88
+ nearestWorkableAmountFor: (providerKey: ProviderKey | null) => NearestWorkableAmount | null;
89
+ /** Providers with sellers, cheapest first. */
90
+ providersWithLiquidity: readonly ProviderKey[];
91
+ prefersReducedMotion: boolean;
92
+ /** True while the first deposit fetch has not finished. */
93
+ isLoading: boolean;
94
+ /** Providers the buyer cannot use right now (cooldown). Excluded from the switch offer. */
95
+ isProviderExcluded?: (providerKey: ProviderKey) => boolean;
96
+ /** True when the amount is above the buyer's per-order cap for this provider. */
97
+ exceedsCapFor?: (providerKey: ProviderKey) => boolean;
98
+ }
99
+ /**
100
+ * The provider with the lowest rate among those that can fill the amount.
101
+ * Same order as the auto pick: rate ascending, then provider key for a stable
102
+ * result. Skips excluded providers and the provider named in `except`.
103
+ *
104
+ * @returns The provider key, or null when none can fill
105
+ */
106
+ export declare function bestRateFillingProvider({ summaries, isProviderExcluded, except, }: {
107
+ summaries: readonly FillabilitySummary[];
108
+ isProviderExcluded?: (providerKey: ProviderKey) => boolean;
109
+ except?: ProviderKey;
110
+ }): ProviderKey | null;
111
+ /** Decide the one fillability notice for the buy panel. */
112
+ export declare function decideFillabilityNotice({ mode, amountInCurrency, selectedProvider, summaries, nearestWorkableAmountFor, providersWithLiquidity, prefersReducedMotion, isLoading, isProviderExcluded, exceedsCapFor, }: DecideFillabilityNoticeInput): FillabilityNotice;
113
+ /**
114
+ * Whether the "No deposits" badge shows on the send card.
115
+ * It is an error badge, so it stays hidden when there is no amount, while
116
+ * data loads, when deposits are listed, or when a notice already explains it.
117
+ */
118
+ export declare function shouldShowNoDepositsBadge({ amountInCurrency, hasListedDeposits, isLoading, notice, }: {
119
+ amountInCurrency: bigint | null;
120
+ hasListedDeposits: boolean;
121
+ isLoading: boolean;
122
+ notice: FillabilityNotice;
123
+ }): boolean;
124
+ /** Why the pill names the provider it names. */
125
+ export type AutoProviderBasis =
126
+ /** Nothing has loaded yet: the only state without a provider. */
127
+ 'pending'
128
+ /** Deposits are in but the maker check is not: a best guess from unchecked sellers. */
129
+ | 'provisional'
130
+ /** The best-rate provider that can fill the amount, or the best-rate provider with sellers when there is no amount. */
131
+ | 'fills'
132
+ /** An amount no provider can fill: the provider the Min or Max button uses. */
133
+ | 'nearest'
134
+ /** No seller anywhere for this currency: the volume default. */
135
+ | 'volume-default';
136
+ export interface AutoProvider {
137
+ /** The provider auto shows and works with. Null only for `pending`. */
138
+ provider: ProviderKey | null;
139
+ basis: AutoProviderBasis;
140
+ }
141
+ /**
142
+ * Decide which provider auto mode shows. Auto always names a concrete provider,
143
+ * the one the buyer would actually use; the only state without one is the wait
144
+ * for the very first deposits.
145
+ *
146
+ * 1. No deposits fetched yet: `pending` (a brief skeleton).
147
+ * 2. Deposits fetched, maker check still running: `provisional`, the best-rate
148
+ * provider among UNCHECKED sellers. It names a provider early. The receive
149
+ * card stays a skeleton and no notice shows until the check ends, so nothing
150
+ * claims "no sellers" about a provider that may change; if the check removes
151
+ * the sellers, the pill moves once.
152
+ * 3. Checked, no amount: the best-rate provider with sellers (the carousel, when
153
+ * it runs, only cycles the display).
154
+ * 4. Checked, an amount some provider fills within the buyer's cap: the best-rate
155
+ * one that fills it.
156
+ * 5. Checked, an amount nothing fills: the provider of the nearest-amount
157
+ * suggestion, so the pill and the Min or Max button name the same provider.
158
+ * 6. No seller anywhere: the volume default. The receive card and the notice
159
+ * explain there are no sellers.
160
+ */
161
+ export declare function decideAutoProvider({ amountInCurrency, hasFetchedDeposits, isVerifying, isRankingPending, provisionalProvider, bestRateProvider, cheapestFillingProvider, someProviderFills, nearestWorkableAmount, providersWithLiquidity, volumeDefault, }: {
162
+ amountInCurrency: bigint | null;
163
+ /** The first cross-provider deposit fetch has finished. */
164
+ hasFetchedDeposits: boolean;
165
+ /** The maker check is still running. */
166
+ isVerifying: boolean;
167
+ /** The completion-rate ranking is still loading. */
168
+ isRankingPending: boolean;
169
+ /** Best-rate provider among unchecked sellers, or null. */
170
+ provisionalProvider: ProviderKey | null;
171
+ /** The ranked pick (best rate that clears the completion gate), or null. */
172
+ bestRateProvider: ProviderKey | null;
173
+ /** Cheapest provider that can serve the amount, ignoring the completion gate. */
174
+ cheapestFillingProvider: ProviderKey | null;
175
+ /** Some provider fills the amount within the buyer's cap. */
176
+ someProviderFills: boolean;
177
+ nearestWorkableAmount: NearestWorkableAmount | null;
178
+ /** Providers with checked sellers, cheapest first. */
179
+ providersWithLiquidity: readonly ProviderKey[];
180
+ /** The highest-volume provider that serves the currency. A concrete last resort. */
181
+ volumeDefault: ProviderKey;
182
+ }): AutoProvider;
183
+ /** What the provider pill and the "You Receive" card show while data loads. */
184
+ export interface LoadingPresentation {
185
+ /**
186
+ * `loading`: a skeleton, only before auto has any provider to name.
187
+ * `selected`: show the provider. There is no neutral "Auto" state: auto always
188
+ * names a concrete provider.
189
+ */
190
+ pill: 'selected' | 'loading';
191
+ /** `skeleton` replaces any "no sellers" text until the data is settled. */
192
+ receiveCard: 'normal' | 'skeleton';
193
+ }
194
+ /**
195
+ * Decide how the pill and the receive card look while data is still loading.
196
+ *
197
+ * In auto mode the pill is a skeleton only until auto has a provider to name
198
+ * (see {@link decideAutoProvider}). In manual mode the pick is the buyer's own
199
+ * and always shows. In both modes the receive card shows a skeleton, never "no
200
+ * sellers", until loading ends, and in auto mode also while the pick can still
201
+ * move (provisional).
202
+ */
203
+ export declare function decideLoadingPresentation({ mode, amountInCurrency, isFillabilityPending, isDepositsPending, isAutoPickPending, autoProvider, }: {
204
+ mode: ProviderSelectionMode;
205
+ amountInCurrency: bigint | null;
206
+ /** Cross-provider deposits or maker verification are still loading. */
207
+ isFillabilityPending: boolean;
208
+ /** The selected provider's own deposit list or maker check is still loading. */
209
+ isDepositsPending: boolean;
210
+ /** Auto mode only: the ranking is still being computed. */
211
+ isAutoPickPending: boolean;
212
+ /** What auto names; null only before the first data. */
213
+ autoProvider: AutoProvider;
214
+ }): LoadingPresentation;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @fileoverview What the buy panel tells the buyer about provider fillability.
3
+ *
4
+ * One pure decision turns the fillability summaries (from
5
+ * `useProviderFillability`) and the buyer's state into at most one notice.
6
+ * BrowsePanel renders what this returns and decides nothing itself.
7
+ *
8
+ * The rules, from the product owner:
9
+ * 1. A zero or empty amount never shows an error. There is nothing to fill yet.
10
+ * 2. Auto mode with no amount cycles through the providers that have sellers
11
+ * (`carousel`). Display only. It ends when the buyer types an amount. With
12
+ * reduced motion, or with fewer than two providers, nothing cycles.
13
+ * 3. Auto mode with an amount follows the auto pick. The panel does not fall
14
+ * back to the volume leader when nothing fills (see `nearestAmount`).
15
+ * 4. A manual pick that cannot fill the amount is kept. If another provider
16
+ * can, offer a one-tap switch to the best-rate one (`switchProvider`).
17
+ * Never override the pick.
18
+ * 5. If no provider can fill the amount, suggest the nearest amount that works
19
+ * (`nearestAmount`). The suggestion comes from the provider the buyer is on:
20
+ * the selected one in manual mode, the one auto names otherwise. A manual
21
+ * pick never gets another provider's amount; that is the switch segment's job.
22
+ * 6. "Can fill" includes the buyer's per-order cap. A provider whose cap is
23
+ * below the amount does not count, and a suggestion never exceeds the cap.
24
+ */
25
+ export const NO_NOTICE = { kind: 'none' };
26
+ /**
27
+ * Rate a provider offers on its best filling deposit, or null when none fills.
28
+ * `fillingDeposits` is sorted best rate first, and each filling deposit has a
29
+ * range carrying its rate.
30
+ */
31
+ function bestFillingRate(summary) {
32
+ const bestDeposit = summary.fillingDeposits[0];
33
+ if (!bestDeposit)
34
+ return null;
35
+ const range = summary.fillableRanges.find((candidate) => candidate.deposit === bestDeposit);
36
+ return range ? range.rate : null;
37
+ }
38
+ /**
39
+ * The provider with the lowest rate among those that can fill the amount.
40
+ * Same order as the auto pick: rate ascending, then provider key for a stable
41
+ * result. Skips excluded providers and the provider named in `except`.
42
+ *
43
+ * @returns The provider key, or null when none can fill
44
+ */
45
+ export function bestRateFillingProvider({ summaries, isProviderExcluded, except, }) {
46
+ let best = null;
47
+ for (const summary of summaries) {
48
+ if (summary.providerKey === except)
49
+ continue;
50
+ if (isProviderExcluded?.(summary.providerKey))
51
+ continue;
52
+ const rate = bestFillingRate(summary);
53
+ if (rate === null)
54
+ continue;
55
+ const beatsBest = best === null ||
56
+ rate < best.rate ||
57
+ (rate === best.rate && summary.providerKey < best.providerKey);
58
+ if (beatsBest)
59
+ best = { providerKey: summary.providerKey, rate };
60
+ }
61
+ return best ? best.providerKey : null;
62
+ }
63
+ /** Decide the one fillability notice for the buy panel. */
64
+ export function decideFillabilityNotice({ mode, amountInCurrency, selectedProvider, summaries, nearestWorkableAmountFor, providersWithLiquidity, prefersReducedMotion, isLoading, isProviderExcluded, exceedsCapFor, }) {
65
+ if (isLoading)
66
+ return NO_NOTICE;
67
+ if (!amountInCurrency) {
68
+ const canCycle = mode === 'auto' && !prefersReducedMotion && providersWithLiquidity.length >= 2;
69
+ return canCycle ? { kind: 'carousel', providerKeys: providersWithLiquidity } : NO_NOTICE;
70
+ }
71
+ const fillsWithinCap = (summary) => summary.fillingDeposits.length > 0 && !exceedsCapFor?.(summary.providerKey);
72
+ const selectedSummary = summaries.find((summary) => summary.providerKey === selectedProvider);
73
+ if (selectedSummary && fillsWithinCap(selectedSummary))
74
+ return NO_NOTICE;
75
+ const someProviderFills = summaries.some(fillsWithinCap);
76
+ if (!someProviderFills) {
77
+ const nearestWorkableAmount = nearestWorkableAmountFor(mode === 'manual' ? selectedProvider : null);
78
+ return nearestWorkableAmount ? { kind: 'nearestAmount', ...nearestWorkableAmount } : NO_NOTICE;
79
+ }
80
+ // Auto mode: the auto pick lands on a provider that fills. Nothing to say.
81
+ if (mode === 'auto')
82
+ return NO_NOTICE;
83
+ const alternative = bestRateFillingProvider({
84
+ summaries,
85
+ isProviderExcluded: (providerKey) => !!isProviderExcluded?.(providerKey) || !!exceedsCapFor?.(providerKey),
86
+ except: selectedProvider,
87
+ });
88
+ return alternative
89
+ ? { kind: 'switchProvider', providerKey: alternative, amountInCurrency }
90
+ : NO_NOTICE;
91
+ }
92
+ /**
93
+ * Whether the "No deposits" badge shows on the send card.
94
+ * It is an error badge, so it stays hidden when there is no amount, while
95
+ * data loads, when deposits are listed, or when a notice already explains it.
96
+ */
97
+ export function shouldShowNoDepositsBadge({ amountInCurrency, hasListedDeposits, isLoading, notice, }) {
98
+ if (!amountInCurrency)
99
+ return false;
100
+ if (hasListedDeposits)
101
+ return false;
102
+ if (isLoading)
103
+ return false;
104
+ return notice.kind === 'none';
105
+ }
106
+ /**
107
+ * Decide which provider auto mode shows. Auto always names a concrete provider,
108
+ * the one the buyer would actually use; the only state without one is the wait
109
+ * for the very first deposits.
110
+ *
111
+ * 1. No deposits fetched yet: `pending` (a brief skeleton).
112
+ * 2. Deposits fetched, maker check still running: `provisional`, the best-rate
113
+ * provider among UNCHECKED sellers. It names a provider early. The receive
114
+ * card stays a skeleton and no notice shows until the check ends, so nothing
115
+ * claims "no sellers" about a provider that may change; if the check removes
116
+ * the sellers, the pill moves once.
117
+ * 3. Checked, no amount: the best-rate provider with sellers (the carousel, when
118
+ * it runs, only cycles the display).
119
+ * 4. Checked, an amount some provider fills within the buyer's cap: the best-rate
120
+ * one that fills it.
121
+ * 5. Checked, an amount nothing fills: the provider of the nearest-amount
122
+ * suggestion, so the pill and the Min or Max button name the same provider.
123
+ * 6. No seller anywhere: the volume default. The receive card and the notice
124
+ * explain there are no sellers.
125
+ */
126
+ export function decideAutoProvider({ amountInCurrency, hasFetchedDeposits, isVerifying, isRankingPending, provisionalProvider, bestRateProvider, cheapestFillingProvider, someProviderFills, nearestWorkableAmount, providersWithLiquidity, volumeDefault, }) {
127
+ if (!hasFetchedDeposits)
128
+ return { provider: null, basis: 'pending' };
129
+ if (isVerifying) {
130
+ return provisionalProvider
131
+ ? { provider: provisionalProvider, basis: 'provisional' }
132
+ : { provider: null, basis: 'pending' };
133
+ }
134
+ const ranked = isRankingPending ? null : bestRateProvider;
135
+ if (!amountInCurrency) {
136
+ const provider = ranked ?? cheapestFillingProvider ?? providersWithLiquidity[0] ?? null;
137
+ return provider ? { provider, basis: 'fills' } : { provider: volumeDefault, basis: 'volume-default' };
138
+ }
139
+ if (someProviderFills) {
140
+ const provider = ranked ?? cheapestFillingProvider;
141
+ return provider ? { provider, basis: 'fills' } : { provider: volumeDefault, basis: 'volume-default' };
142
+ }
143
+ if (nearestWorkableAmount)
144
+ return { provider: nearestWorkableAmount.providerKey, basis: 'nearest' };
145
+ return { provider: volumeDefault, basis: 'volume-default' };
146
+ }
147
+ /**
148
+ * Decide how the pill and the receive card look while data is still loading.
149
+ *
150
+ * In auto mode the pill is a skeleton only until auto has a provider to name
151
+ * (see {@link decideAutoProvider}). In manual mode the pick is the buyer's own
152
+ * and always shows. In both modes the receive card shows a skeleton, never "no
153
+ * sellers", until loading ends, and in auto mode also while the pick can still
154
+ * move (provisional).
155
+ */
156
+ export function decideLoadingPresentation({ mode, amountInCurrency, isFillabilityPending, isDepositsPending, isAutoPickPending, autoProvider, }) {
157
+ const autoPickUnsettled = mode === 'auto' && (isFillabilityPending || isAutoPickPending);
158
+ const receivePending = isFillabilityPending || isDepositsPending || autoPickUnsettled;
159
+ const receiveCard = !!amountInCurrency && receivePending ? 'skeleton' : 'normal';
160
+ const pill = mode === 'auto' && autoProvider.provider === null ? 'loading' : 'selected';
161
+ return { pill, receiveCard };
162
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @fileoverview Tests for the fillability decisions, with plain-object summaries.
3
+ *
4
+ * The user-interface package keeps the end-to-end suite, which builds real
5
+ * deposits through the indexer client. These tests pin the decisions alone, so
6
+ * this package can change them without that client.
7
+ */
8
+ export {};
@@ -0,0 +1,246 @@
1
+ /**
2
+ * @fileoverview Tests for the fillability decisions, with plain-object summaries.
3
+ *
4
+ * The user-interface package keeps the end-to-end suite, which builds real
5
+ * deposits through the indexer client. These tests pin the decisions alone, so
6
+ * this package can change them without that client.
7
+ */
8
+ import { describe, it } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { providers } from '../payment.js';
11
+ import { bestRateFillingProvider, decideAutoProvider, decideFillabilityNotice, decideLoadingPresentation, shouldShowNoDepositsBadge, } from './fillabilityNotice.js';
12
+ const { VENMO, CASHAPP, ZELLE } = providers;
13
+ /** A summary whose deposits fill the amount at the given rate, or fill nothing when rate is null. */
14
+ const summary = (providerKey, rate) => {
15
+ if (rate === null)
16
+ return { providerKey, fillingDeposits: [], fillableRanges: [] };
17
+ const deposit = { providerKey };
18
+ return { providerKey, fillingDeposits: [deposit], fillableRanges: [{ deposit, rate }] };
19
+ };
20
+ const nearest = {
21
+ direction: 'raise',
22
+ reason: 'sellerMinimum',
23
+ amountInCurrency: 500n,
24
+ providerKey: CASHAPP,
25
+ };
26
+ const baseInput = (overrides) => ({
27
+ mode: 'auto',
28
+ amountInCurrency: 1000n,
29
+ selectedProvider: VENMO,
30
+ summaries: [summary(VENMO, 102n), summary(CASHAPP, 101n)],
31
+ nearestWorkableAmountFor: () => null,
32
+ providersWithLiquidity: [CASHAPP, VENMO],
33
+ prefersReducedMotion: false,
34
+ isLoading: false,
35
+ ...overrides,
36
+ });
37
+ describe('decideFillabilityNotice', () => {
38
+ it('says nothing while data loads', () => {
39
+ assert.deepEqual(decideFillabilityNotice(baseInput({ isLoading: true })), { kind: 'none' });
40
+ });
41
+ it('cycles providers in auto mode with no amount', () => {
42
+ assert.deepEqual(decideFillabilityNotice(baseInput({ amountInCurrency: null })), {
43
+ kind: 'carousel',
44
+ providerKeys: [CASHAPP, VENMO],
45
+ });
46
+ });
47
+ it('does not cycle with reduced motion or a single provider', () => {
48
+ assert.deepEqual(decideFillabilityNotice(baseInput({ amountInCurrency: null, prefersReducedMotion: true })), {
49
+ kind: 'none',
50
+ });
51
+ assert.deepEqual(decideFillabilityNotice(baseInput({ amountInCurrency: null, providersWithLiquidity: [VENMO] })), {
52
+ kind: 'none',
53
+ });
54
+ });
55
+ it('offers a switch to the cheapest filling provider when a manual pick cannot fill', () => {
56
+ const notice = decideFillabilityNotice(baseInput({
57
+ mode: 'manual',
58
+ selectedProvider: ZELLE,
59
+ summaries: [summary(ZELLE, null), summary(VENMO, 102n), summary(CASHAPP, 101n)],
60
+ }));
61
+ assert.deepEqual(notice, { kind: 'switchProvider', providerKey: CASHAPP, amountInCurrency: 1000n });
62
+ });
63
+ it('keeps a manual pick that fills', () => {
64
+ const notice = decideFillabilityNotice(baseInput({ mode: 'manual', selectedProvider: VENMO }));
65
+ assert.deepEqual(notice, { kind: 'none' });
66
+ });
67
+ it('treats a provider over the buyer cap as unable to fill', () => {
68
+ const notice = decideFillabilityNotice(baseInput({ mode: 'manual', selectedProvider: VENMO, exceedsCapFor: (providerKey) => providerKey === VENMO }));
69
+ assert.deepEqual(notice, { kind: 'switchProvider', providerKey: CASHAPP, amountInCurrency: 1000n });
70
+ });
71
+ it('suggests the nearest amount when no provider fills', () => {
72
+ const asked = [];
73
+ const notice = decideFillabilityNotice(baseInput({
74
+ mode: 'manual',
75
+ selectedProvider: VENMO,
76
+ summaries: [summary(VENMO, null), summary(CASHAPP, null)],
77
+ nearestWorkableAmountFor: (providerKey) => {
78
+ asked.push(providerKey);
79
+ return nearest;
80
+ },
81
+ }));
82
+ assert.deepEqual(notice, { kind: 'nearestAmount', ...nearest });
83
+ // A manual pick never gets another provider's amount.
84
+ assert.deepEqual(asked, [VENMO]);
85
+ });
86
+ it('asks across all providers in auto mode', () => {
87
+ const asked = [];
88
+ decideFillabilityNotice(baseInput({
89
+ summaries: [summary(VENMO, null)],
90
+ nearestWorkableAmountFor: (providerKey) => {
91
+ asked.push(providerKey);
92
+ return null;
93
+ },
94
+ }));
95
+ assert.deepEqual(asked, [null]);
96
+ });
97
+ });
98
+ describe('decideFillabilityNotice: other providers fill but the buyer is not on one', () => {
99
+ it('says nothing in auto mode, because auto already lands on a provider that fills', () => {
100
+ const notice = decideFillabilityNotice(baseInput({ mode: 'auto', selectedProvider: ZELLE, summaries: [summary(ZELLE, null), summary(CASHAPP, 101n)] }));
101
+ assert.deepEqual(notice, { kind: 'none' });
102
+ });
103
+ it('offers no switch when every other filling provider is in cooldown', () => {
104
+ const notice = decideFillabilityNotice(baseInput({
105
+ mode: 'manual',
106
+ selectedProvider: ZELLE,
107
+ summaries: [summary(ZELLE, null), summary(CASHAPP, 101n)],
108
+ isProviderExcluded: (providerKey) => providerKey === CASHAPP,
109
+ }));
110
+ assert.deepEqual(notice, { kind: 'none' });
111
+ });
112
+ it('offers no switch when every other filling provider is over the buyer cap', () => {
113
+ const notice = decideFillabilityNotice(baseInput({
114
+ mode: 'manual',
115
+ selectedProvider: ZELLE,
116
+ summaries: [summary(ZELLE, null), summary(CASHAPP, 101n)],
117
+ exceedsCapFor: (providerKey) => providerKey === CASHAPP,
118
+ }));
119
+ assert.deepEqual(notice, { kind: 'none' });
120
+ });
121
+ it('says nothing when no provider fills and there is no nearer amount to suggest', () => {
122
+ const notice = decideFillabilityNotice(baseInput({ mode: 'manual', summaries: [summary(VENMO, null)], nearestWorkableAmountFor: () => null }));
123
+ assert.deepEqual(notice, { kind: 'none' });
124
+ });
125
+ });
126
+ describe('bestRateFillingProvider: inconsistent summaries', () => {
127
+ it('ignores a filling deposit that has no rate range, so a rate is never invented', () => {
128
+ const orphan = { providerKey: VENMO, fillingDeposits: [{}], fillableRanges: [] };
129
+ assert.equal(bestRateFillingProvider({ summaries: [orphan] }), null);
130
+ });
131
+ it('keeps the earlier provider when a later one offers a worse rate', () => {
132
+ assert.equal(bestRateFillingProvider({ summaries: [summary(VENMO, 101n), summary(CASHAPP, 102n)] }), VENMO);
133
+ });
134
+ });
135
+ describe('bestRateFillingProvider', () => {
136
+ const summaries = [summary(VENMO, 102n), summary(CASHAPP, 101n), summary(ZELLE, 101n)];
137
+ it('picks the lowest rate and breaks a tie by provider key', () => {
138
+ assert.equal(bestRateFillingProvider({ summaries }), CASHAPP);
139
+ });
140
+ it('skips the named provider and excluded providers', () => {
141
+ assert.equal(bestRateFillingProvider({ summaries, except: CASHAPP }), ZELLE);
142
+ assert.equal(bestRateFillingProvider({ summaries, isProviderExcluded: (providerKey) => providerKey !== VENMO }), VENMO);
143
+ });
144
+ it('returns null when none fill', () => {
145
+ assert.equal(bestRateFillingProvider({ summaries: [summary(VENMO, null)] }), null);
146
+ });
147
+ });
148
+ describe('shouldShowNoDepositsBadge', () => {
149
+ it('shows only for a real amount, nothing listed, nothing loading and no notice', () => {
150
+ const base = { amountInCurrency: 500n, hasListedDeposits: false, isLoading: false, notice: { kind: 'none' } };
151
+ assert.equal(shouldShowNoDepositsBadge(base), true);
152
+ assert.equal(shouldShowNoDepositsBadge({ ...base, amountInCurrency: 0n }), false);
153
+ assert.equal(shouldShowNoDepositsBadge({ ...base, hasListedDeposits: true }), false);
154
+ assert.equal(shouldShowNoDepositsBadge({ ...base, isLoading: true }), false);
155
+ assert.equal(shouldShowNoDepositsBadge({ ...base, notice: { kind: 'nearestAmount', ...nearest } }), false);
156
+ });
157
+ });
158
+ describe('decideAutoProvider', () => {
159
+ const settled = {
160
+ amountInCurrency: 1000n,
161
+ hasFetchedDeposits: true,
162
+ isVerifying: false,
163
+ isRankingPending: false,
164
+ provisionalProvider: null,
165
+ bestRateProvider: CASHAPP,
166
+ cheapestFillingProvider: VENMO,
167
+ someProviderFills: true,
168
+ nearestWorkableAmount: null,
169
+ providersWithLiquidity: [CASHAPP, VENMO],
170
+ volumeDefault: ZELLE,
171
+ };
172
+ it('names no provider before the first deposits arrive', () => {
173
+ assert.deepEqual(decideAutoProvider({ ...settled, hasFetchedDeposits: false }), { provider: null, basis: 'pending' });
174
+ });
175
+ it('names a provisional provider while the maker check runs', () => {
176
+ assert.deepEqual(decideAutoProvider({ ...settled, isVerifying: true, provisionalProvider: VENMO }), {
177
+ provider: VENMO,
178
+ basis: 'provisional',
179
+ });
180
+ assert.deepEqual(decideAutoProvider({ ...settled, isVerifying: true }), { provider: null, basis: 'pending' });
181
+ });
182
+ it('names the ranked provider that fills the amount', () => {
183
+ assert.deepEqual(decideAutoProvider(settled), { provider: CASHAPP, basis: 'fills' });
184
+ });
185
+ it('falls back to the cheapest filling provider while ranking loads', () => {
186
+ assert.deepEqual(decideAutoProvider({ ...settled, isRankingPending: true }), { provider: VENMO, basis: 'fills' });
187
+ });
188
+ it('names the provider of the nearest amount when nothing fills', () => {
189
+ assert.deepEqual(decideAutoProvider({ ...settled, someProviderFills: false, nearestWorkableAmount: nearest }), {
190
+ provider: CASHAPP,
191
+ basis: 'nearest',
192
+ });
193
+ });
194
+ it('names the first provider with liquidity when no amount is typed and nothing ranks', () => {
195
+ assert.deepEqual(decideAutoProvider({
196
+ ...settled,
197
+ amountInCurrency: null,
198
+ bestRateProvider: null,
199
+ cheapestFillingProvider: null,
200
+ providersWithLiquidity: [VENMO],
201
+ }), { provider: VENMO, basis: 'fills' });
202
+ });
203
+ it('names the volume default when the buyer cap leaves no provider to name for an amount', () => {
204
+ assert.deepEqual(decideAutoProvider({ ...settled, bestRateProvider: null, cheapestFillingProvider: null }), { provider: ZELLE, basis: 'volume-default' });
205
+ });
206
+ it('names the volume default when nothing fills and there is no nearer amount', () => {
207
+ assert.deepEqual(decideAutoProvider({ ...settled, someProviderFills: false, nearestWorkableAmount: null }), {
208
+ provider: ZELLE,
209
+ basis: 'volume-default',
210
+ });
211
+ });
212
+ it('falls back to the volume default when no seller exists', () => {
213
+ assert.deepEqual(decideAutoProvider({
214
+ ...settled,
215
+ amountInCurrency: null,
216
+ bestRateProvider: null,
217
+ cheapestFillingProvider: null,
218
+ providersWithLiquidity: [],
219
+ }), { provider: ZELLE, basis: 'volume-default' });
220
+ });
221
+ });
222
+ describe('decideLoadingPresentation', () => {
223
+ const idle = {
224
+ mode: 'auto',
225
+ amountInCurrency: 1000n,
226
+ isFillabilityPending: false,
227
+ isDepositsPending: false,
228
+ isAutoPickPending: false,
229
+ autoProvider: { provider: VENMO, basis: 'fills' },
230
+ };
231
+ it('shows a skeleton pill only in auto mode before a provider exists', () => {
232
+ assert.equal(decideLoadingPresentation({ ...idle, autoProvider: { provider: null, basis: 'pending' } }).pill, 'loading');
233
+ assert.equal(decideLoadingPresentation({ ...idle, mode: 'manual', autoProvider: { provider: null, basis: 'pending' } }).pill, 'selected');
234
+ });
235
+ it('shows a skeleton receive card instead of "no sellers" while data loads', () => {
236
+ assert.equal(decideLoadingPresentation({ ...idle, isDepositsPending: true }).receiveCard, 'skeleton');
237
+ assert.equal(decideLoadingPresentation({ ...idle, isAutoPickPending: true }).receiveCard, 'skeleton');
238
+ assert.equal(decideLoadingPresentation(idle).receiveCard, 'normal');
239
+ });
240
+ it('keeps the receive card normal with no amount typed', () => {
241
+ assert.equal(decideLoadingPresentation({ ...idle, amountInCurrency: null, isDepositsPending: true }).receiveCard, 'normal');
242
+ });
243
+ it('does not wait on the auto pick in manual mode', () => {
244
+ assert.equal(decideLoadingPresentation({ ...idle, mode: 'manual', isAutoPickPending: true }).receiveCard, 'normal');
245
+ });
246
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @fileoverview Format a currency amount held in base units.
3
+ *
4
+ * Two outputs, for two audiences:
5
+ * - `formatCurrencyBaseUnits` is for reading: symbol, thousands separators,
6
+ * and the currency's own decimals (cents for USD, none for KRW).
7
+ * - `toAmountInputString` is for the amount field: plain digits with a dot,
8
+ * no symbol and no separators, so the field's own sanitizer accepts it.
9
+ */
10
+ import { type Currency } from '../currencies.js';
11
+ /**
12
+ * Display string such as `$5.00`, `€9.20` or `₩14,000`.
13
+ *
14
+ * @param params.amount - Amount in the currency's base units
15
+ * @param params.currency - Currency whose symbol and decimals apply
16
+ */
17
+ export declare function formatCurrencyBaseUnits({ amount, currency }: {
18
+ amount: bigint;
19
+ currency: Currency;
20
+ }): string;
21
+ /**
22
+ * Plain decimal string for the amount input, such as `5.00` or `14000`.
23
+ *
24
+ * @param params.amount - Amount in the currency's base units
25
+ * @param params.currency - Currency whose decimals apply
26
+ */
27
+ export declare function toAmountInputString({ amount, currency }: {
28
+ amount: bigint;
29
+ currency: Currency;
30
+ }): string;