@capxul/sdk-react 0.0.0-rc-2cdddbb91
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 +131 -0
- package/dist/index.d.mts +1167 -0
- package/dist/index.mjs +3936 -0
- package/dist/onboarding-controller-DWuvVuC8.mjs +1108 -0
- package/dist/onboarding-controller-DrJiYWfY.d.mts +205 -0
- package/dist/testing/index.d.mts +19 -0
- package/dist/testing/index.mjs +22 -0
- package/package.json +94 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3936 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { _ as orgKeySegment, a as entered, c as useCapxulIdentity, d as useCapxulTransitions, f as toInvocationControls, g as capxulKeys, h as actingAccount, i as CapxulProvider, l as useCapxulIdentityOrNull, m as accountKey, n as CapxulAuthenticationController, o as useCapxulAuth, p as toInvocationOptions, r as absurd, s as useCapxulDestination, t as CapxulOnboardingController, u as useCapxulSend, v as useCapxulClientOrNull, y as useCapxul } from "./onboarding-controller-DWuvVuC8.mjs";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { CAPXUL_OPERATIONS, CapxulError, EVM_ADDRESS_RE, Errors, HANDLE_RE, PAYMENT_DIRECTIONS, PAYMENT_STATUSES, assetSymbolFor, fingerprintPaymentIntent, formatAssetAmount, formatMoney, isCapxulError, isClaimed, isClaimed as isClaimed$1, isMoneyParseError, isRestoring, isRestoring as isRestoring$1, parseAssetAmount, parseMoney, paymentPhase, requireAsset, resolveIdentityDestination, toAssetId, toCurrencyCode, toEvmAddress, toOrgId, toPartyId, toRaw, valueActivitySummary } from "@capxul/sdk";
|
|
6
|
+
import { Fragment, jsx } from "react/jsx-runtime";
|
|
7
|
+
//#region src/access/use-client-connection.ts
|
|
8
|
+
function useClientConnection(input) {
|
|
9
|
+
const [view, setView] = useState(null);
|
|
10
|
+
const [pending, setPending] = useState(false);
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
const [resendReady, setResendReady] = useState(false);
|
|
13
|
+
const identityRef = useRef({
|
|
14
|
+
client: input.client,
|
|
15
|
+
requestId: input.requestId
|
|
16
|
+
});
|
|
17
|
+
identityRef.current = {
|
|
18
|
+
client: input.client,
|
|
19
|
+
requestId: input.requestId
|
|
20
|
+
};
|
|
21
|
+
const belongsHere = useCallback((client, requestId) => identityRef.current.client === client && identityRef.current.requestId === requestId, []);
|
|
22
|
+
const refresh = useCallback(async (signal) => {
|
|
23
|
+
const { client, requestId } = identityRef.current;
|
|
24
|
+
const result = await client.auth.clientRequests.read({ requestId }, signal === void 0 ? void 0 : { signal });
|
|
25
|
+
if (signal?.aborted || !belongsHere(client, requestId)) return;
|
|
26
|
+
if (result.ok) {
|
|
27
|
+
setView(result.value);
|
|
28
|
+
setError(null);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
setError(result.error);
|
|
32
|
+
}, [belongsHere]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setView(null);
|
|
35
|
+
setError(null);
|
|
36
|
+
setPending(false);
|
|
37
|
+
setResendReady(false);
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
refresh(controller.signal);
|
|
40
|
+
return () => {
|
|
41
|
+
controller.abort();
|
|
42
|
+
};
|
|
43
|
+
}, [
|
|
44
|
+
input.client,
|
|
45
|
+
input.requestId,
|
|
46
|
+
refresh
|
|
47
|
+
]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (view?.phase !== "otp_required") {
|
|
50
|
+
setResendReady(false);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const remaining = view.resendAt - Date.now();
|
|
54
|
+
if (remaining <= 0) {
|
|
55
|
+
setResendReady(true);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setResendReady(false);
|
|
59
|
+
const timer = setTimeout(() => setResendReady(true), remaining);
|
|
60
|
+
return () => clearTimeout(timer);
|
|
61
|
+
}, [view]);
|
|
62
|
+
const run = useCallback(async (work) => {
|
|
63
|
+
const { client, requestId } = identityRef.current;
|
|
64
|
+
setPending(true);
|
|
65
|
+
try {
|
|
66
|
+
const result = await work();
|
|
67
|
+
if (!belongsHere(client, requestId)) return;
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
setError(result.error);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await refresh();
|
|
73
|
+
} finally {
|
|
74
|
+
if (belongsHere(client, requestId)) setPending(false);
|
|
75
|
+
}
|
|
76
|
+
}, [belongsHere, refresh]);
|
|
77
|
+
return useMemo(() => {
|
|
78
|
+
if (error !== null && (view === null || view.phase !== "refused")) return {
|
|
79
|
+
phase: "failure",
|
|
80
|
+
otp: null,
|
|
81
|
+
consent: null,
|
|
82
|
+
complete: null,
|
|
83
|
+
failure: {
|
|
84
|
+
code: error.code,
|
|
85
|
+
retry: () => refresh(),
|
|
86
|
+
cancel: async () => {
|
|
87
|
+
if (view === null) return;
|
|
88
|
+
const { client, requestId } = identityRef.current;
|
|
89
|
+
await run(() => client.auth.clientRequests.cancel({
|
|
90
|
+
requestId,
|
|
91
|
+
revision: view.revision
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
if (view === null) return {
|
|
97
|
+
phase: "checking",
|
|
98
|
+
otp: null,
|
|
99
|
+
consent: null,
|
|
100
|
+
complete: null,
|
|
101
|
+
failure: null
|
|
102
|
+
};
|
|
103
|
+
if (view.phase === "otp_required") return {
|
|
104
|
+
phase: view.phase,
|
|
105
|
+
otp: {
|
|
106
|
+
email: view.email,
|
|
107
|
+
pending,
|
|
108
|
+
canResend: resendReady,
|
|
109
|
+
verifyCode: async (code) => {
|
|
110
|
+
const { client, requestId } = identityRef.current;
|
|
111
|
+
await run(() => client.auth.clientRequests.verifyOtp({
|
|
112
|
+
requestId,
|
|
113
|
+
code
|
|
114
|
+
}));
|
|
115
|
+
},
|
|
116
|
+
resend: async () => {
|
|
117
|
+
const { client, requestId } = identityRef.current;
|
|
118
|
+
await run(() => client.auth.clientRequests.sendOtp({ requestId }));
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
consent: null,
|
|
122
|
+
complete: null,
|
|
123
|
+
failure: null
|
|
124
|
+
};
|
|
125
|
+
if (view.phase === "review") return {
|
|
126
|
+
phase: view.phase,
|
|
127
|
+
otp: null,
|
|
128
|
+
consent: {
|
|
129
|
+
verifiedEmail: view.email,
|
|
130
|
+
clientLabel: view.clientLabel,
|
|
131
|
+
scopeCodes: view.scopes,
|
|
132
|
+
purpose: view.intent.mode,
|
|
133
|
+
setupState: view.setupState,
|
|
134
|
+
pending,
|
|
135
|
+
accept: async () => {
|
|
136
|
+
const { client, requestId } = identityRef.current;
|
|
137
|
+
await run(() => client.auth.clientRequests.accept({
|
|
138
|
+
requestId,
|
|
139
|
+
revision: view.revision
|
|
140
|
+
}));
|
|
141
|
+
},
|
|
142
|
+
cancel: async () => {
|
|
143
|
+
const { client, requestId } = identityRef.current;
|
|
144
|
+
await run(() => client.auth.clientRequests.cancel({
|
|
145
|
+
requestId,
|
|
146
|
+
revision: view.revision
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
complete: null,
|
|
151
|
+
failure: null
|
|
152
|
+
};
|
|
153
|
+
if (view.phase === "connected") return {
|
|
154
|
+
phase: view.phase,
|
|
155
|
+
otp: null,
|
|
156
|
+
consent: null,
|
|
157
|
+
complete: { outcome: "connected" },
|
|
158
|
+
failure: null
|
|
159
|
+
};
|
|
160
|
+
if (view.phase === "refused") return {
|
|
161
|
+
phase: view.phase,
|
|
162
|
+
otp: null,
|
|
163
|
+
consent: null,
|
|
164
|
+
complete: null,
|
|
165
|
+
failure: {
|
|
166
|
+
code: view.code,
|
|
167
|
+
...view.field === void 0 ? {} : { field: view.field },
|
|
168
|
+
retry: () => refresh(),
|
|
169
|
+
cancel: async () => void 0
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
phase: view.phase,
|
|
174
|
+
otp: null,
|
|
175
|
+
consent: null,
|
|
176
|
+
complete: null,
|
|
177
|
+
failure: null
|
|
178
|
+
};
|
|
179
|
+
}, [
|
|
180
|
+
error,
|
|
181
|
+
pending,
|
|
182
|
+
refresh,
|
|
183
|
+
resendReady,
|
|
184
|
+
run,
|
|
185
|
+
view
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/access/client-connection.tsx
|
|
190
|
+
const ConnectionContext = createContext(null);
|
|
191
|
+
function useConnection() {
|
|
192
|
+
const engine = useContext(ConnectionContext);
|
|
193
|
+
if (engine === null) throw new Error("CapxulClientConnection parts must be used inside <CapxulClientConnection>");
|
|
194
|
+
return engine;
|
|
195
|
+
}
|
|
196
|
+
function Root$7({ client, requestId, children }) {
|
|
197
|
+
const engine = useClientConnection({
|
|
198
|
+
client,
|
|
199
|
+
requestId
|
|
200
|
+
});
|
|
201
|
+
return /* @__PURE__ */ jsx(ConnectionContext.Provider, {
|
|
202
|
+
value: engine,
|
|
203
|
+
children
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function Checking$1({ children }) {
|
|
207
|
+
return useConnection().phase === "checking" ? /* @__PURE__ */ jsx(Fragment, { children }) : null;
|
|
208
|
+
}
|
|
209
|
+
function Otp({ children }) {
|
|
210
|
+
const otp = useConnection().otp;
|
|
211
|
+
return otp === null ? null : /* @__PURE__ */ jsx(Fragment, { children: children(otp) });
|
|
212
|
+
}
|
|
213
|
+
function Consent({ children }) {
|
|
214
|
+
const consent = useConnection().consent;
|
|
215
|
+
return consent === null ? null : /* @__PURE__ */ jsx(Fragment, { children: children(consent) });
|
|
216
|
+
}
|
|
217
|
+
function Complete({ children }) {
|
|
218
|
+
const complete = useConnection().complete;
|
|
219
|
+
return complete === null ? null : /* @__PURE__ */ jsx(Fragment, { children: children(complete) });
|
|
220
|
+
}
|
|
221
|
+
function Failure({ children }) {
|
|
222
|
+
const failure = useConnection().failure;
|
|
223
|
+
return failure === null ? null : /* @__PURE__ */ jsx(Fragment, { children: children(failure) });
|
|
224
|
+
}
|
|
225
|
+
const CapxulClientConnection = Object.assign(Root$7, {
|
|
226
|
+
Checking: Checking$1,
|
|
227
|
+
Otp,
|
|
228
|
+
Consent,
|
|
229
|
+
Complete,
|
|
230
|
+
Failure
|
|
231
|
+
});
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/platform/require-bootstrapped-client.ts
|
|
234
|
+
/**
|
|
235
|
+
* Narrow the bootstrap-nullable client to a ready client inside a query /
|
|
236
|
+
* mutation function (sdk-provider-owned-bootstrap.md). Data hooks gate their
|
|
237
|
+
* queries on `enabled: client !== null`, so this only ever throws for a mutation
|
|
238
|
+
* triggered while `<CapxulProvider>` is still bootstrapping.
|
|
239
|
+
*/
|
|
240
|
+
function requireBootstrappedClient(client, method) {
|
|
241
|
+
if (client === null) throw Errors.wrongState({
|
|
242
|
+
method,
|
|
243
|
+
currentState: "bootstrapping",
|
|
244
|
+
validStates: ["ready"]
|
|
245
|
+
});
|
|
246
|
+
return client;
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/platform/unwrap-capxul-result.ts
|
|
250
|
+
/**
|
|
251
|
+
* Unwrap a `CapxulResult` for TanStack query/mutation functions and throw into
|
|
252
|
+
* React Query's error path.
|
|
253
|
+
*
|
|
254
|
+
* Failure observation belongs to the core SDK method boundary. React does not
|
|
255
|
+
* report the same logical failure a second time.
|
|
256
|
+
*/
|
|
257
|
+
function unwrapCapxulResult(result) {
|
|
258
|
+
if (result.ok) return result.value;
|
|
259
|
+
throw result.error;
|
|
260
|
+
}
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/organizations/use-org-me-reading.ts
|
|
263
|
+
function useOrgMeReading(orgId) {
|
|
264
|
+
const client = useCapxulClientOrNull();
|
|
265
|
+
const identity = useCapxulIdentityOrNull();
|
|
266
|
+
const ready = client !== null && identity !== null && isClaimed$1(identity);
|
|
267
|
+
const mayStillOpen = client === null || identity === null || isRestoring$1(identity);
|
|
268
|
+
const query = useQuery({
|
|
269
|
+
queryKey: capxulKeys.orgMe(orgKeySegment(orgId), actingAccount(identity)),
|
|
270
|
+
queryFn: async () => {
|
|
271
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.org.me).org(toOrgId(orgId)).me());
|
|
272
|
+
},
|
|
273
|
+
enabled: ready && orgId !== void 0
|
|
274
|
+
});
|
|
275
|
+
return useMemo(() => {
|
|
276
|
+
if (orgId === void 0) return { kind: "no-org" };
|
|
277
|
+
if (!ready) return { kind: mayStillOpen ? "loading" : "unavailable" };
|
|
278
|
+
if (query.error !== null) return { kind: "unavailable" };
|
|
279
|
+
if (query.data !== void 0) return {
|
|
280
|
+
kind: "ready",
|
|
281
|
+
me: query.data
|
|
282
|
+
};
|
|
283
|
+
return { kind: "loading" };
|
|
284
|
+
}, [
|
|
285
|
+
mayStillOpen,
|
|
286
|
+
orgId,
|
|
287
|
+
query.data,
|
|
288
|
+
query.error,
|
|
289
|
+
ready
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/organizations/org-member.tsx
|
|
294
|
+
const DENIED_NO_ORG = {
|
|
295
|
+
allowed: false,
|
|
296
|
+
isLoading: false,
|
|
297
|
+
reason: "org-unavailable"
|
|
298
|
+
};
|
|
299
|
+
const DENIED_LOADING = {
|
|
300
|
+
allowed: false,
|
|
301
|
+
isLoading: true,
|
|
302
|
+
reason: "loading"
|
|
303
|
+
};
|
|
304
|
+
const ALLOWED = {
|
|
305
|
+
allowed: true,
|
|
306
|
+
isLoading: false,
|
|
307
|
+
reason: null
|
|
308
|
+
};
|
|
309
|
+
const DENIED_NO_PERMISSION = {
|
|
310
|
+
allowed: false,
|
|
311
|
+
isLoading: false,
|
|
312
|
+
reason: "no-permission"
|
|
313
|
+
};
|
|
314
|
+
function canSlice(reading, held) {
|
|
315
|
+
switch (reading.kind) {
|
|
316
|
+
case "no-org":
|
|
317
|
+
case "unavailable": return DENIED_NO_ORG;
|
|
318
|
+
case "loading": return DENIED_LOADING;
|
|
319
|
+
case "ready": return held(reading.me) ? ALLOWED : DENIED_NO_PERMISSION;
|
|
320
|
+
default: return absurd(reading);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The classification rule, derived line by line from the app's own expression
|
|
325
|
+
* at `use-current-organization.ts:124-134`. Same order, same folds:
|
|
326
|
+
*
|
|
327
|
+
* | Test, in order | standing |
|
|
328
|
+
* | ------------------------------------------- | -------------- |
|
|
329
|
+
* | the `me()` read has not settled | `"loading"` |
|
|
330
|
+
* | the read errored, or no `orgId` was given | `"unavailable"`|
|
|
331
|
+
* | `role === null` | `"unavailable"`|
|
|
332
|
+
* | `canManagePeople \|\| canSpend` | `"active"` |
|
|
333
|
+
* | neither capability | `"restricted"` |
|
|
334
|
+
*
|
|
335
|
+
* `"unavailable"` folds TWO causes — a failed read and a member with no Role
|
|
336
|
+
* label — exactly as today. And because the `role === null` test runs BEFORE
|
|
337
|
+
* the capability test, `"active"` guarantees a non-null label, so no screen
|
|
338
|
+
* needs a fallback word in that branch.
|
|
339
|
+
*/
|
|
340
|
+
function standingSlice(reading) {
|
|
341
|
+
if (reading.kind === "loading") return {
|
|
342
|
+
standing: "loading",
|
|
343
|
+
roleLabel: null
|
|
344
|
+
};
|
|
345
|
+
if (reading.kind !== "ready") return {
|
|
346
|
+
standing: "unavailable",
|
|
347
|
+
roleLabel: null
|
|
348
|
+
};
|
|
349
|
+
const { role, capabilities } = reading.me;
|
|
350
|
+
if (role === null) return {
|
|
351
|
+
standing: "unavailable",
|
|
352
|
+
roleLabel: null
|
|
353
|
+
};
|
|
354
|
+
return {
|
|
355
|
+
standing: capabilities.canManagePeople || capabilities.canSpend ? "active" : "restricted",
|
|
356
|
+
roleLabel: role.label
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/** The engine — all the logic. NOT exported from the package barrel. */
|
|
360
|
+
function useOrgMember(orgId) {
|
|
361
|
+
const reading = useOrgMeReading(orgId);
|
|
362
|
+
return useMemo(() => ({
|
|
363
|
+
can: {
|
|
364
|
+
spend: canSlice(reading, (me) => me.capabilities.canSpend),
|
|
365
|
+
managePeople: canSlice(reading, (me) => me.capabilities.canManagePeople)
|
|
366
|
+
},
|
|
367
|
+
standing: standingSlice(reading)
|
|
368
|
+
}), [reading]);
|
|
369
|
+
}
|
|
370
|
+
const OrgMemberContext = createContext(null);
|
|
371
|
+
function useOrgMemberContext() {
|
|
372
|
+
const state = useContext(OrgMemberContext);
|
|
373
|
+
if (state === null) throw new Error("CapxulOrgMember parts must be used inside <CapxulOrgMember>");
|
|
374
|
+
return state;
|
|
375
|
+
}
|
|
376
|
+
function Root$6({ orgId, children }) {
|
|
377
|
+
const state = useOrgMember(orgId);
|
|
378
|
+
return /* @__PURE__ */ jsx(OrgMemberContext.Provider, {
|
|
379
|
+
value: state,
|
|
380
|
+
children
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* One part serves all four denial idioms the app already uses: disabled with no
|
|
385
|
+
* explanation, disabled plus a native `title`, a whole-surface swap, and
|
|
386
|
+
* buttons that vanish. The part branches on nothing — the root computes both
|
|
387
|
+
* slices and this picks the one the `do` prop names.
|
|
388
|
+
*/
|
|
389
|
+
function Can({ do: action, children }) {
|
|
390
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useOrgMemberContext().can[action]) });
|
|
391
|
+
}
|
|
392
|
+
function Standing({ children }) {
|
|
393
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useOrgMemberContext().standing) });
|
|
394
|
+
}
|
|
395
|
+
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
396
|
+
const CapxulOrgMember = Object.assign(Root$6, {
|
|
397
|
+
Can,
|
|
398
|
+
Standing
|
|
399
|
+
});
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/organizations/use-capxul-create-org.ts
|
|
402
|
+
function useCapxulCreateOrg() {
|
|
403
|
+
const client = useCapxulClientOrNull();
|
|
404
|
+
const queryClient = useQueryClient();
|
|
405
|
+
return useMutation({
|
|
406
|
+
mutationFn: async (input) => {
|
|
407
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.onboarding.completeOrganization).onboarding.completeOrganization(input));
|
|
408
|
+
},
|
|
409
|
+
onSuccess: async () => {
|
|
410
|
+
await queryClient.invalidateQueries({ queryKey: capxulKeys.orgs });
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region src/organizations/use-capxul-org-members.ts
|
|
416
|
+
function useCapxulOrgMembers(orgId, options) {
|
|
417
|
+
const client = useCapxulClientOrNull();
|
|
418
|
+
const enabled = options?.enabled ?? true;
|
|
419
|
+
return useQuery({
|
|
420
|
+
queryKey: capxulKeys.orgMembers(orgId),
|
|
421
|
+
queryFn: async () => {
|
|
422
|
+
if (orgId === void 0) throw Errors.invalidInput("orgId", "required for useCapxulOrgMembers");
|
|
423
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.org.members).org(orgId).members());
|
|
424
|
+
},
|
|
425
|
+
enabled: client !== null && enabled && orgId !== void 0
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/organizations/use-capxul-org-roles.ts
|
|
430
|
+
function useCapxulOrgRoles(orgId, options) {
|
|
431
|
+
const client = useCapxulClientOrNull();
|
|
432
|
+
const enabled = options?.enabled ?? true;
|
|
433
|
+
return useQuery({
|
|
434
|
+
queryKey: capxulKeys.orgRoles(orgId),
|
|
435
|
+
queryFn: async () => {
|
|
436
|
+
if (orgId === void 0) throw Errors.invalidInput("orgId", "required for useCapxulOrgRoles");
|
|
437
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.org.roles).org(orgId).roles());
|
|
438
|
+
},
|
|
439
|
+
enabled: client !== null && enabled && orgId !== void 0
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/organizations/use-capxul-org-treasury.ts
|
|
444
|
+
function useCapxulOrgTreasury(orgId, options) {
|
|
445
|
+
const client = useCapxulClientOrNull();
|
|
446
|
+
const identity = useCapxulIdentityOrNull();
|
|
447
|
+
const enabled = options?.enabled ?? true;
|
|
448
|
+
return useQuery({
|
|
449
|
+
queryKey: accountKey(capxulKeys.orgTreasury(orgId), identity),
|
|
450
|
+
queryFn: async () => {
|
|
451
|
+
if (orgId === void 0) throw Errors.invalidInput("orgId", "required for useCapxulOrgTreasury");
|
|
452
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.org.treasury).org(orgId).treasury());
|
|
453
|
+
},
|
|
454
|
+
enabled: client !== null && enabled && orgId !== void 0
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/organizations/use-capxul-orgs.ts
|
|
459
|
+
function useCapxulOrgs(options) {
|
|
460
|
+
const client = useCapxulClientOrNull();
|
|
461
|
+
const identity = useCapxulIdentityOrNull();
|
|
462
|
+
return useQuery({
|
|
463
|
+
queryKey: accountKey(capxulKeys.orgs, identity),
|
|
464
|
+
queryFn: async () => {
|
|
465
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, "orgs").orgs());
|
|
466
|
+
},
|
|
467
|
+
enabled: client !== null && (options?.enabled ?? true)
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/access/use-dashboard-access.ts
|
|
472
|
+
function isOrgLaneSettled(identity) {
|
|
473
|
+
if (identity.phase !== "authenticated" || identity.account.at !== "claimed") return true;
|
|
474
|
+
const org = identity.account.org;
|
|
475
|
+
if (org === null) return true;
|
|
476
|
+
return org.observation === "connected" && org.setup !== null;
|
|
477
|
+
}
|
|
478
|
+
function decidePhase(input) {
|
|
479
|
+
if (input.error !== null) return "error";
|
|
480
|
+
if (!input.settled) return "checking";
|
|
481
|
+
if (input.granted) return "granted";
|
|
482
|
+
if (input.orgLaneSettled && input.destination !== null) return "redirect";
|
|
483
|
+
return "checking";
|
|
484
|
+
}
|
|
485
|
+
function useDashboardAccess(scope) {
|
|
486
|
+
const client = useCapxulClientOrNull();
|
|
487
|
+
const queryClient = useQueryClient();
|
|
488
|
+
const identity = useCapxulIdentityOrNull();
|
|
489
|
+
const destination = useMemo(() => destinationOrNull(identity), [identity]);
|
|
490
|
+
const orgId = scope.kind === "organization" ? scope.orgId : void 0;
|
|
491
|
+
const claimed = isClaimedOrFalse(identity);
|
|
492
|
+
const scopeKey = `${orgId ?? ""}:${identity?.phase === "authenticated" ? identity.session.authUserId : ""}:${actingAccount(identity) ?? ""}`;
|
|
493
|
+
const [observation, setObservation] = useState({
|
|
494
|
+
scopeKey,
|
|
495
|
+
status: "loading"
|
|
496
|
+
});
|
|
497
|
+
const [subscriptionRetry, setSubscriptionRetry] = useState(0);
|
|
498
|
+
const sessionError = useMemo(() => identity?.phase === "faulted" ? identityError(identity) : null, [identity]);
|
|
499
|
+
const wantsMemberships = orgId !== void 0 && claimed;
|
|
500
|
+
const memberships = useCapxulOrgs({ enabled: wantsMemberships });
|
|
501
|
+
const membershipError = wantsMemberships ? memberships.error : null;
|
|
502
|
+
const membershipsSettled = !wantsMemberships || memberships.data !== void 0 || membershipError !== null;
|
|
503
|
+
const hasMembership = orgId !== void 0 && (memberships.data?.items ?? []).some((organization) => String(organization.id) === orgId);
|
|
504
|
+
const wantsLifecycle = wantsMemberships && membershipsSettled && hasMembership;
|
|
505
|
+
const orgKey = orgKeySegment(orgId);
|
|
506
|
+
const setupKey = accountKey(capxulKeys.orgLifecycle(orgKey), identity);
|
|
507
|
+
const lifecycle = useQuery({
|
|
508
|
+
queryKey: setupKey,
|
|
509
|
+
queryFn: async () => {
|
|
510
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.organization.setup).org(toOrgId(orgId)).setup.get());
|
|
511
|
+
},
|
|
512
|
+
enabled: wantsLifecycle && client !== null
|
|
513
|
+
});
|
|
514
|
+
const meKey = capxulKeys.orgMe(orgKey, actingAccount(identity));
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
if (!wantsLifecycle || client === null || orgKey === void 0) return;
|
|
517
|
+
const controller = new AbortController();
|
|
518
|
+
let closed = false;
|
|
519
|
+
let stop = null;
|
|
520
|
+
setObservation({
|
|
521
|
+
scopeKey,
|
|
522
|
+
status: "loading"
|
|
523
|
+
});
|
|
524
|
+
client.org(orgKey).setup.subscribe((snapshot) => {
|
|
525
|
+
if (closed) return;
|
|
526
|
+
if (snapshot.status === "value") {
|
|
527
|
+
const value = queryClient.setQueryData(setupKey, snapshot.value) ?? snapshot.value;
|
|
528
|
+
setObservation({
|
|
529
|
+
scopeKey,
|
|
530
|
+
status: "value",
|
|
531
|
+
value
|
|
532
|
+
});
|
|
533
|
+
} else if (snapshot.status === "error") setObservation({
|
|
534
|
+
scopeKey,
|
|
535
|
+
status: "error",
|
|
536
|
+
error: snapshot.error
|
|
537
|
+
});
|
|
538
|
+
}, { signal: controller.signal }).then((result) => {
|
|
539
|
+
if (!result.ok) {
|
|
540
|
+
if (!closed) setObservation({
|
|
541
|
+
scopeKey,
|
|
542
|
+
status: "error",
|
|
543
|
+
error: result.error
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
stop = result.value;
|
|
548
|
+
if (closed) stop();
|
|
549
|
+
}).catch((cause) => {
|
|
550
|
+
if (!closed) setObservation({
|
|
551
|
+
scopeKey,
|
|
552
|
+
status: "error",
|
|
553
|
+
error: isCapxulError(cause) ? cause : new CapxulError("UNKNOWN", "Organization setup subscription failed", { cause })
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
return () => {
|
|
557
|
+
closed = true;
|
|
558
|
+
controller.abort();
|
|
559
|
+
stop?.();
|
|
560
|
+
};
|
|
561
|
+
}, [
|
|
562
|
+
client,
|
|
563
|
+
orgKey,
|
|
564
|
+
wantsLifecycle,
|
|
565
|
+
queryClient,
|
|
566
|
+
scopeKey,
|
|
567
|
+
subscriptionRetry
|
|
568
|
+
]);
|
|
569
|
+
const currentSetup = observation.scopeKey === scopeKey && observation.status === "value" ? observation.value : void 0;
|
|
570
|
+
const wantsAuthority = wantsLifecycle && currentSetup?.status === "ready";
|
|
571
|
+
const authority = useQuery({
|
|
572
|
+
queryKey: meKey,
|
|
573
|
+
queryFn: async () => {
|
|
574
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.org.me).org(toOrgId(orgId)).me());
|
|
575
|
+
},
|
|
576
|
+
enabled: wantsAuthority && client !== null
|
|
577
|
+
});
|
|
578
|
+
const [checkedScope, setCheckedScope] = useState(null);
|
|
579
|
+
useEffect(() => {
|
|
580
|
+
if (!wantsAuthority) {
|
|
581
|
+
setCheckedScope(null);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
let active = true;
|
|
585
|
+
authority.refetch().then((result) => {
|
|
586
|
+
if (active && result.isSuccess) setCheckedScope(scopeKey);
|
|
587
|
+
});
|
|
588
|
+
return () => {
|
|
589
|
+
active = false;
|
|
590
|
+
};
|
|
591
|
+
}, [
|
|
592
|
+
scopeKey,
|
|
593
|
+
currentSetup,
|
|
594
|
+
wantsAuthority,
|
|
595
|
+
authority.refetch
|
|
596
|
+
]);
|
|
597
|
+
const authorityCurrent = checkedScope === scopeKey && authority.data !== void 0;
|
|
598
|
+
const observationError = observation.scopeKey === scopeKey && observation.status === "error" ? observation.error : void 0;
|
|
599
|
+
const lifecycleError = wantsLifecycle ? observationError ?? lifecycle.error : null;
|
|
600
|
+
const authorityError = wantsAuthority ? authority.error : null;
|
|
601
|
+
const lifecycleSettled = !wantsLifecycle || lifecycleError !== null || authorityError !== null || isLifecycleSettled(currentSetup) && (!wantsAuthority || authorityCurrent);
|
|
602
|
+
const retry = useCallback(() => {
|
|
603
|
+
if (sessionError !== null && client !== null) client.auth.getSession();
|
|
604
|
+
if (membershipError !== null) memberships.refetch();
|
|
605
|
+
if (observationError !== void 0) setSubscriptionRetry((attempt) => attempt + 1);
|
|
606
|
+
if (lifecycleError !== null) lifecycle.refetch();
|
|
607
|
+
if (authorityError !== null) authority.refetch();
|
|
608
|
+
}, [
|
|
609
|
+
client,
|
|
610
|
+
sessionError,
|
|
611
|
+
membershipError,
|
|
612
|
+
observationError,
|
|
613
|
+
lifecycleError,
|
|
614
|
+
memberships.refetch,
|
|
615
|
+
lifecycle.refetch,
|
|
616
|
+
authorityError,
|
|
617
|
+
authority.refetch
|
|
618
|
+
]);
|
|
619
|
+
const error = sessionError ?? membershipError ?? lifecycleError ?? authorityError;
|
|
620
|
+
const probe = probeOf(sessionError, membershipError, lifecycleError, authorityError);
|
|
621
|
+
const phase = decidePhase({
|
|
622
|
+
error,
|
|
623
|
+
settled: client !== null && identity !== null && !isRestoring$1(identity) && membershipsSettled && lifecycleSettled,
|
|
624
|
+
granted: scope.kind === "personal" ? claimed : claimed && orgId !== void 0 && hasMembership && isExactOrgReady(currentSetup, orgId) && authorityCurrent,
|
|
625
|
+
orgLaneSettled: identity !== null && isOrgLaneSettled(identity),
|
|
626
|
+
destination
|
|
627
|
+
});
|
|
628
|
+
return useMemo(() => ({
|
|
629
|
+
phase,
|
|
630
|
+
error,
|
|
631
|
+
probe,
|
|
632
|
+
destination,
|
|
633
|
+
retry
|
|
634
|
+
}), [
|
|
635
|
+
phase,
|
|
636
|
+
error,
|
|
637
|
+
probe,
|
|
638
|
+
destination,
|
|
639
|
+
retry
|
|
640
|
+
]);
|
|
641
|
+
}
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/access/dashboard-access.tsx
|
|
644
|
+
const PART_NAME = {
|
|
645
|
+
checking: "Checking",
|
|
646
|
+
error: "Error",
|
|
647
|
+
redirect: "Redirect",
|
|
648
|
+
granted: "Granted"
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* Mirrors `publicIdentityFailure`, which is internal to `@capxul/sdk`
|
|
652
|
+
* (`src/surface/_shared/effect-actor-bridge.ts`). The identity machine carries
|
|
653
|
+
* its own failure representation; `.Error` hands a `CapxulError`, so the
|
|
654
|
+
* projection happens once, here.
|
|
655
|
+
*/
|
|
656
|
+
function identityError(state) {
|
|
657
|
+
const { failure } = state;
|
|
658
|
+
if (failure.error !== void 0) return failure.error;
|
|
659
|
+
return new CapxulError(failure.code === "WORK_DIED" ? "UNKNOWN" : failure.code, failure.message, {
|
|
660
|
+
layer: "identity",
|
|
661
|
+
...failure.mode === void 0 ? {} : { mode: failure.mode }
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/** Exactly the Organization asked for, and ready to be used. */
|
|
665
|
+
function isExactOrgReady(lifecycle, orgId) {
|
|
666
|
+
return lifecycle?.status === "ready" && String(lifecycle.orgId) === orgId;
|
|
667
|
+
}
|
|
668
|
+
function isLifecycleSettled(lifecycle) {
|
|
669
|
+
return lifecycle !== void 0;
|
|
670
|
+
}
|
|
671
|
+
function destinationOrNull(identity) {
|
|
672
|
+
return identity === null ? null : resolveIdentityDestination(identity);
|
|
673
|
+
}
|
|
674
|
+
function isClaimedOrFalse(identity) {
|
|
675
|
+
return identity !== null && isClaimed$1(identity);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Which probe produced the surfaced error — the SAME precedence `error` itself
|
|
679
|
+
* is chosen by, written once so the code and its source can never disagree.
|
|
680
|
+
*/
|
|
681
|
+
function probeOf(session, membership, lifecycle, authority) {
|
|
682
|
+
if (session !== null) return "session";
|
|
683
|
+
if (membership !== null) return "membership";
|
|
684
|
+
if (lifecycle !== null) return "lifecycle";
|
|
685
|
+
if (authority !== null) return "authority";
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
const DashboardAccessContext = createContext(null);
|
|
689
|
+
function useDashboardAccessContext() {
|
|
690
|
+
const value = useContext(DashboardAccessContext);
|
|
691
|
+
if (value === null) throw new Error("CapxulDashboardAccess parts must be used inside <CapxulDashboardAccess>");
|
|
692
|
+
return value;
|
|
693
|
+
}
|
|
694
|
+
/** A part's own turn, and its registration for the missing-part warning. */
|
|
695
|
+
function usePart(phase) {
|
|
696
|
+
const { state, placed } = useDashboardAccessContext();
|
|
697
|
+
useEffect(() => {
|
|
698
|
+
placed.add(phase);
|
|
699
|
+
return () => {
|
|
700
|
+
placed.delete(phase);
|
|
701
|
+
};
|
|
702
|
+
}, [placed, phase]);
|
|
703
|
+
return state.phase === phase ? state : null;
|
|
704
|
+
}
|
|
705
|
+
function Root$5({ scope, children }) {
|
|
706
|
+
const state = useDashboardAccess(scope);
|
|
707
|
+
const [placed] = useState(() => /* @__PURE__ */ new Set());
|
|
708
|
+
const value = useMemo(() => ({
|
|
709
|
+
state,
|
|
710
|
+
placed
|
|
711
|
+
}), [state, placed]);
|
|
712
|
+
useEffect(() => {
|
|
713
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
|
|
714
|
+
if (placed.has(state.phase)) return;
|
|
715
|
+
console.warn(`<CapxulDashboardAccess> reached "${state.phase}" but no <CapxulDashboardAccess.${PART_NAME[state.phase]}> is placed, so nothing renders.`);
|
|
716
|
+
}, [placed, state.phase]);
|
|
717
|
+
return /* @__PURE__ */ jsx(DashboardAccessContext.Provider, {
|
|
718
|
+
value,
|
|
719
|
+
children
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
/** Hands nothing — today's app renders `null` here, exactly as it does now. */
|
|
723
|
+
function Checking({ children }) {
|
|
724
|
+
return usePart("checking") === null ? null : /* @__PURE__ */ jsx(Fragment, { children });
|
|
725
|
+
}
|
|
726
|
+
function DashboardError({ children }) {
|
|
727
|
+
const state = usePart("error");
|
|
728
|
+
if (state === null || state.error === null || state.probe === null) return null;
|
|
729
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children({
|
|
730
|
+
error: state.error,
|
|
731
|
+
probe: state.probe,
|
|
732
|
+
retry: state.retry
|
|
733
|
+
}) });
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Hands the destination and draws nothing. Reaching this part is a REQUEST to
|
|
737
|
+
* navigate, never an arrival: the app may cancel or supersede it, so nothing
|
|
738
|
+
* here — and nothing a consumer hangs off it — may claim a completed redirect
|
|
739
|
+
* (#1963 R05-1). The destination page's own normalized pageview proves arrival.
|
|
740
|
+
*/
|
|
741
|
+
function Redirect({ children }) {
|
|
742
|
+
const state = usePart("redirect");
|
|
743
|
+
if (state === null || state.destination === null) return null;
|
|
744
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children({ destination: state.destination }) });
|
|
745
|
+
}
|
|
746
|
+
function Granted({ children }) {
|
|
747
|
+
return usePart("granted") === null ? null : /* @__PURE__ */ jsx(Fragment, { children });
|
|
748
|
+
}
|
|
749
|
+
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
750
|
+
const CapxulDashboardAccess = Object.assign(Root$5, {
|
|
751
|
+
Checking,
|
|
752
|
+
Error: DashboardError,
|
|
753
|
+
Redirect,
|
|
754
|
+
Granted
|
|
755
|
+
});
|
|
756
|
+
//#endregion
|
|
757
|
+
//#region ../protocol/src/primitives/record.ts
|
|
758
|
+
/** A non-null object that is not an array: a value whose fields can be read by name. */
|
|
759
|
+
function isRecord(value) {
|
|
760
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
761
|
+
}
|
|
762
|
+
//#endregion
|
|
763
|
+
//#region src/profiles/journey-validation.ts
|
|
764
|
+
/** A stored journey is current, well-formed field by field, and consistent across fields. */
|
|
765
|
+
function isValidJourney(value) {
|
|
766
|
+
return isRecord(value) && value.version === 1 && hasJourneyFields(value) && hasConsistentJourney(value);
|
|
767
|
+
}
|
|
768
|
+
function hasJourneyFields(value) {
|
|
769
|
+
if (!hasOnlyKeys(value, [
|
|
770
|
+
"version",
|
|
771
|
+
"ownerId",
|
|
772
|
+
"journeyId",
|
|
773
|
+
"intent",
|
|
774
|
+
"entryPoint",
|
|
775
|
+
"origin",
|
|
776
|
+
"step",
|
|
777
|
+
"profile",
|
|
778
|
+
"organization",
|
|
779
|
+
"stableHandle",
|
|
780
|
+
"organizationId",
|
|
781
|
+
"submission"
|
|
782
|
+
])) return false;
|
|
783
|
+
if (!isBoundedId(value.ownerId)) return false;
|
|
784
|
+
if (!isBoundedId(value.journeyId) || !value.journeyId.startsWith("journey_")) return false;
|
|
785
|
+
if (value.intent !== "personal" && value.intent !== "organization") return false;
|
|
786
|
+
if (value.entryPoint !== "signup" && value.entryPoint !== "dashboard") return false;
|
|
787
|
+
if (value.step !== "profile" && value.step !== "organization" && value.step !== "provisioning") return false;
|
|
788
|
+
if (value.origin !== void 0 && !isOrigin(value.origin)) return false;
|
|
789
|
+
if (value.profile !== void 0 && !isProfileDraft(value.profile)) return false;
|
|
790
|
+
if (value.organization !== void 0 && !isOrganizationDraft(value.organization)) return false;
|
|
791
|
+
if (value.submission !== void 0 && !isCreateOrganizationSubmission(value.submission)) return false;
|
|
792
|
+
if (value.stableHandle !== void 0 && !isDraftText(value.stableHandle)) return false;
|
|
793
|
+
if (value.organizationId !== void 0 && !isBoundedId(value.organizationId)) return false;
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
/** The handle, intent, step and Organization agree with one another. */
|
|
797
|
+
function hasConsistentJourney(value) {
|
|
798
|
+
if (isRecord(value.organization) && value.stableHandle !== value.organization.handle) return false;
|
|
799
|
+
if (value.organization === void 0 && value.stableHandle !== void 0) return false;
|
|
800
|
+
if (value.intent === "personal" && (value.step === "organization" || value.organization !== void 0 || value.stableHandle !== void 0)) return false;
|
|
801
|
+
if (value.step === "organization" && value.intent !== "organization") return false;
|
|
802
|
+
if (value.organizationId !== void 0 && value.intent !== "organization") return false;
|
|
803
|
+
if (value.organizationId !== void 0 && value.step !== "provisioning") return false;
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
function isOrigin(value) {
|
|
807
|
+
if (!isRecord(value)) return false;
|
|
808
|
+
if (value.kind === "personal") return hasOnlyKeys(value, ["kind"]);
|
|
809
|
+
if (value.kind === "organization") return hasOnlyKeys(value, ["kind", "organizationId"]) && isBoundedId(value.organizationId);
|
|
810
|
+
return value.kind === "verification" && hasOnlyKeys(value, ["kind", "organizationId"]) && (value.organizationId === void 0 || isBoundedId(value.organizationId));
|
|
811
|
+
}
|
|
812
|
+
function isProfileDraft(value) {
|
|
813
|
+
if (!isRecord(value) || !hasOnlyKeys(value, [
|
|
814
|
+
"displayName",
|
|
815
|
+
"country",
|
|
816
|
+
"withdrawalAddress",
|
|
817
|
+
"handle",
|
|
818
|
+
"payoutAddresses",
|
|
819
|
+
"imageStorageId"
|
|
820
|
+
]) || !isDraftText(value.displayName) || !isDraftText(value.country) || !isDraftText(value.withdrawalAddress)) return false;
|
|
821
|
+
if (value.handle !== void 0 && !isDraftText(value.handle)) return false;
|
|
822
|
+
if (value.payoutAddresses !== void 0 && !isPayoutDraftList(value.payoutAddresses)) return false;
|
|
823
|
+
if (value.imageStorageId !== void 0 && !isBoundedId(value.imageStorageId)) return false;
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* The submitted organization (#1955). ONE validator now — the app's duplicate
|
|
828
|
+
* of it is deleted, so a submit cannot be written by two writers with
|
|
829
|
+
* disagreeing rules and then rejected here.
|
|
830
|
+
*/
|
|
831
|
+
function isCreateOrganizationSubmission(value) {
|
|
832
|
+
if (!isRecord(value) || !hasOnlyKeys(value, ["profileDetails", "organization"])) return false;
|
|
833
|
+
const { profileDetails, organization } = value;
|
|
834
|
+
if (!isRecord(profileDetails) || !hasOnlyKeys(profileDetails, [
|
|
835
|
+
"displayName",
|
|
836
|
+
"country",
|
|
837
|
+
"handle",
|
|
838
|
+
"imageStorageId",
|
|
839
|
+
"payoutAddresses"
|
|
840
|
+
]) || !isDraftText(profileDetails.displayName) || !isDraftText(profileDetails.country) || !isDraftText(profileDetails.handle)) return false;
|
|
841
|
+
if (profileDetails.imageStorageId !== void 0 && !isBoundedId(profileDetails.imageStorageId)) return false;
|
|
842
|
+
if (profileDetails.payoutAddresses !== void 0 && !isPayoutDraftList(profileDetails.payoutAddresses)) return false;
|
|
843
|
+
return isOrganizationDraft(organization);
|
|
844
|
+
}
|
|
845
|
+
function isPayoutDraftList(value) {
|
|
846
|
+
return Array.isArray(value) && value.length <= 10 && value.every((entry) => isRecord(entry) && hasOnlyKeys(entry, ["chain", "address"]) && (entry.chain === "evm" || entry.chain === "solana" || entry.chain === "starknet") && isDraftText(entry.address));
|
|
847
|
+
}
|
|
848
|
+
function isOrganizationDraft(value) {
|
|
849
|
+
if (!isRecord(value) || !hasOnlyKeys(value, [
|
|
850
|
+
"name",
|
|
851
|
+
"handle",
|
|
852
|
+
"country",
|
|
853
|
+
"bio",
|
|
854
|
+
"size",
|
|
855
|
+
"logoStorageId"
|
|
856
|
+
]) || !isDraftText(value.name) || !isDraftText(value.handle) || !isDraftText(value.country)) return false;
|
|
857
|
+
if (value.bio !== void 0 && !isDraftText(value.bio)) return false;
|
|
858
|
+
if (value.size !== void 0 && !isDraftText(value.size)) return false;
|
|
859
|
+
if (value.logoStorageId !== void 0 && !isBoundedId(value.logoStorageId)) return false;
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
function isDraftText(value) {
|
|
863
|
+
return typeof value === "string" && value.length <= 200;
|
|
864
|
+
}
|
|
865
|
+
function isBoundedId(value) {
|
|
866
|
+
return typeof value === "string" && value.length > 0 && value.length <= 160;
|
|
867
|
+
}
|
|
868
|
+
function hasOnlyKeys(value, allowed) {
|
|
869
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/profiles/journey-state.ts
|
|
873
|
+
const JOURNEY_KEY = "capxul.onboarding.journey.v1";
|
|
874
|
+
const VALIDATED_OWNER_KEY = "capxul.onboarding.validated-owner.v1";
|
|
875
|
+
const MAX_STORED_JOURNEY_LENGTH = 8192;
|
|
876
|
+
/**
|
|
877
|
+
* The owner whose stored journey may currently be attributed to observations.
|
|
878
|
+
* Persisted in sessionStorage rather than module memory so `currentOnboarding-
|
|
879
|
+
* JourneyId` is a pure function of durable state — attribution is the same
|
|
880
|
+
* regardless of the order of renders, reloads, or telemetry emits. Established
|
|
881
|
+
* only when an authenticated owner is (journey start, or an owner-matched load)
|
|
882
|
+
* and cleared at every auth boundary or on discard.
|
|
883
|
+
*/
|
|
884
|
+
function markOnboardingOwnerValidated(ownerId) {
|
|
885
|
+
try {
|
|
886
|
+
sessionStorage.setItem(VALIDATED_OWNER_KEY, ownerId);
|
|
887
|
+
} catch {}
|
|
888
|
+
}
|
|
889
|
+
function clearValidatedOnboardingOwner() {
|
|
890
|
+
try {
|
|
891
|
+
sessionStorage.removeItem(VALIDATED_OWNER_KEY);
|
|
892
|
+
} catch {}
|
|
893
|
+
}
|
|
894
|
+
function validatedOnboardingOwner() {
|
|
895
|
+
try {
|
|
896
|
+
return sessionStorage.getItem(VALIDATED_OWNER_KEY);
|
|
897
|
+
} catch {
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function startOnboardingJourney(input) {
|
|
902
|
+
const journey = {
|
|
903
|
+
version: 1,
|
|
904
|
+
ownerId: input.ownerId,
|
|
905
|
+
journeyId: `journey_${createRandomId()}`,
|
|
906
|
+
intent: input.intent,
|
|
907
|
+
entryPoint: input.entryPoint,
|
|
908
|
+
step: input.step ?? "profile",
|
|
909
|
+
...input.origin === void 0 ? {} : { origin: input.origin }
|
|
910
|
+
};
|
|
911
|
+
markOnboardingOwnerValidated(journey.ownerId);
|
|
912
|
+
saveOnboardingJourney(journey);
|
|
913
|
+
return journey;
|
|
914
|
+
}
|
|
915
|
+
function loadOnboardingJourney(expectedOwnerId) {
|
|
916
|
+
try {
|
|
917
|
+
const raw = sessionStorage.getItem(JOURNEY_KEY);
|
|
918
|
+
if (raw === null) return null;
|
|
919
|
+
if (raw.length > MAX_STORED_JOURNEY_LENGTH) return discardInvalidJourney();
|
|
920
|
+
const value = migrateStoredJourney(JSON.parse(raw));
|
|
921
|
+
if (!isValidJourney(value)) return discardInvalidJourney();
|
|
922
|
+
if (expectedOwnerId !== void 0 && value.ownerId !== expectedOwnerId) return discardInvalidJourney();
|
|
923
|
+
if (expectedOwnerId !== void 0) markOnboardingOwnerValidated(expectedOwnerId);
|
|
924
|
+
return value;
|
|
925
|
+
} catch {
|
|
926
|
+
return discardInvalidJourney();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function migrateStoredJourney(value) {
|
|
930
|
+
if (!isRecord(value) || !isRecord(value.origin) || value.origin.kind !== "proof") return value;
|
|
931
|
+
const migrated = {
|
|
932
|
+
...value,
|
|
933
|
+
origin: {
|
|
934
|
+
...value.origin,
|
|
935
|
+
kind: "verification"
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
try {
|
|
939
|
+
sessionStorage.setItem(JOURNEY_KEY, JSON.stringify(migrated));
|
|
940
|
+
} catch {}
|
|
941
|
+
return migrated;
|
|
942
|
+
}
|
|
943
|
+
function saveOnboardingJourney(journey) {
|
|
944
|
+
if (!isValidJourney(journey)) {
|
|
945
|
+
discardInvalidJourney();
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
sessionStorage.setItem(JOURNEY_KEY, JSON.stringify(journey));
|
|
950
|
+
} catch {}
|
|
951
|
+
}
|
|
952
|
+
function currentOnboardingJourneyId() {
|
|
953
|
+
const journey = loadOnboardingJourney();
|
|
954
|
+
const owner = validatedOnboardingOwner();
|
|
955
|
+
return journey !== null && owner !== null && journey.ownerId === owner ? journey.journeyId : void 0;
|
|
956
|
+
}
|
|
957
|
+
/** Stop observation attribution while no authenticated owner is established. */
|
|
958
|
+
function invalidateOnboardingJourneyObservation() {
|
|
959
|
+
clearValidatedOnboardingOwner();
|
|
960
|
+
}
|
|
961
|
+
/** Where the journey stands — the pure position apps map to their routes. */
|
|
962
|
+
function onboardingJourneyPosition(journey) {
|
|
963
|
+
const verification = journey.origin?.kind === "verification";
|
|
964
|
+
if (journey.organizationId !== void 0) return {
|
|
965
|
+
intent: journey.intent,
|
|
966
|
+
step: "provisioning",
|
|
967
|
+
organizationId: journey.organizationId,
|
|
968
|
+
verification
|
|
969
|
+
};
|
|
970
|
+
return {
|
|
971
|
+
intent: journey.intent,
|
|
972
|
+
step: journey.step,
|
|
973
|
+
verification
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* The interruption-recovery decision (behavior map §4): a journey that does
|
|
978
|
+
* not match the ready destination the app just rendered must resume; a
|
|
979
|
+
* matching one needs no recovery. Returns the journey to resume, or null.
|
|
980
|
+
*/
|
|
981
|
+
function activeOnboardingRecovery(ownerId, renderedReadyDestination) {
|
|
982
|
+
const journey = loadOnboardingJourney(ownerId);
|
|
983
|
+
if (journey === null) return null;
|
|
984
|
+
return (renderedReadyDestination?.kind === "personal" ? journey.intent === "personal" && journey.step === "provisioning" && journey.organizationId === void 0 : renderedReadyDestination?.kind === "organization" && journey.intent === "organization" && journey.step === "provisioning" && journey.organizationId === renderedReadyDestination.organizationId) ? null : journey;
|
|
985
|
+
}
|
|
986
|
+
function acknowledgeOnboardingDestination(destination, ownerId) {
|
|
987
|
+
const journey = loadOnboardingJourney(ownerId);
|
|
988
|
+
if (journey === null) return false;
|
|
989
|
+
if (!(destination.kind === "personal" ? journey.intent === "personal" && journey.step === "provisioning" : journey.intent === "organization" && journey.step === "provisioning" && journey.organizationId === destination.organizationId)) return false;
|
|
990
|
+
clearOnboardingJourney();
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
993
|
+
function clearOnboardingJourney() {
|
|
994
|
+
clearValidatedOnboardingOwner();
|
|
995
|
+
try {
|
|
996
|
+
sessionStorage.removeItem(JOURNEY_KEY);
|
|
997
|
+
} catch {}
|
|
998
|
+
}
|
|
999
|
+
function discardInvalidJourney() {
|
|
1000
|
+
clearValidatedOnboardingOwner();
|
|
1001
|
+
try {
|
|
1002
|
+
sessionStorage.removeItem(JOURNEY_KEY);
|
|
1003
|
+
} catch {}
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
function createRandomId() {
|
|
1007
|
+
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
1008
|
+
return Math.random().toString(36).slice(2);
|
|
1009
|
+
}
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/platform/use-debounced-value.ts
|
|
1012
|
+
/** The value as of `delayMs` after its last change (initial value immediate). */
|
|
1013
|
+
function useDebouncedValue(value, delayMs) {
|
|
1014
|
+
const [debounced, setDebounced] = useState(value);
|
|
1015
|
+
useEffect(() => {
|
|
1016
|
+
const timer = setTimeout(() => setDebounced(value), delayMs);
|
|
1017
|
+
return () => clearTimeout(timer);
|
|
1018
|
+
}, [value, delayMs]);
|
|
1019
|
+
return debounced;
|
|
1020
|
+
}
|
|
1021
|
+
//#endregion
|
|
1022
|
+
//#region src/profiles/use-capxul-handle-availability.ts
|
|
1023
|
+
/**
|
|
1024
|
+
* #1062: debounced availability probe for the handle the user is typing.
|
|
1025
|
+
* Disabled until the candidate reaches the 3-character floor; a malformed or
|
|
1026
|
+
* reserved candidate surfaces as the query's error (INVALID_INPUT with the
|
|
1027
|
+
* boundary's message), not as `available: false` — only a handle someone
|
|
1028
|
+
* else owns is "taken".
|
|
1029
|
+
*/
|
|
1030
|
+
function useCapxulHandleAvailability(handle, options) {
|
|
1031
|
+
const client = useCapxulClientOrNull();
|
|
1032
|
+
const candidate = useDebouncedValue(handle.trim(), options?.debounceMs ?? 300);
|
|
1033
|
+
return useQuery({
|
|
1034
|
+
queryKey: capxulKeys.handleAvailability(candidate),
|
|
1035
|
+
queryFn: async () => {
|
|
1036
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.identity.handleAvailable).identity.handleAvailable(candidate));
|
|
1037
|
+
},
|
|
1038
|
+
enabled: client !== null && candidate.length >= 3 && (options?.enabled ?? true)
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/profiles/use-capxul-profile.ts
|
|
1043
|
+
function useCapxulProfile() {
|
|
1044
|
+
const client = useCapxulClientOrNull();
|
|
1045
|
+
const identity = useCapxulIdentityOrNull();
|
|
1046
|
+
return useQuery({
|
|
1047
|
+
queryKey: [...capxulKeys.profile, identity?.phase === "authenticated" ? identity.session.authUserId : null],
|
|
1048
|
+
queryFn: async () => unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.identity.loadCurrent).identity.loadCurrent()),
|
|
1049
|
+
enabled: client !== null && identity?.phase === "authenticated"
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/accounts/use-capxul-account-balance.ts
|
|
1054
|
+
function useCapxulAccountBalance(options) {
|
|
1055
|
+
const client = useCapxulClientOrNull();
|
|
1056
|
+
const identity = useCapxulIdentityOrNull();
|
|
1057
|
+
return useQuery({
|
|
1058
|
+
queryKey: accountKey(capxulKeys.accountBalance, identity),
|
|
1059
|
+
queryFn: async () => {
|
|
1060
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.accounts.read).accounts.read());
|
|
1061
|
+
},
|
|
1062
|
+
enabled: client !== null && (options?.enabled ?? true)
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
//#endregion
|
|
1066
|
+
//#region src/accounts/use-capxul-account-fund.ts
|
|
1067
|
+
function useCapxulAccountFund() {
|
|
1068
|
+
const client = useCapxulClientOrNull();
|
|
1069
|
+
const queryClient = useQueryClient();
|
|
1070
|
+
return useMutation({
|
|
1071
|
+
mutationFn: async (amount) => {
|
|
1072
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS._internal.accounts.fund)._internal.accounts.fund(amount));
|
|
1073
|
+
},
|
|
1074
|
+
onSuccess: async () => {
|
|
1075
|
+
await queryClient.invalidateQueries({ queryKey: capxulKeys.accountBalance });
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region src/accounts/use-capxul-holdings.ts
|
|
1081
|
+
function useCapxulHoldings(params) {
|
|
1082
|
+
const client = useCapxulClientOrNull();
|
|
1083
|
+
const identity = useCapxulIdentityOrNull();
|
|
1084
|
+
const actor = params?.actor;
|
|
1085
|
+
return useQuery({
|
|
1086
|
+
queryKey: accountKey(capxulKeys.holdings(actor), identity),
|
|
1087
|
+
queryFn: async () => {
|
|
1088
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.holdings.current).holdings.current(actor === void 0 ? void 0 : { actor }));
|
|
1089
|
+
},
|
|
1090
|
+
enabled: client !== null && identity !== null && isClaimed$1(identity)
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/payments/asset-selections.ts
|
|
1095
|
+
/**
|
|
1096
|
+
* One Send `.Asset` option per position the Account holds, taken from
|
|
1097
|
+
* `account.balances` — never from `account.available`, which is the Account's
|
|
1098
|
+
* fiat VALUATION of those positions (ADR-0028 R5/R6). Labelling the valuation
|
|
1099
|
+
* was the false identity this cut removes, and it collapsed USDC+USDT into one
|
|
1100
|
+
* "USD" row.
|
|
1101
|
+
*
|
|
1102
|
+
* The option is keyed on the ASSET id, never the symbol and never the account:
|
|
1103
|
+
* several positions share one account id, and a symbol is display only
|
|
1104
|
+
* (ADR-0028 R3). Every row is listed, so a real multi-position Account still
|
|
1105
|
+
* offers its holdings.
|
|
1106
|
+
*/
|
|
1107
|
+
function assetSelectionsFromAccount(account) {
|
|
1108
|
+
if (account === null) return [];
|
|
1109
|
+
return account.balances.map((position) => {
|
|
1110
|
+
return {
|
|
1111
|
+
option: {
|
|
1112
|
+
id: position.asset,
|
|
1113
|
+
symbol: position.symbol,
|
|
1114
|
+
availableDisplay: formatAssetAmount({
|
|
1115
|
+
asset: position.asset,
|
|
1116
|
+
value: position.available
|
|
1117
|
+
})
|
|
1118
|
+
},
|
|
1119
|
+
money: {
|
|
1120
|
+
asset: position.asset,
|
|
1121
|
+
value: position.available
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
//#endregion
|
|
1127
|
+
//#region src/payments/invalidate-money-state.ts
|
|
1128
|
+
/**
|
|
1129
|
+
* Invalidate what a settled money mutation changed, for the actor it ran as.
|
|
1130
|
+
*
|
|
1131
|
+
* #1145 (DEMOLITION §D5): the insights, address-book and org-`account`
|
|
1132
|
+
* invalidations this used to fan out to named queries that no hook creates any
|
|
1133
|
+
* more — the actor-scope family and `useCapxulOrganizationAccount` are deleted.
|
|
1134
|
+
* Invalidating a key nothing reads is a no-op that reads as coverage.
|
|
1135
|
+
*/
|
|
1136
|
+
async function invalidateMoneyState(queryClient, input) {
|
|
1137
|
+
const invalidations = [queryClient.invalidateQueries({ queryKey: capxulKeys.payments })];
|
|
1138
|
+
if (input.payment !== void 0) invalidations.push(queryClient.invalidateQueries({ queryKey: capxulKeys.payment(input.payment.id) }));
|
|
1139
|
+
invalidations.push(queryClient.invalidateQueries({ queryKey: input.actor.kind === "personal" ? capxulKeys.accountBalance : capxulKeys.orgTreasury(toOrgId(input.actor.organizationId)) }));
|
|
1140
|
+
await Promise.all(invalidations);
|
|
1141
|
+
}
|
|
1142
|
+
//#endregion
|
|
1143
|
+
//#region ../protocol/src/payments/request-finality.ts
|
|
1144
|
+
/** Only an authoritative failed-key response or confirmed durable closure resolves a key. */
|
|
1145
|
+
function isFinalPaymentRequestError(error) {
|
|
1146
|
+
return error.details?.paymentRequestFinality === "closed" || error.code === "INVALID_INPUT" && error.details?.field === "requestKey" && error.details.paymentRequestFinality === "failed";
|
|
1147
|
+
}
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region ../protocol/src/payments/attempt-locks.ts
|
|
1150
|
+
/** Prefix of the storage slot (and its locks) one payment intent's request key lives in. */
|
|
1151
|
+
const PAYMENT_REQUEST_KEY_SLOT_PREFIX = "capxul.payment.request-key.v3";
|
|
1152
|
+
function isPaymentRequestKeyState(value) {
|
|
1153
|
+
return isRecord(value) && typeof value.key === "string" && Array.isArray(value.active) && value.active.every((attempt) => typeof attempt === "string") && typeof value.resolved === "boolean";
|
|
1154
|
+
}
|
|
1155
|
+
/** The lock one attempt holds for the life of its payment call. */
|
|
1156
|
+
function paymentAttemptLockName(slot, attemptId) {
|
|
1157
|
+
return `${slot}:attempt:${attemptId}`;
|
|
1158
|
+
}
|
|
1159
|
+
/** The attempt ids whose locks are held for this slot right now, in any tab. */
|
|
1160
|
+
async function heldPaymentAttemptIds(manager, slot) {
|
|
1161
|
+
const prefix = `${slot}:attempt:`;
|
|
1162
|
+
return ((await manager.query()).held ?? []).map((lock) => lock.name).filter((name) => name?.startsWith(prefix) === true).map((name) => name.slice(prefix.length));
|
|
1163
|
+
}
|
|
1164
|
+
/** Hold the named lock until the returned release runs. Resolves once it is acquired. */
|
|
1165
|
+
async function holdPaymentAttemptLock(manager, name) {
|
|
1166
|
+
let acquired;
|
|
1167
|
+
let release;
|
|
1168
|
+
const ready = new Promise((resolve) => {
|
|
1169
|
+
acquired = resolve;
|
|
1170
|
+
});
|
|
1171
|
+
const held = manager.request(name, async () => {
|
|
1172
|
+
acquired?.();
|
|
1173
|
+
await new Promise((resolve) => {
|
|
1174
|
+
release = resolve;
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
await Promise.race([ready, held.then(() => Promise.reject(/* @__PURE__ */ new Error("Payment attempt lock ended before acquisition")))]);
|
|
1178
|
+
return () => {
|
|
1179
|
+
release?.();
|
|
1180
|
+
held.catch(() => void 0);
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
const attemptReleases = /* @__PURE__ */ new Set();
|
|
1184
|
+
let pagehideInstalled = false;
|
|
1185
|
+
/** Release the attempt's lock when the page goes away; the returned function forgets it. */
|
|
1186
|
+
function releasePaymentAttemptOnPagehide(release) {
|
|
1187
|
+
attemptReleases.add(release);
|
|
1188
|
+
if (!pagehideInstalled) {
|
|
1189
|
+
pagehideInstalled = true;
|
|
1190
|
+
window.addEventListener("pagehide", () => {
|
|
1191
|
+
for (const releaseAttempt of attemptReleases) releaseAttempt();
|
|
1192
|
+
attemptReleases.clear();
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
return () => attemptReleases.delete(release);
|
|
1196
|
+
}
|
|
1197
|
+
//#endregion
|
|
1198
|
+
//#region src/payments/payment-request-key.ts
|
|
1199
|
+
async function storageKey$1(operation, intent) {
|
|
1200
|
+
return `${PAYMENT_REQUEST_KEY_SLOT_PREFIX}:${await fingerprintPaymentIntent({
|
|
1201
|
+
operation,
|
|
1202
|
+
intent
|
|
1203
|
+
})}`;
|
|
1204
|
+
}
|
|
1205
|
+
function paymentLockManager() {
|
|
1206
|
+
const manager = navigator.locks;
|
|
1207
|
+
if (manager === void 0 || typeof manager.query !== "function") throw Errors.providerError("sdk-react", "paymentRequestKey", /* @__PURE__ */ new Error("Web Locks are required for payment request keys"));
|
|
1208
|
+
return manager;
|
|
1209
|
+
}
|
|
1210
|
+
function readState(key) {
|
|
1211
|
+
const stored = localStorage.getItem(key);
|
|
1212
|
+
if (stored === null) return null;
|
|
1213
|
+
const state = JSON.parse(stored);
|
|
1214
|
+
if (!isPaymentRequestKeyState(state)) throw Errors.providerError("sdk-react", "paymentRequestKey", /* @__PURE__ */ new TypeError("Invalid payment request key state"));
|
|
1215
|
+
return state;
|
|
1216
|
+
}
|
|
1217
|
+
async function beginPaymentRequestKey(operation, intent) {
|
|
1218
|
+
let releaseAttemptLock;
|
|
1219
|
+
let forgetPagehideRelease;
|
|
1220
|
+
try {
|
|
1221
|
+
const storageSlot = await storageKey$1(operation, intent);
|
|
1222
|
+
const attemptId = `attempt_${crypto.randomUUID()}`;
|
|
1223
|
+
const manager = paymentLockManager();
|
|
1224
|
+
releaseAttemptLock = await holdPaymentAttemptLock(manager, paymentAttemptLockName(storageSlot, attemptId));
|
|
1225
|
+
forgetPagehideRelease = releasePaymentAttemptOnPagehide(releaseAttemptLock);
|
|
1226
|
+
const key = await manager.request(storageSlot, async () => {
|
|
1227
|
+
let state = readState(storageSlot) ?? {
|
|
1228
|
+
key: `pay_${Array.from(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("")}`,
|
|
1229
|
+
active: [],
|
|
1230
|
+
resolved: false
|
|
1231
|
+
};
|
|
1232
|
+
const registered = state.active.includes(attemptId);
|
|
1233
|
+
const active = await heldPaymentAttemptIds(manager, storageSlot);
|
|
1234
|
+
if (!registered && active.every((id) => id === attemptId) && state.resolved) state = {
|
|
1235
|
+
key: `pay_${crypto.randomUUID()}`,
|
|
1236
|
+
active: [],
|
|
1237
|
+
resolved: false
|
|
1238
|
+
};
|
|
1239
|
+
state.active = active;
|
|
1240
|
+
localStorage.setItem(storageSlot, JSON.stringify(state));
|
|
1241
|
+
return state.key;
|
|
1242
|
+
});
|
|
1243
|
+
let finished = false;
|
|
1244
|
+
return {
|
|
1245
|
+
key,
|
|
1246
|
+
finish: async (succeeded) => {
|
|
1247
|
+
if (finished) return;
|
|
1248
|
+
try {
|
|
1249
|
+
await manager.request(storageSlot, async () => {
|
|
1250
|
+
const state = readState(storageSlot);
|
|
1251
|
+
if (state === null || state.key !== key) return;
|
|
1252
|
+
const attemptIndex = state.active.indexOf(attemptId);
|
|
1253
|
+
if (attemptIndex < 0) return;
|
|
1254
|
+
state.active.splice(attemptIndex, 1);
|
|
1255
|
+
state.active = (await heldPaymentAttemptIds(manager, storageSlot)).filter((id) => id !== attemptId);
|
|
1256
|
+
state.resolved = succeeded;
|
|
1257
|
+
if (state.active.length === 0 && state.resolved) localStorage.removeItem(storageSlot);
|
|
1258
|
+
else localStorage.setItem(storageSlot, JSON.stringify(state));
|
|
1259
|
+
});
|
|
1260
|
+
finished = true;
|
|
1261
|
+
} finally {
|
|
1262
|
+
forgetPagehideRelease?.();
|
|
1263
|
+
releaseAttemptLock?.();
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
} catch (cause) {
|
|
1268
|
+
forgetPagehideRelease?.();
|
|
1269
|
+
releaseAttemptLock?.();
|
|
1270
|
+
throw Errors.providerError("sdk-react", "paymentRequestKey", cause);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
async function withPaymentRequestKey(operation, intent, work) {
|
|
1274
|
+
const attempt = await beginPaymentRequestKey(operation, intent);
|
|
1275
|
+
try {
|
|
1276
|
+
const result = await work(attempt.key);
|
|
1277
|
+
try {
|
|
1278
|
+
await attempt.finish(true);
|
|
1279
|
+
} catch {}
|
|
1280
|
+
return result;
|
|
1281
|
+
} catch (cause) {
|
|
1282
|
+
try {
|
|
1283
|
+
await attempt.finish(isCapxulError(cause) && isFinalPaymentRequestError(cause));
|
|
1284
|
+
} catch {}
|
|
1285
|
+
throw cause;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/platform/reject-unresolved-actor.ts
|
|
1290
|
+
/**
|
|
1291
|
+
* Guard for mutation hooks whose variables carry an optional `actor` field
|
|
1292
|
+
* (`useCapxulPay`, `useCapxulAddDestination`,
|
|
1293
|
+
* `useCapxulRemoveDestination`). Mirrors `requireActorScope` on the query path:
|
|
1294
|
+
* an EXPLICITLY-passed `actor: undefined` (the `capxulOrgScope(notYetLoadedOrgId)`
|
|
1295
|
+
* footgun) must fail loudly rather than silently fall back to the personal
|
|
1296
|
+
* scope. Omitting the `actor` key entirely keeps the personal-scope default.
|
|
1297
|
+
*
|
|
1298
|
+
* Throws `CapxulError` code `INVALID_INPUT` with `details.field === "actor"`;
|
|
1299
|
+
* called inside an async mutationFn it surfaces as `mutation.error: CapxulError`.
|
|
1300
|
+
*/
|
|
1301
|
+
function rejectUnresolvedActor(variables, operation) {
|
|
1302
|
+
if (Object.hasOwn(variables, "actor") && "actor" in variables && variables.actor === void 0) throw Errors.invalidInput("actor", `explicitly provided but undefined for ${operation} — actor scope not yet resolved; omit actor for the personal scope`);
|
|
1303
|
+
}
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/payments/use-capxul-money.ts
|
|
1306
|
+
function useCapxulPay() {
|
|
1307
|
+
const client = useCapxulClientOrNull();
|
|
1308
|
+
const identity = useCapxulIdentityOrNull();
|
|
1309
|
+
const actorId = identity?.phase === "authenticated" ? identity.session.authUserId : "signed-out";
|
|
1310
|
+
const backendScope = client === null ? "pending" : `${client._internal.bootstrap.convexUrl}:${String(client._internal.bootstrap.chainId)}`;
|
|
1311
|
+
const queryClient = useQueryClient();
|
|
1312
|
+
return useMutation({
|
|
1313
|
+
mutationFn: async (input) => {
|
|
1314
|
+
rejectUnresolvedActor(input, CAPXUL_OPERATIONS.payments.pay);
|
|
1315
|
+
const bootstrappedClient = requireBootstrappedClient(client, CAPXUL_OPERATIONS.payments.pay);
|
|
1316
|
+
return withPaymentRequestKey("personal-pay", {
|
|
1317
|
+
actorId,
|
|
1318
|
+
backendScope,
|
|
1319
|
+
input
|
|
1320
|
+
}, async (requestKey) => unwrapCapxulResult(await bootstrappedClient.payments.pay({
|
|
1321
|
+
...input,
|
|
1322
|
+
requestKey
|
|
1323
|
+
})));
|
|
1324
|
+
},
|
|
1325
|
+
onSuccess: async (payment) => {
|
|
1326
|
+
await invalidateMoneyState(queryClient, {
|
|
1327
|
+
actor: { kind: "personal" },
|
|
1328
|
+
payment
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
function useCapxulPayments(options) {
|
|
1334
|
+
const client = useCapxulClientOrNull();
|
|
1335
|
+
const identity = useCapxulIdentityOrNull();
|
|
1336
|
+
return useQuery({
|
|
1337
|
+
queryKey: accountKey(capxulKeys.payments, identity),
|
|
1338
|
+
queryFn: async () => {
|
|
1339
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.payments.list).payments.list());
|
|
1340
|
+
},
|
|
1341
|
+
enabled: client !== null && identity !== null && isClaimed$1(identity) && (options?.enabled ?? true)
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
function useCapxulPayment(paymentId, options) {
|
|
1345
|
+
const client = useCapxulClientOrNull();
|
|
1346
|
+
const identity = useCapxulIdentityOrNull();
|
|
1347
|
+
return useQuery({
|
|
1348
|
+
queryKey: accountKey(capxulKeys.payment(paymentId), identity),
|
|
1349
|
+
queryFn: async () => {
|
|
1350
|
+
if (paymentId === void 0) throw Errors.invalidInput("paymentId", "required for payments.get");
|
|
1351
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.payments.get).payments.get(paymentId));
|
|
1352
|
+
},
|
|
1353
|
+
enabled: client !== null && paymentId !== void 0 && (options?.enabled ?? true)
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/payments/use-capxul-organization-payments.ts
|
|
1358
|
+
/**
|
|
1359
|
+
* `orgId` may be absent because a screen has no branded org id until its org
|
|
1360
|
+
* context loads (`OrgView.id` is the branded one). An absent id refuses the
|
|
1361
|
+
* mutation the way every other org read refuses one, rather than forcing a
|
|
1362
|
+
* `as OrgId` cast at the call site.
|
|
1363
|
+
*/
|
|
1364
|
+
function useCapxulOrganizationPay(orgId) {
|
|
1365
|
+
const client = useCapxulClientOrNull();
|
|
1366
|
+
const backendScope = client === null ? "pending" : `${client._internal.bootstrap.convexUrl}:${String(client._internal.bootstrap.chainId)}`;
|
|
1367
|
+
const queryClient = useQueryClient();
|
|
1368
|
+
return useMutation({
|
|
1369
|
+
mutationFn: async (input) => {
|
|
1370
|
+
if (orgId === void 0) throw Errors.invalidInput("orgId", "required for useCapxulOrganizationPay");
|
|
1371
|
+
const bootstrapped = requireBootstrappedClient(client, CAPXUL_OPERATIONS.organizationPayments.pay);
|
|
1372
|
+
return withPaymentRequestKey("organization-pay", {
|
|
1373
|
+
backendScope,
|
|
1374
|
+
orgId,
|
|
1375
|
+
...input
|
|
1376
|
+
}, async (requestKey) => unwrapCapxulResult(await bootstrapped.org(orgId).payments.pay({
|
|
1377
|
+
...input,
|
|
1378
|
+
requestKey
|
|
1379
|
+
})));
|
|
1380
|
+
},
|
|
1381
|
+
onSuccess: async (payment) => {
|
|
1382
|
+
if (orgId === void 0) return;
|
|
1383
|
+
return invalidateMoneyState(queryClient, {
|
|
1384
|
+
actor: {
|
|
1385
|
+
kind: "organization",
|
|
1386
|
+
organizationId: orgId
|
|
1387
|
+
},
|
|
1388
|
+
payment
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
function useCapxulOrganizationPayBatch(orgId) {
|
|
1394
|
+
const client = useCapxulClientOrNull();
|
|
1395
|
+
const backendScope = client === null ? "pending" : `${client._internal.bootstrap.convexUrl}:${String(client._internal.bootstrap.chainId)}`;
|
|
1396
|
+
const queryClient = useQueryClient();
|
|
1397
|
+
return useMutation({
|
|
1398
|
+
mutationFn: async (input) => {
|
|
1399
|
+
const bootstrapped = requireBootstrappedClient(client, CAPXUL_OPERATIONS.organizationPayments.payBatch);
|
|
1400
|
+
return withPaymentRequestKey("organization-pay-batch", {
|
|
1401
|
+
backendScope,
|
|
1402
|
+
orgId,
|
|
1403
|
+
...input
|
|
1404
|
+
}, async (requestKey) => unwrapCapxulResult(await bootstrapped.org(orgId).payments.payBatch({
|
|
1405
|
+
...input,
|
|
1406
|
+
requestKey
|
|
1407
|
+
})));
|
|
1408
|
+
},
|
|
1409
|
+
onSuccess: async (payments) => {
|
|
1410
|
+
return Promise.all(payments.map((payment) => invalidateMoneyState(queryClient, {
|
|
1411
|
+
actor: {
|
|
1412
|
+
kind: "organization",
|
|
1413
|
+
organizationId: orgId
|
|
1414
|
+
},
|
|
1415
|
+
payment
|
|
1416
|
+
})));
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
//#endregion
|
|
1421
|
+
//#region src/payments/write-names-asset.ts
|
|
1422
|
+
/**
|
|
1423
|
+
* Identity law: "A payment names an asset or inherits it from an authorized
|
|
1424
|
+
* fact. Missing asset refuses." The rule was recorded in the retired
|
|
1425
|
+
* treasury-cut handoff (removed from the current tree by the exact-asset
|
|
1426
|
+
* contraction; its record survives in Git history).
|
|
1427
|
+
*
|
|
1428
|
+
* A Capxul payment writer takes `PaymentMoney` — a peg currency, a value and
|
|
1429
|
+
* `decimals` — and settles one fixed legacy token. No `AssetId` crosses that
|
|
1430
|
+
* seam, so a payment composed from a named position cannot name the asset it
|
|
1431
|
+
* moves: a position selected as USDT authorizes a different token.
|
|
1432
|
+
*
|
|
1433
|
+
* `false` until the writer takes the command's asset. The Send and Payroll
|
|
1434
|
+
* composers read this to refuse a submission rather than authorize a token the
|
|
1435
|
+
* person did not pick.
|
|
1436
|
+
*/
|
|
1437
|
+
function writeNamesAsset() {
|
|
1438
|
+
return false;
|
|
1439
|
+
}
|
|
1440
|
+
//#endregion
|
|
1441
|
+
//#region src/platform/signer-status.ts
|
|
1442
|
+
const NO_SUBSCRIPTION = () => () => {};
|
|
1443
|
+
function useSignerStatus(client) {
|
|
1444
|
+
const signer = client?.signer;
|
|
1445
|
+
return useSyncExternalStore(useMemo(() => signer === void 0 ? NO_SUBSCRIPTION : (listener) => signer.subscribe(listener), [signer]), () => signer === void 0 ? "unknown" : signer.status(), () => "unknown");
|
|
1446
|
+
}
|
|
1447
|
+
//#endregion
|
|
1448
|
+
//#region src/payments/use-recipient-resolution.ts
|
|
1449
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1450
|
+
const PARTY_ID_RE = /^party_[0-9A-Za-z]+$/;
|
|
1451
|
+
const RESOLVE_DEBOUNCE_MS = 400;
|
|
1452
|
+
function parseRecipient(text) {
|
|
1453
|
+
const trimmed = text.trim();
|
|
1454
|
+
if (trimmed === "") return { status: "empty" };
|
|
1455
|
+
if (PARTY_ID_RE.test(trimmed)) return {
|
|
1456
|
+
status: "valid",
|
|
1457
|
+
reference: {
|
|
1458
|
+
kind: "party",
|
|
1459
|
+
partyId: toPartyId(trimmed)
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
if (EVM_ADDRESS_RE.test(trimmed)) return {
|
|
1463
|
+
status: "valid",
|
|
1464
|
+
reference: {
|
|
1465
|
+
kind: "address",
|
|
1466
|
+
address: toEvmAddress(trimmed)
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
if (EMAIL_RE.test(trimmed)) return {
|
|
1470
|
+
status: "valid",
|
|
1471
|
+
reference: {
|
|
1472
|
+
kind: "email",
|
|
1473
|
+
email: trimmed.toLowerCase()
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
const handle = trimmed.replace(/^@/, "").toLowerCase();
|
|
1477
|
+
if (HANDLE_RE.test(handle)) return {
|
|
1478
|
+
status: "valid",
|
|
1479
|
+
reference: {
|
|
1480
|
+
kind: "handle",
|
|
1481
|
+
handle
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
return { status: "invalid" };
|
|
1485
|
+
}
|
|
1486
|
+
function sameReference(left, right) {
|
|
1487
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
1488
|
+
}
|
|
1489
|
+
function publicRecipientValue(value) {
|
|
1490
|
+
if (value === null) return null;
|
|
1491
|
+
if (value.status === "known_party") return value.handle ?? value.label;
|
|
1492
|
+
return value.status === "external_address" ? value.safeAddress : null;
|
|
1493
|
+
}
|
|
1494
|
+
function recipientStatus(parsed, settled, acknowledged) {
|
|
1495
|
+
if (parsed.status === "empty" || parsed.status === "invalid") return parsed.status;
|
|
1496
|
+
if (settled === null) return "checking";
|
|
1497
|
+
if (!settled.ok) return settled.error.code === "INVALID_INPUT" ? "not-found" : "failed";
|
|
1498
|
+
if (settled.value.status === "known_party") return "known-party";
|
|
1499
|
+
if (settled.value.status === "new_email") return "new-email";
|
|
1500
|
+
return acknowledged ? "external-ready" : "external-warning";
|
|
1501
|
+
}
|
|
1502
|
+
function useRecipientResolution(client, scope, organizationId) {
|
|
1503
|
+
const [text, setText] = useState("");
|
|
1504
|
+
const parsed = useMemo(() => parseRecipient(text), [text]);
|
|
1505
|
+
const reference = parsed.status === "valid" ? parsed.reference : null;
|
|
1506
|
+
const debouncedReference = useDebouncedValue(reference, RESOLVE_DEBOUNCE_MS);
|
|
1507
|
+
const debouncedText = useDebouncedValue(text, RESOLVE_DEBOUNCE_MS);
|
|
1508
|
+
const [outcome, setOutcome] = useState(null);
|
|
1509
|
+
const [acknowledgedOutcome, setAcknowledgedOutcome] = useState(null);
|
|
1510
|
+
const capturedAcknowledgement = useRef(null);
|
|
1511
|
+
const change = useCallback((next) => {
|
|
1512
|
+
if (next === text) return;
|
|
1513
|
+
setAcknowledgedOutcome(null);
|
|
1514
|
+
setOutcome(null);
|
|
1515
|
+
setText(next);
|
|
1516
|
+
}, [text]);
|
|
1517
|
+
useEffect(() => {
|
|
1518
|
+
if (debouncedReference === null || client === null) {
|
|
1519
|
+
setOutcome(null);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const controller = new AbortController();
|
|
1523
|
+
setOutcome(null);
|
|
1524
|
+
client.targets.resolve(debouncedReference, { signal: controller.signal }).then((result) => {
|
|
1525
|
+
if (!controller.signal.aborted) setOutcome({
|
|
1526
|
+
client,
|
|
1527
|
+
scope,
|
|
1528
|
+
text: debouncedText,
|
|
1529
|
+
input: debouncedReference,
|
|
1530
|
+
result
|
|
1531
|
+
});
|
|
1532
|
+
});
|
|
1533
|
+
return () => controller.abort();
|
|
1534
|
+
}, [
|
|
1535
|
+
client,
|
|
1536
|
+
scope,
|
|
1537
|
+
debouncedReference,
|
|
1538
|
+
debouncedText
|
|
1539
|
+
]);
|
|
1540
|
+
const settled = reference !== null && debouncedReference !== null && outcome !== null && outcome.client === client && outcome.scope === scope && outcome.text === text && sameReference(reference, debouncedReference) && sameReference(outcome.input, debouncedReference) ? outcome.result : null;
|
|
1541
|
+
const status = recipientStatus(parsed, settled, acknowledgedOutcome === outcome);
|
|
1542
|
+
const ready = settled?.ok === true && (settled.value.status !== "external_address" || status === "external-ready");
|
|
1543
|
+
const value = settled?.ok === true ? settled.value : null;
|
|
1544
|
+
const acknowledgeExternalAddress = useCallback(() => {
|
|
1545
|
+
if (value?.status !== "external_address" || outcome === null) return;
|
|
1546
|
+
setAcknowledgedOutcome(outcome);
|
|
1547
|
+
if (capturedAcknowledgement.current === outcome) return;
|
|
1548
|
+
capturedAcknowledgement.current = outcome;
|
|
1549
|
+
client?._internal.captureExternalAddressAcknowledged(value.safeAddress, organizationId);
|
|
1550
|
+
}, [
|
|
1551
|
+
client,
|
|
1552
|
+
organizationId,
|
|
1553
|
+
value,
|
|
1554
|
+
outcome
|
|
1555
|
+
]);
|
|
1556
|
+
return {
|
|
1557
|
+
slice: {
|
|
1558
|
+
value: text,
|
|
1559
|
+
change,
|
|
1560
|
+
status,
|
|
1561
|
+
label: value?.label ?? null,
|
|
1562
|
+
kind: value?.kind ?? null,
|
|
1563
|
+
isCapxulAccount: value?.isCapxulAccount ?? null,
|
|
1564
|
+
publicValue: publicRecipientValue(value),
|
|
1565
|
+
requiresAcknowledgement: status === "external-warning",
|
|
1566
|
+
acknowledgeExternalAddress
|
|
1567
|
+
},
|
|
1568
|
+
resolvedReference: ready && value !== null ? value.reference : null,
|
|
1569
|
+
trimmed: text.trim()
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
//#endregion
|
|
1573
|
+
//#region src/payments/use-send-money.ts
|
|
1574
|
+
/**
|
|
1575
|
+
* One dropdown's selection. It reconciles whenever the held id is absent from
|
|
1576
|
+
* the option list — first mount AND a swap under a mounted root both land here
|
|
1577
|
+
* — and `select` narrows internally, so an id the caller does not hold is
|
|
1578
|
+
* ignored and no cast reaches app code.
|
|
1579
|
+
*/
|
|
1580
|
+
function useSelectedOption(options) {
|
|
1581
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
1582
|
+
useEffect(() => {
|
|
1583
|
+
if (!options.some((option) => option.id === selectedId)) setSelectedId(options[0]?.id ?? null);
|
|
1584
|
+
}, [options, selectedId]);
|
|
1585
|
+
const select = useCallback((id) => {
|
|
1586
|
+
setSelectedId((current) => options.some((option) => option.id === id) ? id : current);
|
|
1587
|
+
}, [options]);
|
|
1588
|
+
return [options.find((option) => option.id === selectedId) ?? null, select];
|
|
1589
|
+
}
|
|
1590
|
+
function useSendMoney(input) {
|
|
1591
|
+
const { actor, onSent, onFailed } = input;
|
|
1592
|
+
const client = useCapxulClientOrNull();
|
|
1593
|
+
const signerStatus = useSignerStatus(client);
|
|
1594
|
+
const orgId = actor.kind === "organization" ? actor.orgId : void 0;
|
|
1595
|
+
const meReading = useOrgMeReading(orgId);
|
|
1596
|
+
const sourceOptions = useMemo(() => meReading.kind === "ready" ? meReading.me.budgets.map((budget) => ({ id: budget.id })) : [], [meReading]);
|
|
1597
|
+
const [selectedSource, selectSource] = useSelectedOption(sourceOptions);
|
|
1598
|
+
const balance = useCapxulAccountBalance({ enabled: actor.kind === "personal" });
|
|
1599
|
+
const treasury = useCapxulOrgTreasury(orgId, { enabled: actor.kind === "organization" });
|
|
1600
|
+
const accountQuery = actor.kind === "personal" ? balance : treasury;
|
|
1601
|
+
const account = accountQuery.data ?? null;
|
|
1602
|
+
const assetSelections = useMemo(() => assetSelectionsFromAccount(account), [account]);
|
|
1603
|
+
const assetOptions = useMemo(() => assetSelections.map((selection) => selection.option), [assetSelections]);
|
|
1604
|
+
const [selectedAssetOption, selectAsset] = useSelectedOption(assetOptions);
|
|
1605
|
+
const selectedAssetMoney = useMemo(() => assetSelections.find((selection) => selection.option.id === selectedAssetOption?.id)?.money ?? null, [assetSelections, selectedAssetOption]);
|
|
1606
|
+
const [amountText, setAmountText] = useState("");
|
|
1607
|
+
const parsed = useMemo(() => selectedAssetMoney === null || amountText.trim() === "" ? null : parseAssetAmount(amountText, requireAsset(selectedAssetMoney.asset)), [amountText, selectedAssetMoney]);
|
|
1608
|
+
const parsedMoney = parsed !== null && !isMoneyParseError(parsed) ? parsed : null;
|
|
1609
|
+
const amountError = parsed !== null && isMoneyParseError(parsed) ? parsed.reason : null;
|
|
1610
|
+
const recipient = useRecipientResolution(client, JSON.stringify([
|
|
1611
|
+
actor.kind,
|
|
1612
|
+
orgId,
|
|
1613
|
+
account?.id
|
|
1614
|
+
]), orgId);
|
|
1615
|
+
const resolvedReference = recipient.resolvedReference;
|
|
1616
|
+
const personalPay = useCapxulPay();
|
|
1617
|
+
const organizationPay = useCapxulOrganizationPay(orgId);
|
|
1618
|
+
const pay = actor.kind === "personal" ? personalPay : organizationPay;
|
|
1619
|
+
const blockedReason = signerStatus !== "ready" ? "signer-not-ready" : actor.kind === "organization" && selectedSource === null ? "no-source" : selectedAssetOption === null ? "no-asset" : parsedMoney === null ? "amount-invalid" : resolvedReference === null ? "recipient-unresolved" : writeNamesAsset() ? null : "asset-unnamed";
|
|
1620
|
+
const submitGate = useRef(false);
|
|
1621
|
+
const submit = useCallback(() => {
|
|
1622
|
+
if (blockedReason !== null || submitGate.current) return;
|
|
1623
|
+
if (parsedMoney === null || resolvedReference === null) return;
|
|
1624
|
+
const amount = parsedMoney;
|
|
1625
|
+
const sentRecipient = recipient.trimmed;
|
|
1626
|
+
const externalAddressAcknowledged = recipient.slice.status === "external-ready";
|
|
1627
|
+
const callbacks = {
|
|
1628
|
+
onSuccess: (payment) => onSent({
|
|
1629
|
+
payment,
|
|
1630
|
+
amount,
|
|
1631
|
+
recipient: sentRecipient
|
|
1632
|
+
}),
|
|
1633
|
+
onError: (error) => onFailed(error),
|
|
1634
|
+
onSettled: () => {
|
|
1635
|
+
submitGate.current = false;
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
if (actor.kind === "personal") {
|
|
1639
|
+
submitGate.current = true;
|
|
1640
|
+
personalPay.mutate({
|
|
1641
|
+
to: resolvedReference,
|
|
1642
|
+
amount,
|
|
1643
|
+
...externalAddressAcknowledged ? { externalAddressAcknowledged: true } : {}
|
|
1644
|
+
}, callbacks);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (selectedSource === null) return;
|
|
1648
|
+
submitGate.current = true;
|
|
1649
|
+
organizationPay.mutate({
|
|
1650
|
+
permissionId: selectedSource.id,
|
|
1651
|
+
to: resolvedReference,
|
|
1652
|
+
amount,
|
|
1653
|
+
...externalAddressAcknowledged ? { externalAddressAcknowledged: true } : {}
|
|
1654
|
+
}, callbacks);
|
|
1655
|
+
}, [
|
|
1656
|
+
actor.kind,
|
|
1657
|
+
blockedReason,
|
|
1658
|
+
onFailed,
|
|
1659
|
+
onSent,
|
|
1660
|
+
organizationPay,
|
|
1661
|
+
parsedMoney,
|
|
1662
|
+
personalPay,
|
|
1663
|
+
recipient.trimmed,
|
|
1664
|
+
recipient.slice.status,
|
|
1665
|
+
resolvedReference,
|
|
1666
|
+
selectedSource
|
|
1667
|
+
]);
|
|
1668
|
+
return {
|
|
1669
|
+
source: {
|
|
1670
|
+
options: sourceOptions,
|
|
1671
|
+
selected: selectedSource,
|
|
1672
|
+
select: selectSource,
|
|
1673
|
+
error: meReading.kind === "unavailable" ? "read-failed" : null,
|
|
1674
|
+
isPending: meReading.kind === "loading"
|
|
1675
|
+
},
|
|
1676
|
+
asset: {
|
|
1677
|
+
options: assetOptions,
|
|
1678
|
+
selected: selectedAssetOption,
|
|
1679
|
+
select: selectAsset,
|
|
1680
|
+
error: accountQuery.error !== null ? "read-failed" : null,
|
|
1681
|
+
isPending: accountQuery.isPending && (actor.kind === "personal" || orgId !== void 0)
|
|
1682
|
+
},
|
|
1683
|
+
amount: {
|
|
1684
|
+
value: amountText,
|
|
1685
|
+
change: setAmountText,
|
|
1686
|
+
error: amountError,
|
|
1687
|
+
availableDisplay: selectedAssetOption?.availableDisplay ?? null
|
|
1688
|
+
},
|
|
1689
|
+
recipient: recipient.slice,
|
|
1690
|
+
actions: {
|
|
1691
|
+
submit,
|
|
1692
|
+
isSubmitting: pay.isPending,
|
|
1693
|
+
blocked: blockedReason !== null,
|
|
1694
|
+
blockedReason
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
//#endregion
|
|
1699
|
+
//#region src/payments/send-money.tsx
|
|
1700
|
+
const SendMoneyContext = createContext(null);
|
|
1701
|
+
function useSendMoneyContext() {
|
|
1702
|
+
const engine = useContext(SendMoneyContext);
|
|
1703
|
+
if (engine === null) throw new Error("CapxulSendMoney parts must be used inside <CapxulSendMoney>");
|
|
1704
|
+
return engine;
|
|
1705
|
+
}
|
|
1706
|
+
function Root$4({ actor, onSent, onFailed, children }) {
|
|
1707
|
+
const engine = useSendMoney({
|
|
1708
|
+
actor,
|
|
1709
|
+
onSent,
|
|
1710
|
+
onFailed
|
|
1711
|
+
});
|
|
1712
|
+
return /* @__PURE__ */ jsx(SendMoneyContext.Provider, {
|
|
1713
|
+
value: engine,
|
|
1714
|
+
children
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
function Source({ children }) {
|
|
1718
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useSendMoneyContext().source) });
|
|
1719
|
+
}
|
|
1720
|
+
function Asset$1({ children }) {
|
|
1721
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useSendMoneyContext().asset) });
|
|
1722
|
+
}
|
|
1723
|
+
function Amount({ children }) {
|
|
1724
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useSendMoneyContext().amount) });
|
|
1725
|
+
}
|
|
1726
|
+
function Recipient({ children }) {
|
|
1727
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useSendMoneyContext().recipient) });
|
|
1728
|
+
}
|
|
1729
|
+
function Actions$1({ children }) {
|
|
1730
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useSendMoneyContext().actions) });
|
|
1731
|
+
}
|
|
1732
|
+
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
1733
|
+
const CapxulSendMoney = Object.assign(Root$4, {
|
|
1734
|
+
Source,
|
|
1735
|
+
Asset: Asset$1,
|
|
1736
|
+
Amount,
|
|
1737
|
+
Recipient,
|
|
1738
|
+
Actions: Actions$1
|
|
1739
|
+
});
|
|
1740
|
+
//#endregion
|
|
1741
|
+
//#region src/permissions/use-capxul-permissions.ts
|
|
1742
|
+
function usePermissionMutation(orgId, operation) {
|
|
1743
|
+
const client = useCapxulClientOrNull();
|
|
1744
|
+
const queryClient = useQueryClient();
|
|
1745
|
+
return useMutation({
|
|
1746
|
+
mutationFn: async (input) => {
|
|
1747
|
+
const methods = requireBootstrappedClient(client, CAPXUL_OPERATIONS.org.permissions[operation]).org(orgId).permissions;
|
|
1748
|
+
return unwrapCapxulResult(await methods[operation](input));
|
|
1749
|
+
},
|
|
1750
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: capxulKeys.orgPermissions(orgId) })
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
const useCapxulPermissionCreate = (orgId) => usePermissionMutation(orgId, "create");
|
|
1754
|
+
const useCapxulPermissionChange = (orgId) => usePermissionMutation(orgId, "change");
|
|
1755
|
+
const useCapxulPermissionAssign = (orgId) => usePermissionMutation(orgId, "assign");
|
|
1756
|
+
const useCapxulPermissionRevoke = (orgId) => usePermissionMutation(orgId, "revoke");
|
|
1757
|
+
const useCapxulPermissionReplace = (orgId) => usePermissionMutation(orgId, "replace");
|
|
1758
|
+
function useCapxulPermissions(orgId, options) {
|
|
1759
|
+
const client = useCapxulClientOrNull();
|
|
1760
|
+
const identity = useCapxulIdentityOrNull();
|
|
1761
|
+
return useQuery({
|
|
1762
|
+
queryKey: capxulKeys.orgPermissions(orgId),
|
|
1763
|
+
queryFn: async () => {
|
|
1764
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.org.permissions.list).org(orgId).permissions.list());
|
|
1765
|
+
},
|
|
1766
|
+
enabled: client !== null && identity !== null && isClaimed$1(identity) && (options?.enabled ?? true)
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
function useCapxulPermission(orgId, permissionId) {
|
|
1770
|
+
const client = useCapxulClientOrNull();
|
|
1771
|
+
return useQuery({
|
|
1772
|
+
queryKey: [...capxulKeys.orgPermissions(orgId), permissionId ?? "pending"],
|
|
1773
|
+
queryFn: async () => {
|
|
1774
|
+
if (permissionId === void 0) throw Errors.invalidInput("permissionId", "required for permissions.get");
|
|
1775
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.org.permissions.get).org(orgId).permissions.get(permissionId));
|
|
1776
|
+
},
|
|
1777
|
+
enabled: client !== null && permissionId !== void 0
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
//#endregion
|
|
1781
|
+
//#region src/activity/use-capxul-activity.ts
|
|
1782
|
+
function useCapxulActivity(params) {
|
|
1783
|
+
const client = useCapxulClientOrNull();
|
|
1784
|
+
const identity = useCapxulIdentityOrNull();
|
|
1785
|
+
return useQuery({
|
|
1786
|
+
queryKey: accountKey([...capxulKeys.activity, params ?? {}], identity),
|
|
1787
|
+
queryFn: async () => {
|
|
1788
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.activity.items.list).activity.list(params));
|
|
1789
|
+
},
|
|
1790
|
+
enabled: client !== null && identity !== null && isClaimed$1(identity)
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
function useCapxulActivityDetail(reference, actor) {
|
|
1794
|
+
const client = useCapxulClientOrNull();
|
|
1795
|
+
const identity = useCapxulIdentityOrNull();
|
|
1796
|
+
return useQuery({
|
|
1797
|
+
queryKey: accountKey(capxulKeys.activityDetail(reference, actor), identity),
|
|
1798
|
+
queryFn: async () => {
|
|
1799
|
+
if (reference === void 0) throw Errors.invalidInput("reference", "required for activity.get");
|
|
1800
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.activity.items.get).activity.get(reference, actor === void 0 ? void 0 : { actor }));
|
|
1801
|
+
},
|
|
1802
|
+
enabled: client !== null && reference !== void 0 && identity !== null && isClaimed$1(identity)
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
function useCapxulAnnotateMovement() {
|
|
1806
|
+
const client = useCapxulClientOrNull();
|
|
1807
|
+
const queryClient = useQueryClient();
|
|
1808
|
+
return useMutation({
|
|
1809
|
+
mutationFn: async (input) => {
|
|
1810
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.activity.annotations.save).activity.annotate(input));
|
|
1811
|
+
},
|
|
1812
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: capxulKeys.activity })
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
//#endregion
|
|
1816
|
+
//#region src/platform/as-capxul-error.ts
|
|
1817
|
+
/**
|
|
1818
|
+
* A caught failure as the `CapxulError` it is. Every query and mutation in this
|
|
1819
|
+
* package throws through `unwrapCapxulResult` or `requireBootstrappedClient`, so
|
|
1820
|
+
* the value IS a `CapxulError`. Anything else is a defect inside this package:
|
|
1821
|
+
* it is WRAPPED with the `UNKNOWN` code and its cause, never swallowed, because
|
|
1822
|
+
* a slice hands the app an error VALUE carrying a code and the app writes the
|
|
1823
|
+
* sentence (ADR-0023).
|
|
1824
|
+
*/
|
|
1825
|
+
function toCapxulError(cause) {
|
|
1826
|
+
return isCapxulError(cause) ? cause : Errors.unknown(cause);
|
|
1827
|
+
}
|
|
1828
|
+
/** TanStack types a query error as `Error | null`: the same, with no error passed through. */
|
|
1829
|
+
function asCapxulError(error) {
|
|
1830
|
+
return error === null ? null : toCapxulError(error);
|
|
1831
|
+
}
|
|
1832
|
+
//#endregion
|
|
1833
|
+
//#region src/activity/enabled.ts
|
|
1834
|
+
/**
|
|
1835
|
+
* The one gate every read under `CapxulActivity` shares — the engine, each
|
|
1836
|
+
* `.Summary` lane, and each `.Detail` lane. One function, because a gate that
|
|
1837
|
+
* differs between the list and the detail lets a screen load rows it can never
|
|
1838
|
+
* open.
|
|
1839
|
+
*
|
|
1840
|
+
* Every backend activity read resolves the authenticated Account before it
|
|
1841
|
+
* resolves either a personal or Organization actor. Wait for that Account so a
|
|
1842
|
+
* new session cannot issue requests the backend must refuse.
|
|
1843
|
+
*/
|
|
1844
|
+
function useActivityEnabled() {
|
|
1845
|
+
const client = useCapxulClientOrNull();
|
|
1846
|
+
const identity = useCapxulIdentityOrNull();
|
|
1847
|
+
return client !== null && identity !== null && isClaimed$1(identity);
|
|
1848
|
+
}
|
|
1849
|
+
//#endregion
|
|
1850
|
+
//#region src/activity/detail.ts
|
|
1851
|
+
function detailDocuments(detail) {
|
|
1852
|
+
return detail === null ? [] : detail.kind === "payment" ? detail.payment.documents : detail.documents;
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* The root actor and the whole reference are included. A payment reference can
|
|
1856
|
+
* carry its own actor, and a Movement reference relies on the root actor. A key
|
|
1857
|
+
* of kind and id alone would show one actor's draft memo on the other actor's
|
|
1858
|
+
* row, and save it there.
|
|
1859
|
+
*
|
|
1860
|
+
* The parts go into an ARRAY, and the array is what gets serialized. A joined
|
|
1861
|
+
* string collides: an organization whose id is the sentinel `self`, or any id
|
|
1862
|
+
* that carries the separator, produces another reference's key. Serializing the
|
|
1863
|
+
* reference OBJECT collides the other way, because property order is not part of
|
|
1864
|
+
* a reference's meaning and a literal written `{ id, kind }` must not read as a
|
|
1865
|
+
* different row. The array is ordered here, by this function, so neither the
|
|
1866
|
+
* caller's property order nor the content of an id can move a part.
|
|
1867
|
+
*/
|
|
1868
|
+
function actorKey(actor) {
|
|
1869
|
+
if (actor === void 0) return null;
|
|
1870
|
+
return actor.kind === "organization" ? [actor.kind, actor.organizationId] : [actor.kind];
|
|
1871
|
+
}
|
|
1872
|
+
function activityDetailKey(actor, reference) {
|
|
1873
|
+
if (reference === void 0) return JSON.stringify([actorKey(actor), null]);
|
|
1874
|
+
const referenceActor = reference.kind === "payment" ? reference.actor : void 0;
|
|
1875
|
+
return JSON.stringify([actorKey(actor), [
|
|
1876
|
+
reference.kind,
|
|
1877
|
+
reference.id,
|
|
1878
|
+
actorKey(referenceActor)
|
|
1879
|
+
]]);
|
|
1880
|
+
}
|
|
1881
|
+
function useActivityDetail(reference, actor) {
|
|
1882
|
+
const client = useCapxulClientOrNull();
|
|
1883
|
+
const identity = useCapxulIdentityOrNull();
|
|
1884
|
+
const enabled = useActivityEnabled() && reference !== void 0;
|
|
1885
|
+
const detail = useQuery({
|
|
1886
|
+
queryKey: accountKey(capxulKeys.activityDetail(reference, actor), identity),
|
|
1887
|
+
queryFn: async ({ signal }) => {
|
|
1888
|
+
if (reference === void 0) throw Errors.invalidInput("reference", "required for activity.get");
|
|
1889
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.activity.items.get).activity.get(reference, {
|
|
1890
|
+
...actor === void 0 ? {} : { actor },
|
|
1891
|
+
signal
|
|
1892
|
+
}));
|
|
1893
|
+
},
|
|
1894
|
+
enabled
|
|
1895
|
+
});
|
|
1896
|
+
const documents = detailDocuments(detail.data ?? null);
|
|
1897
|
+
const [selected, setSelected] = useState(null);
|
|
1898
|
+
const selectedDocument = (selected === null ? void 0 : documents.find((doc) => doc.documentHash === selected.documentHash && doc.contentHash === selected.contentHash)) ?? documents.find((doc) => doc.role === "instruction") ?? documents.find((doc) => doc.role === "movement") ?? documents[0] ?? null;
|
|
1899
|
+
const pending = useRef(/* @__PURE__ */ new Set());
|
|
1900
|
+
const mounted = useRef(true);
|
|
1901
|
+
useEffect(() => {
|
|
1902
|
+
mounted.current = true;
|
|
1903
|
+
const reads = pending.current;
|
|
1904
|
+
return () => {
|
|
1905
|
+
mounted.current = false;
|
|
1906
|
+
for (const read of reads) read.abort();
|
|
1907
|
+
reads.clear();
|
|
1908
|
+
};
|
|
1909
|
+
}, []);
|
|
1910
|
+
const selectDocument = useCallback((ref) => {
|
|
1911
|
+
if (!documents.some((doc) => doc.documentHash === ref.documentHash && doc.contentHash === ref.contentHash)) throw Errors.invalidInput("documentHash", "document not found");
|
|
1912
|
+
for (const read of pending.current) read.abort();
|
|
1913
|
+
pending.current.clear();
|
|
1914
|
+
setIsOpeningDocument(false);
|
|
1915
|
+
setSelected({
|
|
1916
|
+
documentHash: ref.documentHash,
|
|
1917
|
+
contentHash: ref.contentHash
|
|
1918
|
+
});
|
|
1919
|
+
}, [documents]);
|
|
1920
|
+
const [isOpeningDocument, setIsOpeningDocument] = useState(false);
|
|
1921
|
+
const readDocument = useCallback(async (download) => {
|
|
1922
|
+
const operation = download ? CAPXUL_OPERATIONS.activity.documents.get : CAPXUL_OPERATIONS.activity.documents.render;
|
|
1923
|
+
if (!mounted.current) throw Errors.cancelled({ operation });
|
|
1924
|
+
if (reference === void 0 || selectedDocument === null) throw Errors.invalidInput("documentHash", "no document on this row");
|
|
1925
|
+
const controller = new AbortController();
|
|
1926
|
+
pending.current.add(controller);
|
|
1927
|
+
setIsOpeningDocument(true);
|
|
1928
|
+
try {
|
|
1929
|
+
const bootstrapped = requireBootstrappedClient(client, download ? CAPXUL_OPERATIONS.activity.documents.get : CAPXUL_OPERATIONS.activity.documents.render);
|
|
1930
|
+
const pair = {
|
|
1931
|
+
documentHash: selectedDocument.documentHash,
|
|
1932
|
+
contentHash: selectedDocument.contentHash
|
|
1933
|
+
};
|
|
1934
|
+
const options = { signal: controller.signal };
|
|
1935
|
+
const result = download ? await bootstrapped.paymentDocuments.get(pair, options) : await bootstrapped.paymentDocuments.render(pair, options);
|
|
1936
|
+
if (controller.signal.aborted) throw Errors.cancelled({ operation });
|
|
1937
|
+
return unwrapCapxulResult(result);
|
|
1938
|
+
} finally {
|
|
1939
|
+
pending.current.delete(controller);
|
|
1940
|
+
if (!controller.signal.aborted) setIsOpeningDocument(pending.current.size > 0);
|
|
1941
|
+
}
|
|
1942
|
+
}, [
|
|
1943
|
+
client,
|
|
1944
|
+
selectedDocument,
|
|
1945
|
+
reference
|
|
1946
|
+
]);
|
|
1947
|
+
const renderDocument = useCallback(async () => {
|
|
1948
|
+
const result = await readDocument(false);
|
|
1949
|
+
if (!("html" in result)) throw Errors.invalidInput("documentHash", "invalid document render");
|
|
1950
|
+
return result;
|
|
1951
|
+
}, [readDocument]);
|
|
1952
|
+
const downloadDocument = useCallback(async () => {
|
|
1953
|
+
const result = await readDocument(true);
|
|
1954
|
+
if (!("bytesBase64" in result)) throw Errors.invalidInput("documentHash", "invalid document download");
|
|
1955
|
+
return result;
|
|
1956
|
+
}, [readDocument]);
|
|
1957
|
+
const [typed, setTyped] = useState(null);
|
|
1958
|
+
const annotate = useCapxulAnnotateMovement();
|
|
1959
|
+
const saveMemo = useCallback(async () => {
|
|
1960
|
+
if (reference === void 0 || typed === null) return { ok: true };
|
|
1961
|
+
try {
|
|
1962
|
+
await annotate.mutateAsync({
|
|
1963
|
+
...actor === void 0 ? {} : { actor },
|
|
1964
|
+
reference,
|
|
1965
|
+
memo: typed
|
|
1966
|
+
});
|
|
1967
|
+
return { ok: true };
|
|
1968
|
+
} catch (error) {
|
|
1969
|
+
return {
|
|
1970
|
+
ok: false,
|
|
1971
|
+
error: toCapxulError(error)
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
}, [
|
|
1975
|
+
actor,
|
|
1976
|
+
annotate,
|
|
1977
|
+
reference,
|
|
1978
|
+
typed
|
|
1979
|
+
]);
|
|
1980
|
+
return {
|
|
1981
|
+
...detail.data?.kind === "payment" ? { payment: {
|
|
1982
|
+
status: detail.data.payment.status,
|
|
1983
|
+
...detail.data.payment.execution === void 0 ? {} : { execution: detail.data.payment.execution }
|
|
1984
|
+
} } : {},
|
|
1985
|
+
documents,
|
|
1986
|
+
selectedDocument,
|
|
1987
|
+
selectDocument,
|
|
1988
|
+
renderDocument,
|
|
1989
|
+
downloadDocument,
|
|
1990
|
+
isOpeningDocument,
|
|
1991
|
+
memo: typed ?? detail.data?.annotation?.memo ?? "",
|
|
1992
|
+
setMemo: setTyped,
|
|
1993
|
+
saveMemo,
|
|
1994
|
+
isSavingMemo: annotate.isPending,
|
|
1995
|
+
isLoading: enabled && detail.isLoading,
|
|
1996
|
+
error: asCapxulError(detail.error) ?? annotate.error ?? null
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
//#endregion
|
|
2000
|
+
//#region src/activity/summary.ts
|
|
2001
|
+
const DAY_MS = 864e5;
|
|
2002
|
+
const display = (money) => money === null ? null : formatMoney(money);
|
|
2003
|
+
/**
|
|
2004
|
+
* `"last30days"` is the last 30 whole UTC days including today. Quantizing to
|
|
2005
|
+
* the UTC day keeps the cache key stable for a whole day instead of changing on
|
|
2006
|
+
* every render.
|
|
2007
|
+
*/
|
|
2008
|
+
function resolveWindow(window, now) {
|
|
2009
|
+
if (window === "all") return void 0;
|
|
2010
|
+
if (window === "last30days") return { from: Math.floor(now / DAY_MS) * DAY_MS - 29 * DAY_MS };
|
|
2011
|
+
return window;
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* The current UTC day number, which advances ON ITS OWN at UTC midnight.
|
|
2015
|
+
*
|
|
2016
|
+
* The day is NOT held in state. It is read at every render, so it can never be
|
|
2017
|
+
* stale — not after a mount that sat through midnight, and not when a part
|
|
2018
|
+
* mounted on a fixed window is later switched to a rolling one. State holds
|
|
2019
|
+
* only `tick`, whose single job is to force the render that a screen sitting
|
|
2020
|
+
* idle would otherwise never do: React recomputes nothing on its own, so an
|
|
2021
|
+
* untouched summary would hold yesterday's boundary indefinitely.
|
|
2022
|
+
*
|
|
2023
|
+
* The effect depends on `tick`, not on the day, so the timer re-arms whatever
|
|
2024
|
+
* the clock says when it fires. A wake a millisecond early re-arms for the
|
|
2025
|
+
* boundary still ahead instead of silently ending the rollover, and a wake
|
|
2026
|
+
* hours late — a throttled tab, a sleeping machine — lands on the true day
|
|
2027
|
+
* rather than drifting one day per sleep.
|
|
2028
|
+
*
|
|
2029
|
+
* `active` is false for the windows that do not roll, so a fixed window arms no
|
|
2030
|
+
* timer at all.
|
|
2031
|
+
*/
|
|
2032
|
+
function useUtcDay(active) {
|
|
2033
|
+
const [tick, setTick] = useState(0);
|
|
2034
|
+
useEffect(() => {
|
|
2035
|
+
if (!active) return;
|
|
2036
|
+
const timer = setTimeout(() => {
|
|
2037
|
+
setTick((value) => value + 1);
|
|
2038
|
+
}, DAY_MS - Date.now() % DAY_MS + 1);
|
|
2039
|
+
return () => {
|
|
2040
|
+
clearTimeout(timer);
|
|
2041
|
+
};
|
|
2042
|
+
}, [active, tick]);
|
|
2043
|
+
return Math.floor(Date.now() / DAY_MS);
|
|
2044
|
+
}
|
|
2045
|
+
function useActivitySummary(actor, window) {
|
|
2046
|
+
const client = useCapxulClientOrNull();
|
|
2047
|
+
const identity = useCapxulIdentityOrNull();
|
|
2048
|
+
const enabled = useActivityEnabled();
|
|
2049
|
+
const range = resolveWindow(window, useUtcDay(window === "last30days") * DAY_MS);
|
|
2050
|
+
const summary = useQuery({
|
|
2051
|
+
queryKey: accountKey(capxulKeys.activitySummary(actor, range), identity),
|
|
2052
|
+
queryFn: async () => {
|
|
2053
|
+
const params = {};
|
|
2054
|
+
if (actor !== void 0) params.actor = actor;
|
|
2055
|
+
if (range !== void 0) params.window = range;
|
|
2056
|
+
const result = unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.activity.items.summary).activity.summary(params));
|
|
2057
|
+
return {
|
|
2058
|
+
...result,
|
|
2059
|
+
valuation: valueActivitySummary(result.totals)
|
|
2060
|
+
};
|
|
2061
|
+
},
|
|
2062
|
+
enabled
|
|
2063
|
+
});
|
|
2064
|
+
const totals = summary.data?.totals;
|
|
2065
|
+
const isLoading = summary.isLoading;
|
|
2066
|
+
if (summary.data === void 0 || totals === void 0 || totals.length === 0 || isLoading) return {
|
|
2067
|
+
currency: null,
|
|
2068
|
+
moneyIn: null,
|
|
2069
|
+
moneyOut: null,
|
|
2070
|
+
net: null,
|
|
2071
|
+
pending: null,
|
|
2072
|
+
unrated: [],
|
|
2073
|
+
count: summary.data?.count ?? 0,
|
|
2074
|
+
isLoading,
|
|
2075
|
+
error: asCapxulError(summary.error),
|
|
2076
|
+
...summary.data?.readWarning === void 0 ? {} : { readWarning: summary.data.readWarning }
|
|
2077
|
+
};
|
|
2078
|
+
const valuation = summary.data.valuation;
|
|
2079
|
+
return {
|
|
2080
|
+
currency: valuation.moneyIn?.currency ?? null,
|
|
2081
|
+
moneyIn: display(valuation.moneyIn),
|
|
2082
|
+
moneyOut: display(valuation.moneyOut),
|
|
2083
|
+
net: display(valuation.net),
|
|
2084
|
+
pending: display(valuation.pending),
|
|
2085
|
+
unrated: valuation.unrated,
|
|
2086
|
+
count: summary.data?.count ?? 0,
|
|
2087
|
+
isLoading,
|
|
2088
|
+
error: asCapxulError(summary.error),
|
|
2089
|
+
...summary.data?.readWarning === void 0 ? {} : { readWarning: summary.data.readWarning }
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
//#endregion
|
|
2093
|
+
//#region src/activity/rows.ts
|
|
2094
|
+
/**
|
|
2095
|
+
* A Movement's phase is DERIVED, not asserted: `movementItems` returns `null`
|
|
2096
|
+
* for any Movement whose `canonicalState` is not `"included"`
|
|
2097
|
+
* (`activityList.ts:120`), so a Movement that reaches a page is finished and
|
|
2098
|
+
* carries no verdict of its own.
|
|
2099
|
+
*/
|
|
2100
|
+
const MOVEMENT_PHASE = Object.freeze({
|
|
2101
|
+
terminal: true,
|
|
2102
|
+
outcome: "neutral",
|
|
2103
|
+
needsAction: false
|
|
2104
|
+
});
|
|
2105
|
+
/** The sign grammar of the app's `formatDirectionalAmount` — unsigned for `self`. */
|
|
2106
|
+
const SIGN = Object.freeze({
|
|
2107
|
+
in: "+",
|
|
2108
|
+
out: "-",
|
|
2109
|
+
self: ""
|
|
2110
|
+
});
|
|
2111
|
+
function toActivityRow(item, actor) {
|
|
2112
|
+
const amountDisplay = formatAssetAmount(item.amount);
|
|
2113
|
+
const signedAmountDisplay = `${SIGN[item.direction]}${amountDisplay}`;
|
|
2114
|
+
if (item.kind === "movement") return {
|
|
2115
|
+
kind: "movement",
|
|
2116
|
+
reference: {
|
|
2117
|
+
kind: "movement",
|
|
2118
|
+
id: item.id
|
|
2119
|
+
},
|
|
2120
|
+
direction: item.direction,
|
|
2121
|
+
counterpartyLabel: item.counterpartyLabel,
|
|
2122
|
+
amountDisplay,
|
|
2123
|
+
signedAmountDisplay,
|
|
2124
|
+
currency: assetSymbolFor(item.amount.asset),
|
|
2125
|
+
createdAt: item.createdAt,
|
|
2126
|
+
classification: item.classification,
|
|
2127
|
+
phase: MOVEMENT_PHASE,
|
|
2128
|
+
receipt: item.receipt
|
|
2129
|
+
};
|
|
2130
|
+
const reference = actor === void 0 ? {
|
|
2131
|
+
kind: "payment",
|
|
2132
|
+
id: item.id
|
|
2133
|
+
} : {
|
|
2134
|
+
kind: "payment",
|
|
2135
|
+
id: item.id,
|
|
2136
|
+
actor
|
|
2137
|
+
};
|
|
2138
|
+
return {
|
|
2139
|
+
kind: "payment",
|
|
2140
|
+
...item.execution === void 0 ? {} : { execution: item.execution },
|
|
2141
|
+
reference,
|
|
2142
|
+
direction: item.direction,
|
|
2143
|
+
counterpartyLabel: item.counterpartyLabel,
|
|
2144
|
+
amountDisplay,
|
|
2145
|
+
signedAmountDisplay,
|
|
2146
|
+
currency: assetSymbolFor(item.amount.asset),
|
|
2147
|
+
createdAt: item.createdAt,
|
|
2148
|
+
ledgerKind: item.ledgerKind,
|
|
2149
|
+
paymentType: item.paymentType,
|
|
2150
|
+
status: item.status,
|
|
2151
|
+
phase: item.execution?.notSent ? {
|
|
2152
|
+
...paymentPhase(item.status),
|
|
2153
|
+
needsAction: item.direction !== "in"
|
|
2154
|
+
} : paymentPhase(item.status),
|
|
2155
|
+
receipt: item.receipt
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* The exact field set `.Search` matches, case-insensitively, joined with a
|
|
2160
|
+
* space. Two of today's searchable strings drop out on purpose: `account` is
|
|
2161
|
+
* the currency-to-name guess this item deletes, and `statusLabel` is app copy —
|
|
2162
|
+
* the Status dropdown, wired up in the same change, is the affordance for
|
|
2163
|
+
* filtering by status and it uses the app's own words.
|
|
2164
|
+
*/
|
|
2165
|
+
function searchableText(row) {
|
|
2166
|
+
return (row.kind === "payment" ? [
|
|
2167
|
+
row.counterpartyLabel,
|
|
2168
|
+
row.amountDisplay,
|
|
2169
|
+
row.status,
|
|
2170
|
+
row.paymentType,
|
|
2171
|
+
row.ledgerKind
|
|
2172
|
+
] : [
|
|
2173
|
+
row.counterpartyLabel,
|
|
2174
|
+
row.amountDisplay,
|
|
2175
|
+
row.classification
|
|
2176
|
+
]).join(" ").toLowerCase();
|
|
2177
|
+
}
|
|
2178
|
+
function matchesSearch(row, query) {
|
|
2179
|
+
const needle = query.trim().toLowerCase();
|
|
2180
|
+
return needle === "" || searchableText(row).includes(needle);
|
|
2181
|
+
}
|
|
2182
|
+
//#endregion
|
|
2183
|
+
//#region src/activity/csv.ts
|
|
2184
|
+
/** In this order. A header row is always written, even with no rows. */
|
|
2185
|
+
const COLUMNS = [
|
|
2186
|
+
"Date",
|
|
2187
|
+
"Kind",
|
|
2188
|
+
"Type",
|
|
2189
|
+
"Counterparty",
|
|
2190
|
+
"Direction",
|
|
2191
|
+
"Amount",
|
|
2192
|
+
"Currency",
|
|
2193
|
+
"Status",
|
|
2194
|
+
"Reference",
|
|
2195
|
+
"Transaction hash"
|
|
2196
|
+
];
|
|
2197
|
+
const FORMULA_LEAD = /^[=+\-@\t\r\n]/;
|
|
2198
|
+
const PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
|
|
2199
|
+
/**
|
|
2200
|
+
* RFC 4180: every field is quoted, and an embedded quote is doubled. A field
|
|
2201
|
+
* that would open a formula is first prefixed with an apostrophe, which the
|
|
2202
|
+
* spreadsheet consumes and renders as text.
|
|
2203
|
+
*/
|
|
2204
|
+
function escape(field) {
|
|
2205
|
+
return `"${(FORMULA_LEAD.test(field) && !PLAIN_NUMBER.test(field) ? `'${field}` : field).replaceAll("\"", "\"\"")}"`;
|
|
2206
|
+
}
|
|
2207
|
+
function record$1(fields) {
|
|
2208
|
+
return fields.map(escape).join(",");
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* `Amount` is the major-unit decimal value, not the display string, because a
|
|
2212
|
+
* CSV is data and the sheet has its own `Currency` column to pair it with. The
|
|
2213
|
+
* sign lives in `Direction`, which is the code the row carries.
|
|
2214
|
+
*/
|
|
2215
|
+
function buildCsv(items, actor, search) {
|
|
2216
|
+
const records = [record$1(COLUMNS)];
|
|
2217
|
+
for (const item of items) {
|
|
2218
|
+
const row = toActivityRow(item, actor);
|
|
2219
|
+
if (!matchesSearch(row, search)) continue;
|
|
2220
|
+
records.push(record$1([
|
|
2221
|
+
new Date(row.createdAt).toISOString(),
|
|
2222
|
+
row.kind,
|
|
2223
|
+
row.kind === "payment" ? row.paymentType : row.classification,
|
|
2224
|
+
row.counterpartyLabel,
|
|
2225
|
+
row.direction,
|
|
2226
|
+
item.amount.value,
|
|
2227
|
+
assetSymbolFor(item.amount.asset),
|
|
2228
|
+
row.kind === "payment" ? row.execution?.notSent ? "not_sent" : row.execution?.status === "unresolved" ? "verifying" : row.status : "",
|
|
2229
|
+
row.reference.id,
|
|
2230
|
+
row.receipt?.transactionHash ?? ""
|
|
2231
|
+
]));
|
|
2232
|
+
}
|
|
2233
|
+
return {
|
|
2234
|
+
text: records.join("\r\n"),
|
|
2235
|
+
rowCount: records.length - 1
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
//#endregion
|
|
2239
|
+
//#region src/activity/use-csv-export.ts
|
|
2240
|
+
/**
|
|
2241
|
+
* The export ceiling is HARD: 50 pages of 100 is at most 5,000 rows. An
|
|
2242
|
+
* unbounded `do…while (cursor)` drain is the exact defect this item deletes
|
|
2243
|
+
* from the app; re-creating it inside the SDK would move the bug, not fix it.
|
|
2244
|
+
*/
|
|
2245
|
+
const EXPORT_PAGE_SIZE = 100;
|
|
2246
|
+
const EXPORT_PAGE_CEILING = 50;
|
|
2247
|
+
/** The activity engine's CSV export: every page of one export is one attempt. */
|
|
2248
|
+
function useCsvExport(input) {
|
|
2249
|
+
const { actor, client, listParams, searchText } = input;
|
|
2250
|
+
const [isExporting, setIsExporting] = useState(false);
|
|
2251
|
+
return {
|
|
2252
|
+
toCsv: useCallback(async (options) => {
|
|
2253
|
+
setIsExporting(true);
|
|
2254
|
+
const controls = toInvocationControls(toInvocationOptions(options));
|
|
2255
|
+
try {
|
|
2256
|
+
const bootstrapped = requireBootstrappedClient(client, CAPXUL_OPERATIONS.activity.items.list);
|
|
2257
|
+
const items = [];
|
|
2258
|
+
let readWarning;
|
|
2259
|
+
let cursor;
|
|
2260
|
+
for (let page = 0; page < EXPORT_PAGE_CEILING; page += 1) {
|
|
2261
|
+
const result = unwrapCapxulResult(await bootstrapped.activity.list(listParams(cursor, EXPORT_PAGE_SIZE), controls));
|
|
2262
|
+
readWarning ??= result.readWarning;
|
|
2263
|
+
items.push(...result.items);
|
|
2264
|
+
cursor = result.nextCursor ?? void 0;
|
|
2265
|
+
if (cursor === void 0) break;
|
|
2266
|
+
}
|
|
2267
|
+
return {
|
|
2268
|
+
...buildCsv(items, actor, searchText),
|
|
2269
|
+
truncated: cursor !== void 0,
|
|
2270
|
+
...readWarning === void 0 ? {} : { readWarning }
|
|
2271
|
+
};
|
|
2272
|
+
} finally {
|
|
2273
|
+
setIsExporting(false);
|
|
2274
|
+
}
|
|
2275
|
+
}, [
|
|
2276
|
+
actor,
|
|
2277
|
+
client,
|
|
2278
|
+
listParams,
|
|
2279
|
+
searchText
|
|
2280
|
+
]),
|
|
2281
|
+
isExporting
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
//#endregion
|
|
2285
|
+
//#region src/activity/use-activity.ts
|
|
2286
|
+
/** The band is the backend's own — it hard-rejects a limit outside it. */
|
|
2287
|
+
const MIN_PAGE_SIZE = 1;
|
|
2288
|
+
const MAX_PAGE_SIZE = 100;
|
|
2289
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
2290
|
+
const KIND_OPTIONS = Object.freeze(["payment", "movement"]);
|
|
2291
|
+
/** Clamps into 1..100. A non-number, or a fraction, lands on the default band. */
|
|
2292
|
+
function clampPageSize(pageSize) {
|
|
2293
|
+
if (pageSize === void 0 || !Number.isFinite(pageSize)) return DEFAULT_PAGE_SIZE;
|
|
2294
|
+
return Math.min(MAX_PAGE_SIZE, Math.max(MIN_PAGE_SIZE, Math.trunc(pageSize)));
|
|
2295
|
+
}
|
|
2296
|
+
function useActivity(input) {
|
|
2297
|
+
const { actor, pageSize } = input;
|
|
2298
|
+
const client = useCapxulClientOrNull();
|
|
2299
|
+
const identity = useCapxulIdentityOrNull();
|
|
2300
|
+
const account = actingAccount(identity);
|
|
2301
|
+
const enabled = useActivityEnabled();
|
|
2302
|
+
const limit = clampPageSize(pageSize);
|
|
2303
|
+
const warned = useRef(false);
|
|
2304
|
+
useEffect(() => {
|
|
2305
|
+
if (warned.current || pageSize === void 0 || pageSize === limit) return;
|
|
2306
|
+
warned.current = true;
|
|
2307
|
+
if (process.env.NODE_ENV !== "production") console.warn(`CapxulActivity: pageSize ${String(pageSize)} is outside 1..100 and was clamped to ${String(limit)}.`);
|
|
2308
|
+
}, [limit, pageSize]);
|
|
2309
|
+
const [kind, setKind] = useState(void 0);
|
|
2310
|
+
const [direction, setDirection] = useState(void 0);
|
|
2311
|
+
const [status, setStatus] = useState([]);
|
|
2312
|
+
const [searchText, setSearchText] = useState("");
|
|
2313
|
+
const values = useMemo(() => ({
|
|
2314
|
+
...kind === void 0 ? {} : { kind },
|
|
2315
|
+
...direction === void 0 ? {} : { direction },
|
|
2316
|
+
...status.length === 0 ? {} : { status }
|
|
2317
|
+
}), [
|
|
2318
|
+
direction,
|
|
2319
|
+
kind,
|
|
2320
|
+
status
|
|
2321
|
+
]);
|
|
2322
|
+
const filter = useMemo(() => Object.keys(values).length === 0 ? void 0 : values, [values]);
|
|
2323
|
+
const listParams = useCallback((cursor, pageLimit) => {
|
|
2324
|
+
const params = { limit: pageLimit };
|
|
2325
|
+
if (actor !== void 0) params.actor = actor;
|
|
2326
|
+
if (cursor !== void 0) params.cursor = cursor;
|
|
2327
|
+
if (filter !== void 0) params.filter = filter;
|
|
2328
|
+
return params;
|
|
2329
|
+
}, [actor, filter]);
|
|
2330
|
+
const selectedRetry = useRef(void 0);
|
|
2331
|
+
const takeSelectedRetry = useCallback(() => {
|
|
2332
|
+
const attempt = selectedRetry.current;
|
|
2333
|
+
selectedRetry.current = void 0;
|
|
2334
|
+
return toInvocationControls(attempt);
|
|
2335
|
+
}, []);
|
|
2336
|
+
const query = useInfiniteQuery({
|
|
2337
|
+
queryKey: accountKey([
|
|
2338
|
+
...capxulKeys.activity,
|
|
2339
|
+
"list",
|
|
2340
|
+
actor ?? "self",
|
|
2341
|
+
limit,
|
|
2342
|
+
filter ?? "none"
|
|
2343
|
+
], identity),
|
|
2344
|
+
queryFn: async ({ pageParam }) => {
|
|
2345
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.activity.items.list).activity.list(listParams(pageParam, limit), takeSelectedRetry()));
|
|
2346
|
+
},
|
|
2347
|
+
initialPageParam: void 0,
|
|
2348
|
+
getNextPageParam: (last) => last.nextCursor ?? void 0,
|
|
2349
|
+
enabled
|
|
2350
|
+
});
|
|
2351
|
+
const queryClient = useQueryClient();
|
|
2352
|
+
useEffect(() => {
|
|
2353
|
+
if (!enabled || client === null) return;
|
|
2354
|
+
let stop;
|
|
2355
|
+
let cancelled = false;
|
|
2356
|
+
client.activity.subscribe(() => {
|
|
2357
|
+
if (cancelled) return;
|
|
2358
|
+
queryClient.invalidateQueries({ queryKey: capxulKeys.activity });
|
|
2359
|
+
}, actor === void 0 ? void 0 : { actor }).then((result) => {
|
|
2360
|
+
if (!result.ok) return;
|
|
2361
|
+
if (cancelled) result.value();
|
|
2362
|
+
else stop = result.value;
|
|
2363
|
+
});
|
|
2364
|
+
return () => {
|
|
2365
|
+
cancelled = true;
|
|
2366
|
+
stop?.();
|
|
2367
|
+
};
|
|
2368
|
+
}, [
|
|
2369
|
+
account,
|
|
2370
|
+
actor,
|
|
2371
|
+
client,
|
|
2372
|
+
enabled,
|
|
2373
|
+
queryClient
|
|
2374
|
+
]);
|
|
2375
|
+
const pages = query.data?.pages;
|
|
2376
|
+
const rows = useMemo(() => {
|
|
2377
|
+
return (pages?.flatMap((page) => page.items) ?? []).map((item) => toActivityRow(item, actor)).filter((row) => matchesSearch(row, searchText));
|
|
2378
|
+
}, [
|
|
2379
|
+
actor,
|
|
2380
|
+
pages,
|
|
2381
|
+
searchText
|
|
2382
|
+
]);
|
|
2383
|
+
const { fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = query;
|
|
2384
|
+
const emptyContinuation = pages?.at(-1)?.items.length === 0 && hasNextPage && query.error === null;
|
|
2385
|
+
useEffect(() => {
|
|
2386
|
+
if (enabled && emptyContinuation && !isFetchingNextPage) fetchNextPage();
|
|
2387
|
+
}, [
|
|
2388
|
+
enabled,
|
|
2389
|
+
emptyContinuation,
|
|
2390
|
+
fetchNextPage,
|
|
2391
|
+
isFetchingNextPage,
|
|
2392
|
+
pages
|
|
2393
|
+
]);
|
|
2394
|
+
const pagingRef = useRef({
|
|
2395
|
+
fetchNextPage,
|
|
2396
|
+
hasNextPage,
|
|
2397
|
+
isFetchingNextPage
|
|
2398
|
+
});
|
|
2399
|
+
useEffect(() => {
|
|
2400
|
+
pagingRef.current = {
|
|
2401
|
+
fetchNextPage,
|
|
2402
|
+
hasNextPage,
|
|
2403
|
+
isFetchingNextPage
|
|
2404
|
+
};
|
|
2405
|
+
});
|
|
2406
|
+
const loadMore = useCallback(() => {
|
|
2407
|
+
const paging = pagingRef.current;
|
|
2408
|
+
if (!paging.hasNextPage || paging.isFetchingNextPage) return;
|
|
2409
|
+
paging.fetchNextPage();
|
|
2410
|
+
}, []);
|
|
2411
|
+
const observerRef = useRef(null);
|
|
2412
|
+
const sentinelRef = useCallback((node) => {
|
|
2413
|
+
observerRef.current?.disconnect();
|
|
2414
|
+
observerRef.current = null;
|
|
2415
|
+
if (node === null || typeof IntersectionObserver === "undefined") return;
|
|
2416
|
+
const observer = new IntersectionObserver((entries) => {
|
|
2417
|
+
if (entries.some((entry) => entry.isIntersecting)) loadMore();
|
|
2418
|
+
}, {
|
|
2419
|
+
root: null,
|
|
2420
|
+
rootMargin: "200px",
|
|
2421
|
+
threshold: 0
|
|
2422
|
+
});
|
|
2423
|
+
observer.observe(node);
|
|
2424
|
+
observerRef.current = observer;
|
|
2425
|
+
}, [loadMore]);
|
|
2426
|
+
const csv = useCsvExport({
|
|
2427
|
+
client,
|
|
2428
|
+
actor,
|
|
2429
|
+
searchText,
|
|
2430
|
+
listParams
|
|
2431
|
+
});
|
|
2432
|
+
const clear = useCallback(() => {
|
|
2433
|
+
setKind(void 0);
|
|
2434
|
+
setDirection(void 0);
|
|
2435
|
+
setStatus([]);
|
|
2436
|
+
}, []);
|
|
2437
|
+
const retry = useCallback((options) => {
|
|
2438
|
+
selectedRetry.current = toInvocationOptions(options);
|
|
2439
|
+
refetch();
|
|
2440
|
+
queryClient.invalidateQueries({
|
|
2441
|
+
queryKey: capxulKeys.activitySummary(actor).slice(0, -1),
|
|
2442
|
+
predicate: (cached) => cached.queryKey.at(-1) === accountKey([], identity)[0]
|
|
2443
|
+
});
|
|
2444
|
+
}, [
|
|
2445
|
+
actor,
|
|
2446
|
+
identity,
|
|
2447
|
+
queryClient,
|
|
2448
|
+
refetch
|
|
2449
|
+
]);
|
|
2450
|
+
return {
|
|
2451
|
+
actor,
|
|
2452
|
+
filters: {
|
|
2453
|
+
values,
|
|
2454
|
+
set: {
|
|
2455
|
+
kind: setKind,
|
|
2456
|
+
direction: setDirection,
|
|
2457
|
+
status: setStatus,
|
|
2458
|
+
clear
|
|
2459
|
+
},
|
|
2460
|
+
options: {
|
|
2461
|
+
kind: KIND_OPTIONS,
|
|
2462
|
+
direction: PAYMENT_DIRECTIONS,
|
|
2463
|
+
status: PAYMENT_STATUSES
|
|
2464
|
+
}
|
|
2465
|
+
},
|
|
2466
|
+
search: {
|
|
2467
|
+
value: searchText,
|
|
2468
|
+
change: setSearchText
|
|
2469
|
+
},
|
|
2470
|
+
rows: {
|
|
2471
|
+
rows,
|
|
2472
|
+
isLoading: query.isLoading || rows.length === 0 && emptyContinuation,
|
|
2473
|
+
error: asCapxulError(query.error),
|
|
2474
|
+
retry,
|
|
2475
|
+
observedAt: pages === void 0 ? null : pages[pages.length - 1]?.observedAt ?? null,
|
|
2476
|
+
...pages?.some((page) => page.readWarning === "indexer-unavailable") ? { readWarning: "indexer-unavailable" } : {}
|
|
2477
|
+
},
|
|
2478
|
+
loadMore: {
|
|
2479
|
+
hasMore: hasNextPage,
|
|
2480
|
+
loadMore,
|
|
2481
|
+
isLoadingMore: isFetchingNextPage,
|
|
2482
|
+
sentinelRef
|
|
2483
|
+
},
|
|
2484
|
+
csv
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
//#endregion
|
|
2488
|
+
//#region src/activity/activity.tsx
|
|
2489
|
+
const ActivityContext = createContext(null);
|
|
2490
|
+
function useActivityContext() {
|
|
2491
|
+
const engine = useContext(ActivityContext);
|
|
2492
|
+
if (engine === null) throw new Error("CapxulActivity parts must be used inside <CapxulActivity>");
|
|
2493
|
+
return engine;
|
|
2494
|
+
}
|
|
2495
|
+
function Root$3({ actor, pageSize, children }) {
|
|
2496
|
+
const engine = useActivity({
|
|
2497
|
+
...actor === void 0 ? {} : { actor },
|
|
2498
|
+
...pageSize === void 0 ? {} : { pageSize }
|
|
2499
|
+
});
|
|
2500
|
+
return /* @__PURE__ */ jsx(ActivityContext.Provider, {
|
|
2501
|
+
value: engine,
|
|
2502
|
+
children
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
/**
|
|
2506
|
+
* The one part that holds a query rather than reading the engine's: it takes a
|
|
2507
|
+
* `window` prop, and two placements can ask for different windows, so a single
|
|
2508
|
+
* pre-fetched result cannot serve both. It still holds no logic and draws
|
|
2509
|
+
* nothing.
|
|
2510
|
+
*/
|
|
2511
|
+
function Summary$3({ window = "all", children }) {
|
|
2512
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivitySummary(useActivityContext().actor, window)) });
|
|
2513
|
+
}
|
|
2514
|
+
function Filters({ children }) {
|
|
2515
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().filters) });
|
|
2516
|
+
}
|
|
2517
|
+
function Search({ children }) {
|
|
2518
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().search) });
|
|
2519
|
+
}
|
|
2520
|
+
function Rows({ children }) {
|
|
2521
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().rows) });
|
|
2522
|
+
}
|
|
2523
|
+
function LoadMore({ children }) {
|
|
2524
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().loadMore) });
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* The second part that holds a query: it takes a `reference` prop and the root
|
|
2528
|
+
* has no idea which row was clicked. With no `reference` it issues no request.
|
|
2529
|
+
*/
|
|
2530
|
+
function DetailInstance({ actor, reference, children }) {
|
|
2531
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityDetail(reference, actor)) });
|
|
2532
|
+
}
|
|
2533
|
+
function Detail({ reference, children }) {
|
|
2534
|
+
const { actor } = useActivityContext();
|
|
2535
|
+
const identity = useCapxulIdentityOrNull();
|
|
2536
|
+
return /* @__PURE__ */ jsx(DetailInstance, {
|
|
2537
|
+
actor,
|
|
2538
|
+
reference,
|
|
2539
|
+
children
|
|
2540
|
+
}, JSON.stringify(accountKey([activityDetailKey(actor, reference)], identity)));
|
|
2541
|
+
}
|
|
2542
|
+
function Export({ children }) {
|
|
2543
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().csv) });
|
|
2544
|
+
}
|
|
2545
|
+
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
2546
|
+
const CapxulActivity = Object.assign(Root$3, {
|
|
2547
|
+
Summary: Summary$3,
|
|
2548
|
+
Filters,
|
|
2549
|
+
Search,
|
|
2550
|
+
Rows,
|
|
2551
|
+
LoadMore,
|
|
2552
|
+
Detail,
|
|
2553
|
+
Export
|
|
2554
|
+
});
|
|
2555
|
+
//#endregion
|
|
2556
|
+
//#region src/media/use-capxul-image-upload.ts
|
|
2557
|
+
/**
|
|
2558
|
+
* #1061 / ADR-0014: the whole upload→record sequence as one mutation —
|
|
2559
|
+
* upload the blob, then bind the returned storage id to the profile image or
|
|
2560
|
+
* the org logo. The url is the resolved serving URL (re-read from queries,
|
|
2561
|
+
* never persisted by consumers).
|
|
2562
|
+
*/
|
|
2563
|
+
function useCapxulImageUpload() {
|
|
2564
|
+
const client = useCapxulClientOrNull();
|
|
2565
|
+
const queryClient = useQueryClient();
|
|
2566
|
+
return useMutation({
|
|
2567
|
+
mutationFn: async ({ blob, target }) => {
|
|
2568
|
+
const media = requireBootstrappedClient(client, CAPXUL_OPERATIONS.media.uploadImage).media;
|
|
2569
|
+
const uploaded = unwrapCapxulResult(await media.uploadImage(blob));
|
|
2570
|
+
if (target.kind === "profile") return { url: unwrapCapxulResult(await media.setProfileImage({ storageId: uploaded.storageId })).imageUrl };
|
|
2571
|
+
return { url: unwrapCapxulResult(await media.setOrgLogo({
|
|
2572
|
+
orgId: target.orgId,
|
|
2573
|
+
storageId: uploaded.storageId
|
|
2574
|
+
})).logoUrl };
|
|
2575
|
+
},
|
|
2576
|
+
onSuccess: async (_value, input) => {
|
|
2577
|
+
await queryClient.invalidateQueries({ queryKey: input.target.kind === "profile" ? capxulKeys.profile : capxulKeys.orgs });
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
//#endregion
|
|
2582
|
+
//#region src/media/use-capxul-upload-image.ts
|
|
2583
|
+
/**
|
|
2584
|
+
* #1955: upload only. Hands back the storage id for a CREATE call to carry
|
|
2585
|
+
* (`ProfileDetails.imageStorageId`, `OrganizationDetails.logoStorageId`).
|
|
2586
|
+
*
|
|
2587
|
+
* `useCapxulImageUpload` is upload+bind and stays as it is for the settings
|
|
2588
|
+
* pages. It cannot serve onboarding: the row it binds to does not exist yet.
|
|
2589
|
+
* Nothing is bound here, so nothing is invalidated either — the create call
|
|
2590
|
+
* that carries the id owns the cache change.
|
|
2591
|
+
*/
|
|
2592
|
+
function useCapxulUploadImage() {
|
|
2593
|
+
const client = useCapxulClientOrNull();
|
|
2594
|
+
return useMutation({ mutationFn: async (blob) => {
|
|
2595
|
+
return { storageId: unwrapCapxulResult(await requireBootstrappedClient(client, CAPXUL_OPERATIONS.media.uploadImage).media.uploadImage(blob)).storageId };
|
|
2596
|
+
} });
|
|
2597
|
+
}
|
|
2598
|
+
//#endregion
|
|
2599
|
+
//#region src/parties/use-contacts.ts
|
|
2600
|
+
/** One captured snapshot, or nothing — never a fabricated empty options object. */
|
|
2601
|
+
const carry$1 = (options) => options === void 0 ? {} : { options };
|
|
2602
|
+
function useContacts(input) {
|
|
2603
|
+
const { actor, onAdded, onFailed } = input;
|
|
2604
|
+
const client = useCapxulClientOrNull();
|
|
2605
|
+
const queryClient = useQueryClient();
|
|
2606
|
+
const orgId = actor.kind === "organization" ? actor.orgId : void 0;
|
|
2607
|
+
const scope = actor.kind === "personal" ? "personal" : orgId ?? "pending";
|
|
2608
|
+
const refusal = actor.kind === "organization" && orgId === void 0 ? "org-unavailable" : null;
|
|
2609
|
+
const book = useMemo(() => addressBookOf(client, actor.kind, orgId), [
|
|
2610
|
+
client,
|
|
2611
|
+
actor.kind,
|
|
2612
|
+
orgId
|
|
2613
|
+
]);
|
|
2614
|
+
const invalidate = useCallback(() => queryClient.invalidateQueries({ queryKey: capxulKeys.contacts(scope) }), [queryClient, scope]);
|
|
2615
|
+
const writeMutate = useMutation({
|
|
2616
|
+
mutationFn: async (command) => {
|
|
2617
|
+
const target = requireBook(book, `addressBook.${command.kind}`);
|
|
2618
|
+
const controls = toInvocationControls(command.options);
|
|
2619
|
+
return unwrapCapxulResult(command.kind === "rename" ? await target.label({
|
|
2620
|
+
entryId: command.id,
|
|
2621
|
+
label: command.name
|
|
2622
|
+
}, controls) : command.kind === "hide" ? await target.hide(command.id, controls) : await target.unhide(command.id, controls));
|
|
2623
|
+
},
|
|
2624
|
+
onSettled: invalidate,
|
|
2625
|
+
onError: (error, command) => onFailed(error, command.options)
|
|
2626
|
+
}).mutate;
|
|
2627
|
+
const [value, setValue] = useState("");
|
|
2628
|
+
const [addError, setAddError] = useState(null);
|
|
2629
|
+
const addContact = useMutation({
|
|
2630
|
+
mutationFn: async (command) => unwrapCapxulResult(await requireBook(book, CAPXUL_OPERATIONS.parties.contacts.add).add({ ref: command.ref }, toInvocationControls(command.options))),
|
|
2631
|
+
onSettled: invalidate
|
|
2632
|
+
});
|
|
2633
|
+
const addMutate = addContact.mutate;
|
|
2634
|
+
const blockedReason = refusal !== null ? "org-unavailable" : value.trim() === "" ? "empty" : null;
|
|
2635
|
+
const submitGate = useRef(false);
|
|
2636
|
+
const submit = useCallback((options) => {
|
|
2637
|
+
if (blockedReason !== null || submitGate.current) return;
|
|
2638
|
+
const attempt = toInvocationOptions(options);
|
|
2639
|
+
submitGate.current = true;
|
|
2640
|
+
setAddError(null);
|
|
2641
|
+
addMutate({
|
|
2642
|
+
ref: refFromTypedText(value),
|
|
2643
|
+
...carry$1(attempt)
|
|
2644
|
+
}, {
|
|
2645
|
+
onSuccess: (entry) => {
|
|
2646
|
+
setValue("");
|
|
2647
|
+
onAdded(toContact(entry), attempt);
|
|
2648
|
+
},
|
|
2649
|
+
onError: (error) => {
|
|
2650
|
+
setAddError(error.code === "INVALID_INPUT" ? "unresolved" : "failed");
|
|
2651
|
+
onFailed(error, attempt);
|
|
2652
|
+
},
|
|
2653
|
+
onSettled: () => {
|
|
2654
|
+
submitGate.current = false;
|
|
2655
|
+
}
|
|
2656
|
+
});
|
|
2657
|
+
}, [
|
|
2658
|
+
addMutate,
|
|
2659
|
+
blockedReason,
|
|
2660
|
+
onAdded,
|
|
2661
|
+
onFailed,
|
|
2662
|
+
value
|
|
2663
|
+
]);
|
|
2664
|
+
const change = useCallback((text) => {
|
|
2665
|
+
setValue(text);
|
|
2666
|
+
setAddError(null);
|
|
2667
|
+
}, []);
|
|
2668
|
+
return {
|
|
2669
|
+
book,
|
|
2670
|
+
scope,
|
|
2671
|
+
refusal,
|
|
2672
|
+
rename: useCallback((id, name, options) => writeMutate({
|
|
2673
|
+
kind: "rename",
|
|
2674
|
+
id,
|
|
2675
|
+
name,
|
|
2676
|
+
...carry$1(toInvocationOptions(options))
|
|
2677
|
+
}), [writeMutate]),
|
|
2678
|
+
hide: useCallback((id, options) => writeMutate({
|
|
2679
|
+
kind: "hide",
|
|
2680
|
+
id,
|
|
2681
|
+
...carry$1(toInvocationOptions(options))
|
|
2682
|
+
}), [writeMutate]),
|
|
2683
|
+
unhide: useCallback((id, options) => writeMutate({
|
|
2684
|
+
kind: "unhide",
|
|
2685
|
+
id,
|
|
2686
|
+
...carry$1(toInvocationOptions(options))
|
|
2687
|
+
}), [writeMutate]),
|
|
2688
|
+
add: {
|
|
2689
|
+
value,
|
|
2690
|
+
change,
|
|
2691
|
+
submit,
|
|
2692
|
+
isSubmitting: addContact.isPending,
|
|
2693
|
+
blocked: blockedReason !== null,
|
|
2694
|
+
blockedReason,
|
|
2695
|
+
error: addError
|
|
2696
|
+
}
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
/**
|
|
2700
|
+
* The read every part stands on. Two placements with the same `show` share one
|
|
2701
|
+
* query key, so React Query issues ONE request for them.
|
|
2702
|
+
*/
|
|
2703
|
+
function useContactsList(engine, options) {
|
|
2704
|
+
const identity = useCapxulIdentityOrNull();
|
|
2705
|
+
const includeHidden = options.show === "all";
|
|
2706
|
+
const { book, refusal } = engine;
|
|
2707
|
+
const query = useQuery({
|
|
2708
|
+
queryKey: accountKey(capxulKeys.contactsList(engine.scope, includeHidden), identity),
|
|
2709
|
+
queryFn: async () => unwrapCapxulResult(await requireBook(book, CAPXUL_OPERATIONS.parties.contacts.list).list({ includeHidden })).items,
|
|
2710
|
+
enabled: book !== null
|
|
2711
|
+
});
|
|
2712
|
+
const reason = refusal ?? (query.isError ? "unavailable" : query.data === void 0 ? "loading" : null);
|
|
2713
|
+
const entries = query.data;
|
|
2714
|
+
const { limit } = options;
|
|
2715
|
+
const { rename, hide, unhide } = engine;
|
|
2716
|
+
return {
|
|
2717
|
+
rows: useMemo(() => {
|
|
2718
|
+
if (entries === void 0) return [];
|
|
2719
|
+
return (limit === void 0 ? entries : entries.slice(0, limit)).map((entry) => Object.assign(toContact(entry), {
|
|
2720
|
+
initials: initialsOf(entry.label),
|
|
2721
|
+
rename: (name, attempt) => rename(entry.id, name, attempt),
|
|
2722
|
+
hide: (attempt) => hide(entry.id, attempt),
|
|
2723
|
+
unhide: (attempt) => unhide(entry.id, attempt)
|
|
2724
|
+
}));
|
|
2725
|
+
}, [
|
|
2726
|
+
entries,
|
|
2727
|
+
hide,
|
|
2728
|
+
limit,
|
|
2729
|
+
rename,
|
|
2730
|
+
unhide
|
|
2731
|
+
]),
|
|
2732
|
+
isLoading: reason === "loading",
|
|
2733
|
+
reason
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
/** Counts over the same visible read `.List` uses — one request, two parts. */
|
|
2737
|
+
function useContactsSummary(engine) {
|
|
2738
|
+
const { rows, isLoading, reason } = useContactsList(engine, {});
|
|
2739
|
+
const settled = reason === null;
|
|
2740
|
+
return {
|
|
2741
|
+
total: settled ? rows.length : null,
|
|
2742
|
+
activeCount: settled ? rows.filter((row) => row.relationship.includes("paid")).length : null,
|
|
2743
|
+
isLoading,
|
|
2744
|
+
reason
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
function addressBookOf(client, kind, orgId) {
|
|
2748
|
+
if (client === null) return null;
|
|
2749
|
+
if (kind === "personal") return client.account.addressBook;
|
|
2750
|
+
return orgId === void 0 ? null : client.org(orgId).addressBook;
|
|
2751
|
+
}
|
|
2752
|
+
function requireBook(book, method) {
|
|
2753
|
+
if (book === null) throw Errors.wrongState({
|
|
2754
|
+
method,
|
|
2755
|
+
currentState: "bootstrapping",
|
|
2756
|
+
validStates: ["ready"]
|
|
2757
|
+
});
|
|
2758
|
+
return book;
|
|
2759
|
+
}
|
|
2760
|
+
function toContact(entry) {
|
|
2761
|
+
return {
|
|
2762
|
+
id: entry.id,
|
|
2763
|
+
name: entry.label,
|
|
2764
|
+
relationship: entry.relationship,
|
|
2765
|
+
hidden: entry.hidden,
|
|
2766
|
+
lastActivityAt: entry.lastActivityAt
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
/**
|
|
2770
|
+
* One typed field, both shapes. A leading `@` names a handle; an `@` anywhere
|
|
2771
|
+
* else names an email — what the app already builds for Quick Pay, and what the
|
|
2772
|
+
* backend resolver already accepts.
|
|
2773
|
+
*/
|
|
2774
|
+
function refFromTypedText(text) {
|
|
2775
|
+
const trimmed = text.trim();
|
|
2776
|
+
return trimmed.includes("@") && !trimmed.startsWith("@") ? {
|
|
2777
|
+
kind: "email",
|
|
2778
|
+
email: trimmed
|
|
2779
|
+
} : {
|
|
2780
|
+
kind: "handle",
|
|
2781
|
+
handle: trimmed.replace(/^@/, "")
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2784
|
+
/** First letters of the first two words, uppercased. */
|
|
2785
|
+
function initialsOf(name) {
|
|
2786
|
+
return name.trim().split(/\s+/).slice(0, 2).map((word) => word.charAt(0).toUpperCase()).join("");
|
|
2787
|
+
}
|
|
2788
|
+
//#endregion
|
|
2789
|
+
//#region src/parties/contacts.tsx
|
|
2790
|
+
const ContactsContext = createContext(null);
|
|
2791
|
+
function useContactsContext() {
|
|
2792
|
+
const engine = useContext(ContactsContext);
|
|
2793
|
+
if (engine === null) throw new Error("CapxulContacts parts must be used inside <CapxulContacts>");
|
|
2794
|
+
return engine;
|
|
2795
|
+
}
|
|
2796
|
+
function Root$2({ actor, onAdded, onFailed, children }) {
|
|
2797
|
+
const engine = useContacts({
|
|
2798
|
+
actor,
|
|
2799
|
+
onAdded,
|
|
2800
|
+
onFailed
|
|
2801
|
+
});
|
|
2802
|
+
return /* @__PURE__ */ jsx(ContactsContext.Provider, {
|
|
2803
|
+
value: engine,
|
|
2804
|
+
children
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
function Summary$2({ children }) {
|
|
2808
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useContactsSummary(useContactsContext())) });
|
|
2809
|
+
}
|
|
2810
|
+
function List({ limit, show, children }) {
|
|
2811
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useContactsList(useContactsContext(), {
|
|
2812
|
+
limit,
|
|
2813
|
+
show
|
|
2814
|
+
})) });
|
|
2815
|
+
}
|
|
2816
|
+
function Add({ children }) {
|
|
2817
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useContactsContext().add) });
|
|
2818
|
+
}
|
|
2819
|
+
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
2820
|
+
const CapxulContacts = Object.assign(Root$2, {
|
|
2821
|
+
Summary: Summary$2,
|
|
2822
|
+
List,
|
|
2823
|
+
Add
|
|
2824
|
+
});
|
|
2825
|
+
//#endregion
|
|
2826
|
+
//#region src/payroll/draft-decoding.ts
|
|
2827
|
+
function decodeDraft(value, scope) {
|
|
2828
|
+
const row = record(value);
|
|
2829
|
+
const asset = record(row.asset);
|
|
2830
|
+
const input = record(row.input);
|
|
2831
|
+
const period = record(input.period);
|
|
2832
|
+
if (row.version !== 1 || row.scope !== scope || typeof asset.currency !== "string" || !/^[A-Z][A-Z0-9]{1,11}$/u.test(asset.currency) || typeof asset.decimals !== "number" || !Number.isInteger(asset.decimals) || asset.decimals < 0 || asset.decimals > 255 || typeof period.start !== "number" || !Number.isSafeInteger(period.start) || period.start <= 0 || period.end !== period.start || !Array.isArray(input.items) || input.items.length === 0) throw Errors.invalidInput("payrollDraft", "Invalid run draft");
|
|
2833
|
+
const items = input.items.map((raw) => {
|
|
2834
|
+
const item = record(raw);
|
|
2835
|
+
const to = record(item.to);
|
|
2836
|
+
const amount = record(item.amount);
|
|
2837
|
+
const partyId = toPartyId(identifier(item.partyId));
|
|
2838
|
+
if (to.kind !== "party" || to.partyId !== partyId || item.gross !== item.net || typeof amount.asset !== "string" || typeof amount.value !== "string" || "currency" in item || "decimals" in item || !Array.isArray(item.adjustments) || item.adjustments.length !== 0) throw Errors.invalidInput("payrollDraft", "Invalid draft recipient");
|
|
2839
|
+
const net = minorAmount(item.net);
|
|
2840
|
+
const selectedAsset = requireAsset(toAssetId(identifier(amount.asset)));
|
|
2841
|
+
const selectedAmount = {
|
|
2842
|
+
asset: selectedAsset.assetId,
|
|
2843
|
+
value: amount.value
|
|
2844
|
+
};
|
|
2845
|
+
try {
|
|
2846
|
+
if (toRaw(selectedAmount, selectedAsset).toString() !== net) throw Errors.invalidInput("payrollDraft", "Draft payout units differ from net");
|
|
2847
|
+
} catch {
|
|
2848
|
+
throw Errors.invalidInput("payrollDraft", "Invalid draft payout amount");
|
|
2849
|
+
}
|
|
2850
|
+
return {
|
|
2851
|
+
to: {
|
|
2852
|
+
kind: "party",
|
|
2853
|
+
partyId
|
|
2854
|
+
},
|
|
2855
|
+
partyId,
|
|
2856
|
+
amount: selectedAmount,
|
|
2857
|
+
gross: net,
|
|
2858
|
+
net,
|
|
2859
|
+
adjustments: []
|
|
2860
|
+
};
|
|
2861
|
+
});
|
|
2862
|
+
if (new Set(items.map((item) => item.partyId)).size !== items.length) throw Errors.invalidInput("payrollDraft", "Duplicate draft recipient");
|
|
2863
|
+
const exactAsset = requireAsset(toAssetId(identifier(asset.id)));
|
|
2864
|
+
if (input.asset !== exactAsset.assetId || items.some((item) => item.amount.asset !== exactAsset.assetId)) throw Errors.invalidInput("payrollDraft", "Draft item asset does not match the run asset");
|
|
2865
|
+
return {
|
|
2866
|
+
version: 1,
|
|
2867
|
+
scope,
|
|
2868
|
+
asset: {
|
|
2869
|
+
id: identifier(asset.id),
|
|
2870
|
+
currency: asset.currency,
|
|
2871
|
+
decimals: asset.decimals
|
|
2872
|
+
},
|
|
2873
|
+
input: {
|
|
2874
|
+
permissionId: identifier(input.permissionId),
|
|
2875
|
+
asset: exactAsset.assetId,
|
|
2876
|
+
period: {
|
|
2877
|
+
start: period.start,
|
|
2878
|
+
end: period.start
|
|
2879
|
+
},
|
|
2880
|
+
items,
|
|
2881
|
+
...input.requestKey === void 0 ? {} : { requestKey: identifier(input.requestKey) }
|
|
2882
|
+
}
|
|
2883
|
+
};
|
|
2884
|
+
}
|
|
2885
|
+
//#endregion
|
|
2886
|
+
//#region src/payroll/run-draft.ts
|
|
2887
|
+
function record(value) {
|
|
2888
|
+
if (!isRecord(value)) throw Errors.invalidInput("payrollDraft", "Invalid run draft");
|
|
2889
|
+
return value;
|
|
2890
|
+
}
|
|
2891
|
+
/**
|
|
2892
|
+
* A draft-scoped opaque key. `/` is in the class so `asset.id` can be the
|
|
2893
|
+
* canonical CAIP-19 `AssetId` (`eip155:<chainId>/erc20:<address>`): the draft
|
|
2894
|
+
* records WHICH asset was authorized, and a symbol is not an identity
|
|
2895
|
+
* (ADR-0028 R3). The field never reaches the backend — only `input` is sent.
|
|
2896
|
+
*/
|
|
2897
|
+
function identifier(value) {
|
|
2898
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/u.test(value)) throw Errors.invalidInput("payrollDraft", "Invalid draft identifier");
|
|
2899
|
+
return value;
|
|
2900
|
+
}
|
|
2901
|
+
function minorAmount(value) {
|
|
2902
|
+
if (typeof value !== "string" || !/^\d+$/u.test(value) || BigInt(value) <= 0n) throw Errors.invalidInput("payrollDraft", "Invalid draft amount");
|
|
2903
|
+
return value;
|
|
2904
|
+
}
|
|
2905
|
+
function runDraftError(cause) {
|
|
2906
|
+
return cause instanceof CapxulError ? cause : Errors.providerError("sdk-react", "payrollDraft", cause);
|
|
2907
|
+
}
|
|
2908
|
+
function storageKey(scope) {
|
|
2909
|
+
return `capxul.payroll.run-draft.v1:${scope}`;
|
|
2910
|
+
}
|
|
2911
|
+
function readRunDraft(scope) {
|
|
2912
|
+
try {
|
|
2913
|
+
const raw = localStorage.getItem(storageKey(scope));
|
|
2914
|
+
if (raw === null) return null;
|
|
2915
|
+
let value;
|
|
2916
|
+
try {
|
|
2917
|
+
value = JSON.parse(raw);
|
|
2918
|
+
} catch {
|
|
2919
|
+
throw Errors.invalidInput("payrollDraft", "Invalid run draft JSON");
|
|
2920
|
+
}
|
|
2921
|
+
return decodeDraft(value, scope);
|
|
2922
|
+
} catch (cause) {
|
|
2923
|
+
throw runDraftError(cause);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
function sameDraft(left, right) {
|
|
2927
|
+
const { requestKey: _leftKey, ...leftInput } = left.input;
|
|
2928
|
+
const { requestKey: _rightKey, ...rightInput } = right.input;
|
|
2929
|
+
return left.scope === right.scope && JSON.stringify(left.asset) === JSON.stringify(right.asset) && JSON.stringify(leftInput) === JSON.stringify(rightInput);
|
|
2930
|
+
}
|
|
2931
|
+
/** Hold the draft across the Core call, including component unmount. A reload releases the native lock. */
|
|
2932
|
+
async function executeRunDraft(candidate, work) {
|
|
2933
|
+
try {
|
|
2934
|
+
if (navigator.locks === void 0) throw Errors.invalidInput("payrollDraft", "Web Locks are required for run drafts");
|
|
2935
|
+
return await navigator.locks.request(storageKey(candidate.scope), { ifAvailable: true }, async (lock) => {
|
|
2936
|
+
if (lock === null) throw Errors.wrongState({
|
|
2937
|
+
method: CAPXUL_OPERATIONS.payroll.runs.authorize,
|
|
2938
|
+
currentState: "run-in-flight",
|
|
2939
|
+
validStates: ["run-idle"]
|
|
2940
|
+
});
|
|
2941
|
+
let draft = readRunDraft(candidate.scope) ?? decodeDraft(candidate, candidate.scope);
|
|
2942
|
+
if (!sameDraft(draft, candidate)) throw Errors.wrongState({
|
|
2943
|
+
method: CAPXUL_OPERATIONS.payroll.runs.authorize,
|
|
2944
|
+
currentState: "unresolved-run-draft",
|
|
2945
|
+
validStates: ["resolved-run-draft"]
|
|
2946
|
+
});
|
|
2947
|
+
localStorage.setItem(storageKey(draft.scope), JSON.stringify(draft));
|
|
2948
|
+
const clear = () => {
|
|
2949
|
+
const stored = readRunDraft(draft.scope);
|
|
2950
|
+
if (stored === null || !sameDraft(stored, draft) || stored.input.requestKey !== draft.input.requestKey) throw Errors.invalidInput("payrollDraft", "Run draft changed during submission");
|
|
2951
|
+
localStorage.removeItem(storageKey(draft.scope));
|
|
2952
|
+
};
|
|
2953
|
+
const run = await work(draft, (key) => {
|
|
2954
|
+
const next = decodeDraft({
|
|
2955
|
+
...draft,
|
|
2956
|
+
input: {
|
|
2957
|
+
...draft.input,
|
|
2958
|
+
requestKey: key
|
|
2959
|
+
}
|
|
2960
|
+
}, draft.scope);
|
|
2961
|
+
localStorage.setItem(storageKey(draft.scope), JSON.stringify(next));
|
|
2962
|
+
draft = next;
|
|
2963
|
+
}, clear);
|
|
2964
|
+
try {
|
|
2965
|
+
clear();
|
|
2966
|
+
} catch {}
|
|
2967
|
+
return run;
|
|
2968
|
+
});
|
|
2969
|
+
} catch (cause) {
|
|
2970
|
+
throw runDraftError(cause);
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
/** Persistence becomes active only in a payable composer, never in the group editor. */
|
|
2974
|
+
function useRunDraft(client, orgId, apply) {
|
|
2975
|
+
const identity = useCapxulIdentityOrNull();
|
|
2976
|
+
const ownerId = identity?.phase === "authenticated" ? identity.session.authUserId : null;
|
|
2977
|
+
const draftScope = client === null || ownerId === null || orgId === void 0 ? null : JSON.stringify([
|
|
2978
|
+
client._internal.bootstrap.convexUrl,
|
|
2979
|
+
client._internal.bootstrap.chainId,
|
|
2980
|
+
ownerId,
|
|
2981
|
+
orgId
|
|
2982
|
+
]);
|
|
2983
|
+
const currentScope = useRef(draftScope);
|
|
2984
|
+
currentScope.current = draftScope;
|
|
2985
|
+
const [active, setActive] = useState(false);
|
|
2986
|
+
const activateDraft = useCallback(() => setActive(true), []);
|
|
2987
|
+
const [restoredScope, setRestoredScope] = useState(null);
|
|
2988
|
+
const [stored, setDraft] = useState(null);
|
|
2989
|
+
const [draftError, setDraftError] = useState(null);
|
|
2990
|
+
const draft = active && stored?.scope === draftScope ? stored : null;
|
|
2991
|
+
const restorationPending = active && (draftScope === null || restoredScope !== draftScope);
|
|
2992
|
+
const restoreDraft = useCallback((scope) => {
|
|
2993
|
+
try {
|
|
2994
|
+
const restored = readRunDraft(scope);
|
|
2995
|
+
setDraft(restored);
|
|
2996
|
+
setDraftError(null);
|
|
2997
|
+
if (restored !== null) apply(restored);
|
|
2998
|
+
} catch (cause) {
|
|
2999
|
+
setDraft(null);
|
|
3000
|
+
setDraftError(runDraftError(cause));
|
|
3001
|
+
}
|
|
3002
|
+
setRestoredScope(scope);
|
|
3003
|
+
}, [apply]);
|
|
3004
|
+
useEffect(() => {
|
|
3005
|
+
if (active && draftScope !== null) restoreDraft(draftScope);
|
|
3006
|
+
}, [
|
|
3007
|
+
active,
|
|
3008
|
+
draftScope,
|
|
3009
|
+
restoreDraft
|
|
3010
|
+
]);
|
|
3011
|
+
return {
|
|
3012
|
+
draftScope,
|
|
3013
|
+
currentScope,
|
|
3014
|
+
draft,
|
|
3015
|
+
draftError,
|
|
3016
|
+
restorationPending,
|
|
3017
|
+
activateDraft,
|
|
3018
|
+
restoreDraft,
|
|
3019
|
+
setDraft,
|
|
3020
|
+
recovery: !active || restorationPending ? "restoring" : draftError !== null ? "unavailable" : draft !== null ? "required" : "none",
|
|
3021
|
+
draftBlocked: !active || restorationPending || draftError !== null
|
|
3022
|
+
};
|
|
3023
|
+
}
|
|
3024
|
+
//#endregion
|
|
3025
|
+
//#region src/payroll/reads.ts
|
|
3026
|
+
function toRecipientOption(entry) {
|
|
3027
|
+
return {
|
|
3028
|
+
id: entry.id,
|
|
3029
|
+
name: entry.label,
|
|
3030
|
+
email: entry.label.includes("@") ? entry.label : null
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* The roster: active `employed` edges ∪ org members, deduped by party
|
|
3035
|
+
* (ruling Q1 = C).
|
|
3036
|
+
*
|
|
3037
|
+
* The organization address book computes exactly that union server-side —
|
|
3038
|
+
* members accumulate from `orgMembers`, employment is PROJECTED from open
|
|
3039
|
+
* `employed` edges, and both land in ONE map keyed by party
|
|
3040
|
+
* (`parties.contacts.list`). Contact edges live in the same book and are
|
|
3041
|
+
* not the roster, so the two relationships the roster is made of are the
|
|
3042
|
+
* filter.
|
|
3043
|
+
*/
|
|
3044
|
+
function useOrgRoster(orgId) {
|
|
3045
|
+
const client = useCapxulClientOrNull();
|
|
3046
|
+
const identity = useCapxulIdentityOrNull();
|
|
3047
|
+
const query = useQuery({
|
|
3048
|
+
queryKey: accountKey(capxulKeys.contactsList(orgId ?? "pending", false), identity),
|
|
3049
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.parties.contacts.list, orgId).addressBook.list({ includeHidden: false })).items,
|
|
3050
|
+
enabled: client !== null && orgId !== void 0
|
|
3051
|
+
});
|
|
3052
|
+
const entries = query.data;
|
|
3053
|
+
return useMemo(() => {
|
|
3054
|
+
const roster = (entries ?? []).filter((entry) => entry.relationship.includes("member") || entry.relationship.includes("employed"));
|
|
3055
|
+
return {
|
|
3056
|
+
options: roster.map(toRecipientOption),
|
|
3057
|
+
refs: new Map(roster.map((entry) => [entry.id, entry.ref])),
|
|
3058
|
+
isLoading: orgId !== void 0 && entries === void 0 && query.error === null,
|
|
3059
|
+
error: query.error
|
|
3060
|
+
};
|
|
3061
|
+
}, [
|
|
3062
|
+
entries,
|
|
3063
|
+
orgId,
|
|
3064
|
+
query.error
|
|
3065
|
+
]);
|
|
3066
|
+
}
|
|
3067
|
+
function usePayrollGroups(orgId) {
|
|
3068
|
+
const client = useCapxulClientOrNull();
|
|
3069
|
+
const identity = useCapxulIdentityOrNull();
|
|
3070
|
+
return useQuery({
|
|
3071
|
+
queryKey: accountKey(capxulKeys.payrollGroups(orgId), identity),
|
|
3072
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.org.payroll.groups.list, orgId).payroll.groups.list()),
|
|
3073
|
+
enabled: client !== null && orgId !== void 0
|
|
3074
|
+
});
|
|
3075
|
+
}
|
|
3076
|
+
function usePayrollRuns(orgId) {
|
|
3077
|
+
const client = useCapxulClientOrNull();
|
|
3078
|
+
const identity = useCapxulIdentityOrNull();
|
|
3079
|
+
return useQuery({
|
|
3080
|
+
queryKey: accountKey(capxulKeys.payrollRuns(orgId), identity),
|
|
3081
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.org.payroll.runs, orgId).payroll.runs()),
|
|
3082
|
+
enabled: client !== null && orgId !== void 0
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* The active engagement terms of the org's employed people (#1638) — the rows
|
|
3087
|
+
* ruling Q3 lets `.Amounts` prefill a rate from.
|
|
3088
|
+
*
|
|
3089
|
+
* The read is gated on `canSpend` server-side, exactly as the groups read is.
|
|
3090
|
+
* A member who may not spend therefore reads an error here and composes with no
|
|
3091
|
+
* seed, which is the same composer they had before this read existed.
|
|
3092
|
+
*/
|
|
3093
|
+
function useOrgTerms(orgId) {
|
|
3094
|
+
const client = useCapxulClientOrNull();
|
|
3095
|
+
const identity = useCapxulIdentityOrNull();
|
|
3096
|
+
return useQuery({
|
|
3097
|
+
queryKey: accountKey(capxulKeys.payrollTerms(orgId), identity),
|
|
3098
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.org.payroll.terms, orgId).payroll.terms()),
|
|
3099
|
+
enabled: client !== null && orgId !== void 0
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
function useOrgTreasury(orgId) {
|
|
3103
|
+
const client = useCapxulClientOrNull();
|
|
3104
|
+
const identity = useCapxulIdentityOrNull();
|
|
3105
|
+
return useQuery({
|
|
3106
|
+
queryKey: accountKey(capxulKeys.orgTreasury(orgId), identity),
|
|
3107
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.org.treasury, orgId).treasury()),
|
|
3108
|
+
enabled: client !== null && orgId !== void 0
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
/**
|
|
3112
|
+
* The caller's own standing, read for its Budgets alone (ruling Q2 grades
|
|
3113
|
+
* them). Keyed as `CapxulOrgMember` keys it — Organization AND acting account
|
|
3114
|
+
* (#2007 R19) — so a payroll screen inside a `.Can` wrapper still reads
|
|
3115
|
+
* `org(id).me()` once, and an account replacement is a miss for both of them
|
|
3116
|
+
* rather than the previous person's Budgets for both.
|
|
3117
|
+
*/
|
|
3118
|
+
function useOrgMe(orgId) {
|
|
3119
|
+
const client = useCapxulClientOrNull();
|
|
3120
|
+
const identity = useCapxulIdentityOrNull();
|
|
3121
|
+
return useQuery({
|
|
3122
|
+
queryKey: capxulKeys.orgMe(orgId, actingAccount(identity)),
|
|
3123
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.org.me, orgId).me()),
|
|
3124
|
+
enabled: client !== null && orgId !== void 0
|
|
3125
|
+
});
|
|
3126
|
+
}
|
|
3127
|
+
/**
|
|
3128
|
+
* The org-scoped bundle inside a query or mutation function. Every caller gates
|
|
3129
|
+
* on `orgId !== undefined`, so the second throw is only reachable from a
|
|
3130
|
+
* mutation fired while the provider is still bootstrapping.
|
|
3131
|
+
*/
|
|
3132
|
+
function scopedClient(client, method, orgId) {
|
|
3133
|
+
const bootstrapped = requireBootstrappedClient(client, method);
|
|
3134
|
+
if (orgId === void 0) throw Errors.invalidInput("orgId", `required for ${method}`);
|
|
3135
|
+
return bootstrapped.org(orgId);
|
|
3136
|
+
}
|
|
3137
|
+
//#endregion
|
|
3138
|
+
//#region src/payroll/use-authorize-run.ts
|
|
3139
|
+
/**
|
|
3140
|
+
* Authorize one persisted run draft. A draft from a scope the composer has
|
|
3141
|
+
* left — another person or organization — is refused, never sent.
|
|
3142
|
+
*/
|
|
3143
|
+
function useAuthorizeRun(input) {
|
|
3144
|
+
const { client, currentScope, orgId, restoreDraft, setDraft } = input;
|
|
3145
|
+
const queryClient = useQueryClient();
|
|
3146
|
+
return useMutation({
|
|
3147
|
+
mutationFn: async ({ candidate, options }) => {
|
|
3148
|
+
if (candidate.scope !== currentScope.current) throw Errors.notAuthenticated();
|
|
3149
|
+
return executeRunDraft(candidate, async (persisted, onRequestKey, onResolved) => {
|
|
3150
|
+
if (candidate.scope === currentScope.current) setDraft(persisted);
|
|
3151
|
+
return unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.org.payroll.authorizeRun, orgId).payroll.authorizeRun(persisted.input, {
|
|
3152
|
+
...toInvocationControls(options),
|
|
3153
|
+
onResolved,
|
|
3154
|
+
onRequestKey: (key) => {
|
|
3155
|
+
if (candidate.scope !== currentScope.current) throw Errors.notAuthenticated();
|
|
3156
|
+
onRequestKey(key);
|
|
3157
|
+
}
|
|
3158
|
+
}));
|
|
3159
|
+
});
|
|
3160
|
+
},
|
|
3161
|
+
onSettled: (_run, _error, { candidate }) => {
|
|
3162
|
+
queryClient.invalidateQueries({ queryKey: capxulKeys.payrollRuns(orgId) });
|
|
3163
|
+
if (candidate.scope === currentScope.current) restoreDraft(candidate.scope);
|
|
3164
|
+
}
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
//#endregion
|
|
3168
|
+
//#region src/payroll/minor-units.ts
|
|
3169
|
+
const TYPED_AMOUNT = /^(-?)(\d+)(?:\.(\d+))?$/;
|
|
3170
|
+
/**
|
|
3171
|
+
* Σ of the employer's TYPED amounts on a saved group — a DISPLAY total.
|
|
3172
|
+
*
|
|
3173
|
+
* A group is a draft, so its amounts were never required to parse (backend
|
|
3174
|
+
* `payroll/groups.ts` validates the name and the recipients, never the money).
|
|
3175
|
+
* What cannot be read is skipped rather than poisoning the card's total, and
|
|
3176
|
+
* the widest fraction any member typed sets the scale so nothing rounds away.
|
|
3177
|
+
*/
|
|
3178
|
+
function sumTypedAmounts(amounts, currency) {
|
|
3179
|
+
const parsed = amounts.map((amount) => TYPED_AMOUNT.exec(amount.trim().replaceAll(",", ""))).filter((match) => match !== null);
|
|
3180
|
+
const decimals = parsed.reduce((widest, match) => Math.max(widest, (match[3] ?? "").length), 0);
|
|
3181
|
+
return fromMinorUnits(parsed.reduce((sum, match) => {
|
|
3182
|
+
const scaled = BigInt(`${match[2] ?? "0"}${(match[3] ?? "").padEnd(decimals, "0")}`);
|
|
3183
|
+
return match[1] === "-" ? sum - scaled : sum + scaled;
|
|
3184
|
+
}, 0n), {
|
|
3185
|
+
currency,
|
|
3186
|
+
decimals
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
/** The inverse, so a summed total can be formatted by the shipped `formatMoney`. */
|
|
3190
|
+
function fromMinorUnits(raw, asset) {
|
|
3191
|
+
const negative = raw < 0n;
|
|
3192
|
+
const digits = (negative ? -raw : raw).toString().padStart(asset.decimals + 1, "0");
|
|
3193
|
+
const whole = digits.slice(0, digits.length - asset.decimals);
|
|
3194
|
+
const fraction = asset.decimals === 0 ? "" : `.${digits.slice(digits.length - asset.decimals)}`;
|
|
3195
|
+
return {
|
|
3196
|
+
currency: asset.currency,
|
|
3197
|
+
value: `${negative ? "-" : ""}${whole}${fraction}`,
|
|
3198
|
+
decimals: asset.decimals
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
/** Convert exact raw units to one asset quantity without a fiat denomination. */
|
|
3202
|
+
function assetFromMinorUnits(raw, asset) {
|
|
3203
|
+
const money = fromMinorUnits(raw, {
|
|
3204
|
+
currency: toCurrencyCode("USD"),
|
|
3205
|
+
decimals: asset.decimals
|
|
3206
|
+
});
|
|
3207
|
+
return {
|
|
3208
|
+
asset: asset.asset,
|
|
3209
|
+
value: money.value
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3212
|
+
//#endregion
|
|
3213
|
+
//#region src/payroll/terms-prefill.ts
|
|
3214
|
+
/**
|
|
3215
|
+
* Is this period exactly one UTC calendar month, CLOSED at both ends?
|
|
3216
|
+
*
|
|
3217
|
+
* Closed because that is the convention `periodStart` / `periodEnd` already
|
|
3218
|
+
* carry: an instant run stores `periodStart === periodEnd`, one instant rather
|
|
3219
|
+
* than a half-open window of zero length. One convention, the shipped one — the
|
|
3220
|
+
* first run that actually carries a period will assert it here.
|
|
3221
|
+
*
|
|
3222
|
+
* A `month` rate states what one month costs and nothing else, so anything that
|
|
3223
|
+
* is not exactly a month leaves the rate underivable and seeds nothing. Half a
|
|
3224
|
+
* month is not half the rate — that would be proration, a policy no layer of
|
|
3225
|
+
* this item owns.
|
|
3226
|
+
*/
|
|
3227
|
+
function spansOneMonth(period) {
|
|
3228
|
+
const at = new Date(period.start);
|
|
3229
|
+
const first = Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), 1);
|
|
3230
|
+
const next = Date.UTC(at.getUTCFullYear(), at.getUTCMonth() + 1, 1);
|
|
3231
|
+
return period.start === first && period.end === next - 1;
|
|
3232
|
+
}
|
|
3233
|
+
/** A stored `rate` this module can count. Nothing decodes the row before us. */
|
|
3234
|
+
const MINOR_UNITS = /^\d+$/u;
|
|
3235
|
+
/**
|
|
3236
|
+
* The typed-amount text a rate becomes: a plain decimal, NOT a display string.
|
|
3237
|
+
* `null` when the stored rate is not minor units at all.
|
|
3238
|
+
*
|
|
3239
|
+
* The text is what the employer sees in the amount input and what the run
|
|
3240
|
+
* submits, so it must survive a round trip back through `parseMoney`.
|
|
3241
|
+
* `formatMoney` appends a currency code, which never would.
|
|
3242
|
+
*
|
|
3243
|
+
* The shape check is not belt-and-braces. `engagementTerms.rate` is a bare
|
|
3244
|
+
* `v.string()`, the SDK maps the row rather than decoding it, and this function
|
|
3245
|
+
* runs inside a render — so an unhandled `BigInt("")` would take the whole
|
|
3246
|
+
* composer down instead of one seed. `sumTypedAmounts` in this same folder
|
|
3247
|
+
* skips what it cannot read for the same reason.
|
|
3248
|
+
*/
|
|
3249
|
+
function typedAmount(terms) {
|
|
3250
|
+
if (!MINOR_UNITS.test(terms.rate)) return null;
|
|
3251
|
+
const value = fromMinorUnits(BigInt(terms.rate), terms).value;
|
|
3252
|
+
return value.includes(".") ? value.replace(/\.?0+$/u, "") : value;
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* The seed text per party, for the recipients whose terms row states a rate
|
|
3256
|
+
* this run can pay.
|
|
3257
|
+
*
|
|
3258
|
+
* Three facts stop a seed, and each one stops it SILENTLY: a recipient with no
|
|
3259
|
+
* seed gets the empty field they had before this read existed, never an error
|
|
3260
|
+
* on a value they did not type.
|
|
3261
|
+
*
|
|
3262
|
+
* the unit `paycheck` always states a rate. `month` states one only over
|
|
3263
|
+
* a month (ruling Q3). `hour`, `week` and `year` state a rate
|
|
3264
|
+
* for a period the run does not carry.
|
|
3265
|
+
* the currency `.Amounts` is denominated in the TREASURY asset's currency and
|
|
3266
|
+
* the one-option dropdown cannot be changed. A rate in another
|
|
3267
|
+
* currency is not that money, and converting it would be Capxul
|
|
3268
|
+
* computing pay.
|
|
3269
|
+
* the text a rate that is not minor units at all, or that does not parse
|
|
3270
|
+
* back to positive money — a zero rate, or one counted in finer
|
|
3271
|
+
* units than the asset — would seed a field that is wrong the
|
|
3272
|
+
* instant it appears.
|
|
3273
|
+
*/
|
|
3274
|
+
function termsSeeds(terms, asset, period) {
|
|
3275
|
+
const seeds = /* @__PURE__ */ new Map();
|
|
3276
|
+
if (asset === null) return seeds;
|
|
3277
|
+
for (const row of terms) {
|
|
3278
|
+
if (!(row.unit === "paycheck" || row.unit === "month" && spansOneMonth(period)) || row.currency !== asset.currency) continue;
|
|
3279
|
+
const text = typedAmount(row);
|
|
3280
|
+
if (text === null || isMoneyParseError(parseMoney(text, asset))) continue;
|
|
3281
|
+
seeds.set(row.partyId, text);
|
|
3282
|
+
}
|
|
3283
|
+
return seeds;
|
|
3284
|
+
}
|
|
3285
|
+
//#endregion
|
|
3286
|
+
//#region src/payroll/run-composer.ts
|
|
3287
|
+
/**
|
|
3288
|
+
* The period this composer composes: an INSTANT run (ruling Q3). `authorizeRun`
|
|
3289
|
+
* stamps `start === end === runAt` at submit, and the payslip's period renders
|
|
3290
|
+
* that date.
|
|
3291
|
+
*
|
|
3292
|
+
* WHICH instant it is cannot change a prefill answer, because no single instant
|
|
3293
|
+
* spans a calendar month. So the seed asks its question with the SHAPE the run
|
|
3294
|
+
* will submit, rather than with a live clock that would rebuild the seed map on
|
|
3295
|
+
* every render. A later period-bearing run passes its own window and reaches
|
|
3296
|
+
* the `month` branch without a second rule.
|
|
3297
|
+
*/
|
|
3298
|
+
const INSTANT_RUN_PERIOD = {
|
|
3299
|
+
start: 0,
|
|
3300
|
+
end: 0
|
|
3301
|
+
};
|
|
3302
|
+
/**
|
|
3303
|
+
* The text one `.Amounts` row shows: the employer's own draft, else the terms
|
|
3304
|
+
* seed. Read in BOTH places a value is needed — the rendered entries and the
|
|
3305
|
+
* parsed totals — so the summary can never state a number the fields do not.
|
|
3306
|
+
*/
|
|
3307
|
+
function amountText(amounts, seeds, recipientId) {
|
|
3308
|
+
return amounts[recipientId]?.value ?? seeds.get(recipientId) ?? "";
|
|
3309
|
+
}
|
|
3310
|
+
function freshRunItems(input) {
|
|
3311
|
+
const items = [];
|
|
3312
|
+
for (const [index, recipientId] of input.selected.entries()) {
|
|
3313
|
+
const to = input.payableRefs[index];
|
|
3314
|
+
const amount = input.amounts[index];
|
|
3315
|
+
const terms = input.compensationTerms.get(recipientId);
|
|
3316
|
+
if (to === void 0 || amount === void 0 || terms === void 0) return Errors.wrongState({
|
|
3317
|
+
method: CAPXUL_OPERATIONS.payroll.runs.authorize,
|
|
3318
|
+
currentState: [
|
|
3319
|
+
...to === void 0 ? ["recipient-unpayable"] : [],
|
|
3320
|
+
...amount === void 0 ? ["amount-missing"] : [],
|
|
3321
|
+
...terms === void 0 ? ["terms-missing"] : []
|
|
3322
|
+
].join(","),
|
|
3323
|
+
validStates: ["payable-priced-recipient-under-terms"]
|
|
3324
|
+
});
|
|
3325
|
+
const rawAmount = toRaw(amount, requireAsset(amount.asset)).toString();
|
|
3326
|
+
items.push({
|
|
3327
|
+
to: {
|
|
3328
|
+
kind: "party",
|
|
3329
|
+
partyId: toPartyId(recipientId)
|
|
3330
|
+
},
|
|
3331
|
+
partyId: recipientId,
|
|
3332
|
+
amount,
|
|
3333
|
+
gross: rawAmount,
|
|
3334
|
+
net: rawAmount,
|
|
3335
|
+
adjustments: []
|
|
3336
|
+
});
|
|
3337
|
+
}
|
|
3338
|
+
return items;
|
|
3339
|
+
}
|
|
3340
|
+
/** The run a fresh composer authorizes: an INSTANT run stamped at submit. */
|
|
3341
|
+
function freshRunDraft(input) {
|
|
3342
|
+
const items = freshRunItems(input);
|
|
3343
|
+
if (!Array.isArray(items)) return items;
|
|
3344
|
+
const runAt = Date.now();
|
|
3345
|
+
const metadata = requireAsset(input.assetMoney.asset);
|
|
3346
|
+
return {
|
|
3347
|
+
version: 1,
|
|
3348
|
+
scope: input.scope,
|
|
3349
|
+
asset: {
|
|
3350
|
+
id: input.assetId,
|
|
3351
|
+
currency: metadata.symbol,
|
|
3352
|
+
decimals: metadata.decimals
|
|
3353
|
+
},
|
|
3354
|
+
input: {
|
|
3355
|
+
permissionId: input.permissionId,
|
|
3356
|
+
asset: input.assetId,
|
|
3357
|
+
period: {
|
|
3358
|
+
start: runAt,
|
|
3359
|
+
end: runAt
|
|
3360
|
+
},
|
|
3361
|
+
items
|
|
3362
|
+
}
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
function hasRequiredCompensation(frozen, writerNamesSelectedAsset, selected, terms) {
|
|
3366
|
+
return frozen || !writerNamesSelectedAsset || selected.every((recipientId) => terms.has(recipientId));
|
|
3367
|
+
}
|
|
3368
|
+
/**
|
|
3369
|
+
* WHICH run the composer is composing, by MEANING rather than by object shape.
|
|
3370
|
+
*
|
|
3371
|
+
* `recipientIds` order is meaning — it is the order `.Amounts` rows appear in.
|
|
3372
|
+
* The `amounts` order is not, so the key reads the seed `initialAmounts` will
|
|
3373
|
+
* actually build rather than the array it was handed: one collapse rule, in one
|
|
3374
|
+
* place, so the two can never disagree about a repeated recipient. A parent
|
|
3375
|
+
* that rebuilds an equivalent `prefill` therefore keeps its key, and the
|
|
3376
|
+
* employer's in-progress edits with it. An absent `prefill` and one that seeds
|
|
3377
|
+
* nothing open the same empty composer, so they share a key on purpose.
|
|
3378
|
+
*/
|
|
3379
|
+
function composerIdentity(orgId, prefill, scope, frozen) {
|
|
3380
|
+
if (frozen) prefill = void 0;
|
|
3381
|
+
const seeds = Object.entries(initialAmounts(prefill)).map(([recipientId, draft]) => `${recipientId}=${draft.value}`).sort();
|
|
3382
|
+
return [
|
|
3383
|
+
orgId ?? "",
|
|
3384
|
+
...prefill?.recipientIds ?? [],
|
|
3385
|
+
"|",
|
|
3386
|
+
...seeds,
|
|
3387
|
+
scope ?? "pending"
|
|
3388
|
+
].join("\0");
|
|
3389
|
+
}
|
|
3390
|
+
/** Seeded from `prefill` on first render; the employer owns it after that. */
|
|
3391
|
+
function initialAmounts(prefill) {
|
|
3392
|
+
const seeded = {};
|
|
3393
|
+
for (const entry of prefill?.amounts ?? []) seeded[entry.recipientId] = {
|
|
3394
|
+
value: entry.amount,
|
|
3395
|
+
prefilled: true
|
|
3396
|
+
};
|
|
3397
|
+
return seeded;
|
|
3398
|
+
}
|
|
3399
|
+
function remainingBalanceDisplay(available, total, asset) {
|
|
3400
|
+
if (available === null || total === null || asset === null) return null;
|
|
3401
|
+
const remaining = available - total;
|
|
3402
|
+
const magnitude = remaining < 0n ? -remaining : remaining;
|
|
3403
|
+
const formatted = formatAssetAmount(assetFromMinorUnits(magnitude, {
|
|
3404
|
+
asset: asset.asset,
|
|
3405
|
+
decimals: requireAsset(asset.asset).decimals
|
|
3406
|
+
}));
|
|
3407
|
+
return remaining < 0n ? `-${formatted}` : formatted;
|
|
3408
|
+
}
|
|
3409
|
+
/** Persisted replay keeps its authorized Budget and asset; a prior debit cannot block its own recovery. */
|
|
3410
|
+
function runAuthorityFacts(draft, budgets, asset, money, funds) {
|
|
3411
|
+
return {
|
|
3412
|
+
budgetCount: draft !== null && !budgets.some((budget) => budget.id === draft.input.permissionId) ? 0 : budgets.length,
|
|
3413
|
+
hasAsset: asset !== null && (draft === null || draft.asset.id === asset.id && draft.input.asset === money?.asset),
|
|
3414
|
+
funds: draft?.input.requestKey === void 0 ? funds : "available"
|
|
3415
|
+
};
|
|
3416
|
+
}
|
|
3417
|
+
/**
|
|
3418
|
+
* The facts, in the ONE order the contract fixes. Exactly one code comes
|
|
3419
|
+
* out, and it is always the first thing that is false — so two problems never
|
|
3420
|
+
* race to name the button.
|
|
3421
|
+
*/
|
|
3422
|
+
function firstFalseFact(facts) {
|
|
3423
|
+
if (!facts.signerReady) return "signer-not-ready";
|
|
3424
|
+
if (facts.budgetCount === 0) return "no-source";
|
|
3425
|
+
if (facts.budgetCount > 1) return "ambiguous-source";
|
|
3426
|
+
if (!facts.hasAsset) return "no-asset";
|
|
3427
|
+
if (facts.recipientCount === 0) return "no-recipients";
|
|
3428
|
+
if (!facts.amountsParse) return "amount-invalid";
|
|
3429
|
+
if (facts.funds === "insufficient") return "insufficient-funds";
|
|
3430
|
+
return facts.writeNamesAsset ? null : "asset-unnamed";
|
|
3431
|
+
}
|
|
3432
|
+
//#endregion
|
|
3433
|
+
//#region src/payroll/use-run-amounts.ts
|
|
3434
|
+
/**
|
|
3435
|
+
* The run's money: one `.Amounts` entry per selected recipient, in SELECTION
|
|
3436
|
+
* order, the parsed total, and whether the treasury position covers it.
|
|
3437
|
+
*/
|
|
3438
|
+
function useRunAmounts(input) {
|
|
3439
|
+
const { amounts, assetMoney, change, names, selected, terms } = input;
|
|
3440
|
+
const seeds = useMemo(() => {
|
|
3441
|
+
if (assetMoney === null) return /* @__PURE__ */ new Map();
|
|
3442
|
+
const metadata = requireAsset(assetMoney.asset);
|
|
3443
|
+
return termsSeeds(terms ?? [], metadata.peg === null ? null : {
|
|
3444
|
+
currency: metadata.peg,
|
|
3445
|
+
decimals: metadata.decimals
|
|
3446
|
+
}, INSTANT_RUN_PERIOD);
|
|
3447
|
+
}, [assetMoney, terms]);
|
|
3448
|
+
const entries = useMemo(() => {
|
|
3449
|
+
if (assetMoney === null) return [];
|
|
3450
|
+
return selected.map((recipientId) => {
|
|
3451
|
+
const value = amountText(amounts, seeds, recipientId);
|
|
3452
|
+
const parsed = value.trim() === "" ? null : parseAssetAmount(value, requireAsset(assetMoney.asset));
|
|
3453
|
+
return {
|
|
3454
|
+
recipientId,
|
|
3455
|
+
name: names.get(recipientId)?.name ?? recipientId,
|
|
3456
|
+
value,
|
|
3457
|
+
change: (text) => change(recipientId, text),
|
|
3458
|
+
error: parsed !== null && isMoneyParseError(parsed) ? parsed.reason : null,
|
|
3459
|
+
prefilled: amounts[recipientId]?.prefilled ?? seeds.has(recipientId),
|
|
3460
|
+
currency: requireAsset(assetMoney.asset).symbol
|
|
3461
|
+
};
|
|
3462
|
+
});
|
|
3463
|
+
}, [
|
|
3464
|
+
amounts,
|
|
3465
|
+
assetMoney,
|
|
3466
|
+
change,
|
|
3467
|
+
names,
|
|
3468
|
+
seeds,
|
|
3469
|
+
selected
|
|
3470
|
+
]);
|
|
3471
|
+
const parsedAmounts = useMemo(() => {
|
|
3472
|
+
if (assetMoney === null || selected.length === 0) return null;
|
|
3473
|
+
const money = [];
|
|
3474
|
+
for (const recipientId of selected) {
|
|
3475
|
+
const parsed = parseAssetAmount(amountText(amounts, seeds, recipientId), requireAsset(assetMoney.asset));
|
|
3476
|
+
if (isMoneyParseError(parsed)) return null;
|
|
3477
|
+
money.push(parsed);
|
|
3478
|
+
}
|
|
3479
|
+
return money;
|
|
3480
|
+
}, [
|
|
3481
|
+
amounts,
|
|
3482
|
+
assetMoney,
|
|
3483
|
+
seeds,
|
|
3484
|
+
selected
|
|
3485
|
+
]);
|
|
3486
|
+
const total = useMemo(() => parsedAmounts === null || assetMoney === null ? null : {
|
|
3487
|
+
asset: assetMoney.asset,
|
|
3488
|
+
value: assetFromMinorUnits(parsedAmounts.reduce((sum, amount) => sum + toRaw(amount, requireAsset(amount.asset)), 0n), {
|
|
3489
|
+
asset: assetMoney.asset,
|
|
3490
|
+
decimals: requireAsset(assetMoney.asset).decimals
|
|
3491
|
+
}).value
|
|
3492
|
+
}, [assetMoney, parsedAmounts]);
|
|
3493
|
+
const availableRaw = assetMoney === null ? null : toRaw(assetMoney, requireAsset(assetMoney.asset));
|
|
3494
|
+
const totalRaw = total === null ? null : toRaw(total, requireAsset(total.asset));
|
|
3495
|
+
return {
|
|
3496
|
+
entries,
|
|
3497
|
+
parsedAmounts,
|
|
3498
|
+
total,
|
|
3499
|
+
availableRaw,
|
|
3500
|
+
totalRaw,
|
|
3501
|
+
funds: availableRaw === null || totalRaw === null ? "unknown" : totalRaw > availableRaw ? "insufficient" : "available"
|
|
3502
|
+
};
|
|
3503
|
+
}
|
|
3504
|
+
//#endregion
|
|
3505
|
+
//#region src/payroll/use-run-asset.ts
|
|
3506
|
+
/**
|
|
3507
|
+
* `.Asset` — names a token from the treasury Account's `balances`, never its
|
|
3508
|
+
* `available`, which is the Account's fiat VALUATION of its pegged positions
|
|
3509
|
+
* (ADR-0028 R5/R6). The option is keyed on the ASSET id: several positions
|
|
3510
|
+
* share one account id, and a symbol is display only (ADR-0028 R3). The run
|
|
3511
|
+
* writer receives the selected asset explicitly.
|
|
3512
|
+
*/
|
|
3513
|
+
function useRunAsset(account, canSelect) {
|
|
3514
|
+
const assetSelections = useMemo(() => assetSelectionsFromAccount(account), [account]);
|
|
3515
|
+
const assetOptions = useMemo(() => assetSelections.map((selection) => selection.option), [assetSelections]);
|
|
3516
|
+
const [selectedAssetId, setSelectedAssetId] = useState(null);
|
|
3517
|
+
useEffect(() => {
|
|
3518
|
+
if (!assetOptions.some((option) => option.id === selectedAssetId)) setSelectedAssetId(assetOptions[0]?.id ?? null);
|
|
3519
|
+
}, [assetOptions, selectedAssetId]);
|
|
3520
|
+
const selectedAsset = assetOptions.find((option) => option.id === selectedAssetId) ?? null;
|
|
3521
|
+
return {
|
|
3522
|
+
assetOptions,
|
|
3523
|
+
selectedAsset,
|
|
3524
|
+
selectAsset: useCallback((id) => {
|
|
3525
|
+
if (canSelect()) setSelectedAssetId((current) => assetOptions.some((option) => option.id === id) ? id : current);
|
|
3526
|
+
}, [assetOptions, canSelect]),
|
|
3527
|
+
assetMoney: assetSelections.find((selection) => selection.option.id === selectedAsset?.id)?.money ?? null
|
|
3528
|
+
};
|
|
3529
|
+
}
|
|
3530
|
+
//#endregion
|
|
3531
|
+
//#region src/payroll/use-run-recipients.ts
|
|
3532
|
+
/**
|
|
3533
|
+
* Who the run pays: each selected recipient's name, the payable `Ref` the
|
|
3534
|
+
* org's own address book handed back, and the paycheck terms it runs under.
|
|
3535
|
+
*/
|
|
3536
|
+
function useRunRecipients(input) {
|
|
3537
|
+
const { options, refs, selected, terms } = input;
|
|
3538
|
+
return {
|
|
3539
|
+
named: useMemo(() => new Map(options.map((option) => [option.id, option])), [options]),
|
|
3540
|
+
payableRefs: useMemo(() => {
|
|
3541
|
+
const resolved = [];
|
|
3542
|
+
for (const recipientId of selected) {
|
|
3543
|
+
const ref = refs.get(recipientId);
|
|
3544
|
+
if (ref === void 0) return null;
|
|
3545
|
+
resolved.push(ref);
|
|
3546
|
+
}
|
|
3547
|
+
return resolved;
|
|
3548
|
+
}, [refs, selected]),
|
|
3549
|
+
compensationTerms: useMemo(() => new Map((terms ?? []).filter((row) => row.unit === "paycheck" && /^[1-9][0-9]*$/u.test(row.rate)).map((row) => [row.partyId, row])), [terms])
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
//#endregion
|
|
3553
|
+
//#region src/payroll/use-payroll-run.ts
|
|
3554
|
+
function usePayrollRun(input) {
|
|
3555
|
+
const { orgId, prefill, onRun, onFailed } = input;
|
|
3556
|
+
const client = useCapxulClientOrNull();
|
|
3557
|
+
const submitGate = useRef(false);
|
|
3558
|
+
const signerStatus = useSignerStatus(client);
|
|
3559
|
+
const roster = useOrgRoster(orgId);
|
|
3560
|
+
const groups = usePayrollGroups(orgId);
|
|
3561
|
+
const treasury = useOrgTreasury(orgId);
|
|
3562
|
+
const me = useOrgMe(orgId);
|
|
3563
|
+
const terms = useOrgTerms(orgId);
|
|
3564
|
+
const [selected, setSelected] = useState(() => prefill?.recipientIds ?? []);
|
|
3565
|
+
const [amounts, setAmounts] = useState(() => initialAmounts(prefill));
|
|
3566
|
+
const { draftScope, currentScope, draft, draftError, restorationPending, activateDraft, restoreDraft, setDraft, recovery, draftBlocked } = useRunDraft(client, orgId, useCallback((restored) => {
|
|
3567
|
+
setSelected(restored.input.items.map((item) => item.partyId));
|
|
3568
|
+
if (requireAsset(restored.input.asset).assetId === restored.input.asset) setAmounts(Object.fromEntries(restored.input.items.map((item) => [item.partyId, {
|
|
3569
|
+
value: item.amount.value,
|
|
3570
|
+
prefilled: false
|
|
3571
|
+
}])));
|
|
3572
|
+
}, []));
|
|
3573
|
+
const frozen = draft !== null;
|
|
3574
|
+
const composerKey = composerIdentity(orgId, prefill, draftScope, frozen);
|
|
3575
|
+
const [seededKey, setSeededKey] = useState(composerKey);
|
|
3576
|
+
if (seededKey !== composerKey) {
|
|
3577
|
+
setSeededKey(composerKey);
|
|
3578
|
+
if (!frozen) {
|
|
3579
|
+
setSelected(prefill?.recipientIds ?? []);
|
|
3580
|
+
setAmounts(initialAmounts(prefill));
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
const toggle = useCallback((id) => {
|
|
3584
|
+
if (submitGate.current || frozen || restorationPending || draftError !== null) return;
|
|
3585
|
+
setSelected((current) => current.includes(id) ? current.filter((held) => held !== id) : [...current, id]);
|
|
3586
|
+
}, [
|
|
3587
|
+
frozen,
|
|
3588
|
+
restorationPending,
|
|
3589
|
+
draftError
|
|
3590
|
+
]);
|
|
3591
|
+
const remove = useCallback((id) => {
|
|
3592
|
+
if (!submitGate.current && !frozen && !restorationPending && draftError === null) setSelected((current) => current.filter((held) => held !== id));
|
|
3593
|
+
}, [
|
|
3594
|
+
frozen,
|
|
3595
|
+
restorationPending,
|
|
3596
|
+
draftError
|
|
3597
|
+
]);
|
|
3598
|
+
const change = useCallback((id, text) => {
|
|
3599
|
+
if (!submitGate.current && !frozen && !restorationPending && draftError === null) setAmounts((current) => ({
|
|
3600
|
+
...current,
|
|
3601
|
+
[id]: {
|
|
3602
|
+
value: text,
|
|
3603
|
+
prefilled: false
|
|
3604
|
+
}
|
|
3605
|
+
}));
|
|
3606
|
+
}, [
|
|
3607
|
+
frozen,
|
|
3608
|
+
restorationPending,
|
|
3609
|
+
draftError
|
|
3610
|
+
]);
|
|
3611
|
+
const canSelectAsset = useCallback(() => !submitGate.current && !frozen, [frozen]);
|
|
3612
|
+
const { assetOptions, selectedAsset, selectAsset, assetMoney } = useRunAsset(treasury.data ?? null, canSelectAsset);
|
|
3613
|
+
const { named, payableRefs, compensationTerms } = useRunRecipients({
|
|
3614
|
+
options: roster.options,
|
|
3615
|
+
refs: roster.refs,
|
|
3616
|
+
selected,
|
|
3617
|
+
terms: terms.data?.items
|
|
3618
|
+
});
|
|
3619
|
+
const { entries, parsedAmounts, total, availableRaw, totalRaw, funds } = useRunAmounts({
|
|
3620
|
+
assetMoney,
|
|
3621
|
+
terms: terms.data?.items,
|
|
3622
|
+
amounts,
|
|
3623
|
+
selected,
|
|
3624
|
+
names: named,
|
|
3625
|
+
change
|
|
3626
|
+
});
|
|
3627
|
+
const budgets = me.data?.budgets ?? [];
|
|
3628
|
+
const writerNamesSelectedAsset = writeNamesAsset();
|
|
3629
|
+
const blockedReason = firstFalseFact({
|
|
3630
|
+
signerReady: signerStatus === "ready",
|
|
3631
|
+
...runAuthorityFacts(draft, budgets, selectedAsset, assetMoney, funds),
|
|
3632
|
+
recipientCount: payableRefs === null ? 0 : selected.length,
|
|
3633
|
+
amountsParse: total !== null && hasRequiredCompensation(frozen, writerNamesSelectedAsset, selected, compensationTerms),
|
|
3634
|
+
writeNamesAsset: writerNamesSelectedAsset
|
|
3635
|
+
});
|
|
3636
|
+
const authorize = useAuthorizeRun({
|
|
3637
|
+
client,
|
|
3638
|
+
orgId,
|
|
3639
|
+
currentScope,
|
|
3640
|
+
setDraft,
|
|
3641
|
+
restoreDraft
|
|
3642
|
+
});
|
|
3643
|
+
const totalDisplay = total === null ? null : formatAssetAmount(total);
|
|
3644
|
+
const authorizeMutate = authorize.mutate;
|
|
3645
|
+
const submit = useCallback((options) => {
|
|
3646
|
+
const attempt = toInvocationOptions(options);
|
|
3647
|
+
const permissionId = budgets[0]?.id;
|
|
3648
|
+
if (blockedReason !== null || submitGate.current || draftBlocked || draftScope === null || assetMoney === null || selectedAsset === null) return;
|
|
3649
|
+
if (permissionId === void 0 || totalDisplay === null || parsedAmounts === null) return;
|
|
3650
|
+
if (payableRefs === null) return;
|
|
3651
|
+
const recipientCount = selected.length;
|
|
3652
|
+
const candidate = frozen ? draft : freshRunDraft({
|
|
3653
|
+
scope: draftScope,
|
|
3654
|
+
assetId: selectedAsset.id,
|
|
3655
|
+
assetMoney,
|
|
3656
|
+
permissionId,
|
|
3657
|
+
selected,
|
|
3658
|
+
payableRefs,
|
|
3659
|
+
amounts: parsedAmounts,
|
|
3660
|
+
compensationTerms
|
|
3661
|
+
});
|
|
3662
|
+
if (isCapxulError(candidate)) {
|
|
3663
|
+
onFailed(candidate);
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
submitGate.current = true;
|
|
3667
|
+
authorizeMutate({
|
|
3668
|
+
candidate,
|
|
3669
|
+
...attempt === void 0 ? {} : { options: attempt }
|
|
3670
|
+
}, {
|
|
3671
|
+
onSuccess: (run) => {
|
|
3672
|
+
if (candidate.scope === currentScope.current) onRun({
|
|
3673
|
+
id: run.id,
|
|
3674
|
+
totalDisplay,
|
|
3675
|
+
recipientCount
|
|
3676
|
+
});
|
|
3677
|
+
},
|
|
3678
|
+
onError: (error) => {
|
|
3679
|
+
if (candidate.scope === currentScope.current) onFailed(error);
|
|
3680
|
+
},
|
|
3681
|
+
onSettled: () => {
|
|
3682
|
+
submitGate.current = false;
|
|
3683
|
+
}
|
|
3684
|
+
});
|
|
3685
|
+
}, [
|
|
3686
|
+
authorizeMutate,
|
|
3687
|
+
draft,
|
|
3688
|
+
frozen,
|
|
3689
|
+
draftScope,
|
|
3690
|
+
restorationPending,
|
|
3691
|
+
draftError,
|
|
3692
|
+
draftBlocked,
|
|
3693
|
+
assetMoney,
|
|
3694
|
+
selectedAsset,
|
|
3695
|
+
blockedReason,
|
|
3696
|
+
budgets,
|
|
3697
|
+
compensationTerms,
|
|
3698
|
+
onFailed,
|
|
3699
|
+
onRun,
|
|
3700
|
+
parsedAmounts,
|
|
3701
|
+
payableRefs,
|
|
3702
|
+
selected,
|
|
3703
|
+
totalDisplay
|
|
3704
|
+
]);
|
|
3705
|
+
return {
|
|
3706
|
+
activateDraft,
|
|
3707
|
+
recipients: {
|
|
3708
|
+
options: roster.options,
|
|
3709
|
+
groups: (groups.data?.items ?? []).map((group) => ({
|
|
3710
|
+
id: group.id,
|
|
3711
|
+
label: group.name,
|
|
3712
|
+
badgeLabel: String(group.members.length),
|
|
3713
|
+
recipientIds: group.members.map((member) => member.partyId)
|
|
3714
|
+
})),
|
|
3715
|
+
selected,
|
|
3716
|
+
toggle,
|
|
3717
|
+
remove,
|
|
3718
|
+
isLoading: roster.isLoading,
|
|
3719
|
+
error: roster.error ?? groups.error
|
|
3720
|
+
},
|
|
3721
|
+
amounts: { entries },
|
|
3722
|
+
asset: {
|
|
3723
|
+
options: assetOptions,
|
|
3724
|
+
selected: selectedAsset,
|
|
3725
|
+
select: selectAsset,
|
|
3726
|
+
error: treasury.error === null ? null : "read-failed",
|
|
3727
|
+
isPending: orgId !== void 0 && treasury.isPending
|
|
3728
|
+
},
|
|
3729
|
+
summary: {
|
|
3730
|
+
recipientCount: selected.length,
|
|
3731
|
+
totalDisplay,
|
|
3732
|
+
balanceAfterDisplay: remainingBalanceDisplay(availableRaw, totalRaw, assetMoney),
|
|
3733
|
+
funds
|
|
3734
|
+
},
|
|
3735
|
+
actions: {
|
|
3736
|
+
submit,
|
|
3737
|
+
isSubmitting: authorize.isPending,
|
|
3738
|
+
blocked: blockedReason !== null || draftBlocked,
|
|
3739
|
+
blockedReason,
|
|
3740
|
+
recovery,
|
|
3741
|
+
recoveryError: draftError
|
|
3742
|
+
}
|
|
3743
|
+
};
|
|
3744
|
+
}
|
|
3745
|
+
//#endregion
|
|
3746
|
+
//#region src/payroll/payroll-run.tsx
|
|
3747
|
+
const PayrollRunContext = createContext(null);
|
|
3748
|
+
function usePayrollRunContext() {
|
|
3749
|
+
const engine = useContext(PayrollRunContext);
|
|
3750
|
+
if (engine === null) throw new Error("CapxulPayrollRun parts must be used inside <CapxulPayrollRun>");
|
|
3751
|
+
return engine;
|
|
3752
|
+
}
|
|
3753
|
+
function Root$1({ orgId, prefill, onRun, onFailed, children }) {
|
|
3754
|
+
const engine = usePayrollRun({
|
|
3755
|
+
orgId,
|
|
3756
|
+
prefill,
|
|
3757
|
+
onRun,
|
|
3758
|
+
onFailed
|
|
3759
|
+
});
|
|
3760
|
+
return /* @__PURE__ */ jsx(PayrollRunContext.Provider, {
|
|
3761
|
+
value: engine,
|
|
3762
|
+
children
|
|
3763
|
+
});
|
|
3764
|
+
}
|
|
3765
|
+
function Recipients({ children }) {
|
|
3766
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().recipients) });
|
|
3767
|
+
}
|
|
3768
|
+
function Amounts({ children }) {
|
|
3769
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().amounts) });
|
|
3770
|
+
}
|
|
3771
|
+
function Asset({ children }) {
|
|
3772
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().asset) });
|
|
3773
|
+
}
|
|
3774
|
+
function Summary$1({ children }) {
|
|
3775
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().summary) });
|
|
3776
|
+
}
|
|
3777
|
+
function Actions({ children }) {
|
|
3778
|
+
const engine = usePayrollRunContext();
|
|
3779
|
+
useEffect(engine.activateDraft, [engine.activateDraft]);
|
|
3780
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(engine.actions) });
|
|
3781
|
+
}
|
|
3782
|
+
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
3783
|
+
const CapxulPayrollRun = Object.assign(Root$1, {
|
|
3784
|
+
Recipients,
|
|
3785
|
+
Amounts,
|
|
3786
|
+
Asset,
|
|
3787
|
+
Summary: Summary$1,
|
|
3788
|
+
Actions
|
|
3789
|
+
});
|
|
3790
|
+
//#endregion
|
|
3791
|
+
//#region src/payroll/use-payroll.ts
|
|
3792
|
+
/** Money reads the same way everywhere on this screen: amount then code. */
|
|
3793
|
+
const GRAMMAR = { grammar: "code" };
|
|
3794
|
+
/**
|
|
3795
|
+
* One captured snapshot, or nothing — never a fabricated empty options object,
|
|
3796
|
+
* and never a DOM event a screen wired straight to `onClick`.
|
|
3797
|
+
*/
|
|
3798
|
+
function carry(options) {
|
|
3799
|
+
const attempt = toInvocationOptions(options);
|
|
3800
|
+
return attempt === void 0 ? {} : { options: attempt };
|
|
3801
|
+
}
|
|
3802
|
+
function toPayrollGroupRow(group) {
|
|
3803
|
+
const currency = new Set(group.members.map((member) => member.currency)).size === 1 ? group.members[0]?.currency : void 0;
|
|
3804
|
+
return {
|
|
3805
|
+
id: group.id,
|
|
3806
|
+
name: group.name,
|
|
3807
|
+
tone: group.tone,
|
|
3808
|
+
memberCount: group.members.length,
|
|
3809
|
+
totalDisplay: currency === void 0 ? "" : formatMoney(sumTypedAmounts(group.members.map((member) => member.amount), currency), GRAMMAR),
|
|
3810
|
+
members: group.members.map((member) => ({
|
|
3811
|
+
recipientId: member.partyId,
|
|
3812
|
+
amount: member.amount,
|
|
3813
|
+
currency: member.currency
|
|
3814
|
+
}))
|
|
3815
|
+
};
|
|
3816
|
+
}
|
|
3817
|
+
/**
|
|
3818
|
+
* The validity `buildOrganizationPayrollGroupRecord` applied in the browser
|
|
3819
|
+
* (payroll groups hook `:57-82`) — a trimmed name, at least one deduped
|
|
3820
|
+
* recipient — checked here so an invalid group never costs a round trip. The
|
|
3821
|
+
* backend re-checks it; this is honest UX, not the authority.
|
|
3822
|
+
*
|
|
3823
|
+
* ADR-0023 R1: the refusal is a CODE, never a toast sentence.
|
|
3824
|
+
*/
|
|
3825
|
+
function invalidGroup(input) {
|
|
3826
|
+
if (input.name.trim().length === 0) return Errors.invalidInput("name", "must not be empty");
|
|
3827
|
+
const deduped = new Set(input.members.map((member) => member.partyId));
|
|
3828
|
+
deduped.delete("");
|
|
3829
|
+
return deduped.size === 0 ? Errors.invalidInput("members", "must not be empty") : null;
|
|
3830
|
+
}
|
|
3831
|
+
function usePayroll(orgId) {
|
|
3832
|
+
const client = useCapxulClientOrNull();
|
|
3833
|
+
const queryClient = useQueryClient();
|
|
3834
|
+
const treasury = useOrgTreasury(orgId);
|
|
3835
|
+
const runs = usePayrollRuns(orgId);
|
|
3836
|
+
const groups = usePayrollGroups(orgId);
|
|
3837
|
+
const invalidate = useCallback(() => queryClient.invalidateQueries({ queryKey: capxulKeys.payrollGroups(orgId) }), [orgId, queryClient]);
|
|
3838
|
+
const saving = useMutation({
|
|
3839
|
+
mutationFn: async ({ input, options }) => unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.org.payroll.groups.save, orgId).payroll.groups.save(input, toInvocationControls(options))),
|
|
3840
|
+
onSettled: invalidate
|
|
3841
|
+
});
|
|
3842
|
+
const removing = useMutation({
|
|
3843
|
+
mutationFn: async ({ id, options }) => unwrapCapxulResult(await scopedClient(client, CAPXUL_OPERATIONS.org.payroll.groups.remove, orgId).payroll.groups.remove(id, toInvocationControls(options))),
|
|
3844
|
+
onSettled: invalidate
|
|
3845
|
+
});
|
|
3846
|
+
const saveAsync = saving.mutateAsync;
|
|
3847
|
+
const removeAsync = removing.mutateAsync;
|
|
3848
|
+
const save = useCallback(async (input, options) => {
|
|
3849
|
+
const refusal = invalidGroup(input);
|
|
3850
|
+
if (refusal !== null) return {
|
|
3851
|
+
ok: false,
|
|
3852
|
+
error: refusal
|
|
3853
|
+
};
|
|
3854
|
+
try {
|
|
3855
|
+
return {
|
|
3856
|
+
ok: true,
|
|
3857
|
+
value: toPayrollGroupRow(await saveAsync({
|
|
3858
|
+
input,
|
|
3859
|
+
...carry(options)
|
|
3860
|
+
}))
|
|
3861
|
+
};
|
|
3862
|
+
} catch (cause) {
|
|
3863
|
+
return {
|
|
3864
|
+
ok: false,
|
|
3865
|
+
error: toCapxulError(cause)
|
|
3866
|
+
};
|
|
3867
|
+
}
|
|
3868
|
+
}, [saveAsync]);
|
|
3869
|
+
const remove = useCallback(async (id, options) => {
|
|
3870
|
+
try {
|
|
3871
|
+
await removeAsync({
|
|
3872
|
+
id,
|
|
3873
|
+
...carry(options)
|
|
3874
|
+
});
|
|
3875
|
+
return {
|
|
3876
|
+
ok: true,
|
|
3877
|
+
value: void 0
|
|
3878
|
+
};
|
|
3879
|
+
} catch (cause) {
|
|
3880
|
+
return {
|
|
3881
|
+
ok: false,
|
|
3882
|
+
error: toCapxulError(cause)
|
|
3883
|
+
};
|
|
3884
|
+
}
|
|
3885
|
+
}, [removeAsync]);
|
|
3886
|
+
const rows = useMemo(() => (groups.data?.items ?? []).map(toPayrollGroupRow), [groups.data]);
|
|
3887
|
+
const started = orgId !== void 0;
|
|
3888
|
+
return {
|
|
3889
|
+
summary: {
|
|
3890
|
+
walletBalanceDisplay: treasury.data?.available === void 0 || treasury.data.available === null ? null : formatMoney(treasury.data.available, GRAMMAR),
|
|
3891
|
+
paidThisMonthDisplay: runs.data === void 0 ? null : runs.data.settledThisMonth.map(formatAssetAmount).join(", "),
|
|
3892
|
+
isLoading: started && (treasury.data === void 0 && treasury.error === null || runs.data === void 0 && runs.error === null),
|
|
3893
|
+
error: treasury.error ?? runs.error
|
|
3894
|
+
},
|
|
3895
|
+
groups: {
|
|
3896
|
+
groups: rows,
|
|
3897
|
+
save,
|
|
3898
|
+
remove,
|
|
3899
|
+
isSaving: saving.isPending || removing.isPending,
|
|
3900
|
+
isLoading: started && groups.data === void 0 && groups.error === null,
|
|
3901
|
+
error: groups.error
|
|
3902
|
+
}
|
|
3903
|
+
};
|
|
3904
|
+
}
|
|
3905
|
+
//#endregion
|
|
3906
|
+
//#region src/payroll/payroll.tsx
|
|
3907
|
+
const PayrollContext = createContext(null);
|
|
3908
|
+
function usePayrollContext() {
|
|
3909
|
+
const engine = useContext(PayrollContext);
|
|
3910
|
+
if (engine === null) throw new Error("CapxulPayroll parts must be used inside <CapxulPayroll>");
|
|
3911
|
+
return engine;
|
|
3912
|
+
}
|
|
3913
|
+
function Root({ orgId, children }) {
|
|
3914
|
+
const engine = usePayroll(orgId);
|
|
3915
|
+
return /* @__PURE__ */ jsx(PayrollContext.Provider, {
|
|
3916
|
+
value: engine,
|
|
3917
|
+
children
|
|
3918
|
+
});
|
|
3919
|
+
}
|
|
3920
|
+
function Summary({ children }) {
|
|
3921
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollContext().summary) });
|
|
3922
|
+
}
|
|
3923
|
+
function Groups({ children }) {
|
|
3924
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollContext().groups) });
|
|
3925
|
+
}
|
|
3926
|
+
/**
|
|
3927
|
+
* The compound shape the app's own design system already uses (`StatusTabs`).
|
|
3928
|
+
* There is NO `.Runs` part (ruling Q6): no shipped region has the right shape
|
|
3929
|
+
* for a run list, and a part without a production caller may not ship.
|
|
3930
|
+
*/
|
|
3931
|
+
const CapxulPayroll = Object.assign(Root, {
|
|
3932
|
+
Summary,
|
|
3933
|
+
Groups
|
|
3934
|
+
});
|
|
3935
|
+
//#endregion
|
|
3936
|
+
export { CapxulActivity, CapxulAuthenticationController, CapxulClientConnection, CapxulContacts, CapxulDashboardAccess, CapxulOnboardingController, CapxulOrgMember, CapxulPayroll, CapxulPayrollRun, CapxulProvider, CapxulSendMoney, acknowledgeOnboardingDestination, activeOnboardingRecovery, capxulKeys, clearOnboardingJourney, currentOnboardingJourneyId, entered, invalidateOnboardingJourneyObservation, isClaimed, isRestoring, loadOnboardingJourney, onboardingJourneyPosition, saveOnboardingJourney, startOnboardingJourney, useCapxul, useCapxulAccountBalance, useCapxulAccountFund, useCapxulActivity, useCapxulActivityDetail, useCapxulAnnotateMovement, useCapxulAuth, useCapxulClientOrNull, useCapxulCreateOrg, useCapxulDestination, useCapxulHandleAvailability, useCapxulHoldings, useCapxulIdentity, useCapxulImageUpload, useCapxulOrgMembers, useCapxulOrgRoles, useCapxulOrgTreasury, useCapxulOrganizationPay, useCapxulOrganizationPayBatch, useCapxulOrgs, useCapxulPay, useCapxulPayment, useCapxulPayments, useCapxulPermission, useCapxulPermissionAssign, useCapxulPermissionChange, useCapxulPermissionCreate, useCapxulPermissionReplace, useCapxulPermissionRevoke, useCapxulPermissions, useCapxulProfile, useCapxulSend, useCapxulTransitions, useCapxulUploadImage };
|