@global-torque/invest-core 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +11 -0
  4. package/README.md +76 -0
  5. package/SECURITY.md +9 -0
  6. package/SUPPORT.md +6 -0
  7. package/dist/node/app/config.js +173 -0
  8. package/dist/node/helpers/text.js +37 -0
  9. package/dist/node/markdown/tableWrap.js +23 -0
  10. package/package.json +113 -0
  11. package/src/accreditation/status.ts +100 -0
  12. package/src/analytics/__tests__/analyticsBody.test.ts +50 -0
  13. package/src/analytics/analyticsBody.ts +270 -0
  14. package/src/app/config.test.ts +79 -0
  15. package/src/app/config.ts +329 -0
  16. package/src/decimal/__tests__/canonicalDecimal.test.ts +58 -0
  17. package/src/decimal/canonicalDecimal.ts +154 -0
  18. package/src/evm/__tests__/walletInfo.test.ts +488 -0
  19. package/src/evm/walletInfo.ts +625 -0
  20. package/src/filer/__tests__/documentFormatter.test.ts +208 -0
  21. package/src/filer/__tests__/publicImage.test.ts +42 -0
  22. package/src/filer/documentFormatter.ts +195 -0
  23. package/src/filer/publicImage.ts +120 -0
  24. package/src/form-validation/__tests__/general.test.ts +78 -0
  25. package/src/form-validation/__tests__/investment.test.ts +100 -0
  26. package/src/form-validation/ajv.ts +109 -0
  27. package/src/form-validation/constants.ts +22 -0
  28. package/src/form-validation/general.ts +114 -0
  29. package/src/form-validation/index.ts +5 -0
  30. package/src/form-validation/investment.ts +65 -0
  31. package/src/form-validation/rules.ts +35 -0
  32. package/src/formatting/__tests__/buildInfo.test.ts +19 -0
  33. package/src/formatting/__tests__/dateTime.test.ts +24 -0
  34. package/src/formatting/__tests__/display.test.ts +30 -0
  35. package/src/formatting/buildInfo.ts +31 -0
  36. package/src/formatting/dateTime.ts +43 -0
  37. package/src/formatting/display.ts +24 -0
  38. package/src/helpers/arrays.ts +11 -0
  39. package/src/helpers/currency.ts +19 -0
  40. package/src/helpers/formatters/formatToDate.ts +47 -0
  41. package/src/helpers/formatters/formatToNumber.ts +39 -0
  42. package/src/helpers/formatters/formatToPhone.ts +13 -0
  43. package/src/helpers/general.ts +164 -0
  44. package/src/helpers/model.ts +87 -0
  45. package/src/helpers/numberFormatter.ts +4 -0
  46. package/src/helpers/text.ts +51 -0
  47. package/src/index.ts +22 -0
  48. package/src/investment/__tests__/status.test.ts +59 -0
  49. package/src/investment/rawAmount.test.ts +23 -0
  50. package/src/investment/rawAmount.ts +81 -0
  51. package/src/investment/status.ts +56 -0
  52. package/src/kyc/__tests__/kycAlert.formatter.test.ts +47 -0
  53. package/src/kyc/__tests__/kycAlert.test.ts +47 -0
  54. package/src/kyc/__tests__/thirdPartyScreen.test.ts +16 -0
  55. package/src/kyc/kycAlert.ts +47 -0
  56. package/src/kyc/status.ts +109 -0
  57. package/src/kyc/thirdPartyScreen.ts +28 -0
  58. package/src/markdown/tableWrap.ts +29 -0
  59. package/src/notifications/shareFields.ts +15 -0
  60. package/src/offer/__tests__/metrics.test.ts +67 -0
  61. package/src/offer/formatter.ts +559 -0
  62. package/src/offer/metrics.ts +83 -0
  63. package/src/onboarding/__tests__/intents.test.ts +83 -0
  64. package/src/onboarding/intents.ts +128 -0
  65. package/src/profiles/__tests__/formatting.test.ts +24 -0
  66. package/src/profiles/avatarInitial.ts +5 -0
  67. package/src/profiles/formatting.ts +38 -0
  68. package/src/repository/__tests__/formatterCache.test.ts +45 -0
  69. package/src/repository/formatterCache.ts +56 -0
  70. package/src/wallet/__tests__/auth.test.ts +102 -0
  71. package/src/wallet/__tests__/operationPresentation.test.ts +94 -0
  72. package/src/wallet/__tests__/setupError.test.ts +32 -0
  73. package/src/wallet/auth.ts +491 -0
  74. package/src/wallet/operationPresentation.ts +106 -0
  75. package/src/wallet/setupError.ts +50 -0
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { JSONSchemaType } from 'ajv/dist/types/json-schema';
3
+ import {
4
+ composeInvestmentFormSchema,
5
+ createInvestmentAjv,
6
+ getReferenceType,
7
+ prepareInvestmentFormData,
8
+ } from '../index.ts';
9
+
10
+ describe('investment form policy', () => {
11
+ it('registers investment keywords per validator and keeps neutral validators isolated', () => {
12
+ const schema = { type: 'object', properties: { country: { type: 'string', mustBeUS: true } }, required: ['country'] } as unknown as JSONSchemaType<{ country: string }>;
13
+ const first = createInvestmentAjv();
14
+ const second = createInvestmentAjv();
15
+ expect(first.compile(schema)({ country: 'CA' })).toBe(false);
16
+ expect(second.compile(schema)({ country: 'CA' })).toBe(false);
17
+ });
18
+
19
+ it('does not mutate frontend or backend schemas while applying frontend precedence', () => {
20
+ const frontend = { type: 'object', properties: { name: { type: 'string', minLength: 2 } }, required: ['name'] } as unknown as JSONSchemaType<{ name: string }>;
21
+ const backend = { type: 'object', properties: { name: { type: 'string', minLength: 1, contentMediaType: 3 } }, required: ['name'] } as unknown as JSONSchemaType<{ name: string }>;
22
+ const beforeFrontend = JSON.stringify(frontend);
23
+ const beforeBackend = JSON.stringify(backend);
24
+ const composed = composeInvestmentFormSchema(frontend, backend);
25
+ expect((composed as any).properties.name.minLength).toBe(2);
26
+ expect((composed as any).properties.name.contentMediaType).toBeUndefined();
27
+ expect(JSON.stringify(frontend)).toBe(beforeFrontend);
28
+ expect(JSON.stringify(backend)).toBe(beforeBackend);
29
+ });
30
+
31
+ it('removes only backend root and named-definition requirements', () => {
32
+ const frontend = {
33
+ type: 'object',
34
+ properties: { name: { type: 'string' } },
35
+ } as unknown as JSONSchemaType<{ name: string }>;
36
+ const backend = {
37
+ type: 'object',
38
+ required: ['legacyRoot'],
39
+ properties: {
40
+ details: {
41
+ type: 'object',
42
+ required: ['inlineField'],
43
+ properties: { inlineField: { type: 'string' } },
44
+ },
45
+ },
46
+ allOf: [{ required: ['composedField'] }],
47
+ definitions: {
48
+ LegacyDetails: {
49
+ type: 'object',
50
+ required: ['definitionField'],
51
+ properties: {
52
+ nested: {
53
+ type: 'object',
54
+ required: ['nestedField'],
55
+ properties: { nestedField: { type: 'string' } },
56
+ },
57
+ },
58
+ },
59
+ },
60
+ $defs: {
61
+ ModernDetails: {
62
+ type: 'object',
63
+ required: ['modernDefinitionField'],
64
+ anyOf: [{ required: ['composedDefinitionField'] }],
65
+ },
66
+ },
67
+ } as unknown as JSONSchemaType<{ name: string }>;
68
+ const beforeBackend = JSON.stringify(backend);
69
+
70
+ const composed = composeInvestmentFormSchema(frontend, backend) as any;
71
+
72
+ expect(composed.required).toBeUndefined();
73
+ expect(composed.definitions.LegacyDetails.required).toBeUndefined();
74
+ expect(composed.$defs.ModernDetails.required).toBeUndefined();
75
+ expect(composed.properties.details.required).toEqual(['inlineField']);
76
+ expect(composed.allOf[0].required).toEqual(['composedField']);
77
+ expect(composed.definitions.LegacyDetails.properties.nested.required).toEqual(['nestedField']);
78
+ expect(composed.$defs.ModernDetails.anyOf[0].required).toEqual(['composedDefinitionField']);
79
+ expect(JSON.stringify(backend)).toBe(beforeBackend);
80
+
81
+ const ajv = createInvestmentAjv();
82
+ const validate = ajv.compile(composed);
83
+ expect(validate({ name: 'Ada' })).toBe(false);
84
+ expect(validate.errors).toEqual(expect.arrayContaining([
85
+ expect.objectContaining({
86
+ keyword: 'required',
87
+ params: { missingProperty: 'composedField' },
88
+ }),
89
+ ]));
90
+ expect(validate({ name: 'Ada', composedField: true } as any)).toBe(true);
91
+ });
92
+
93
+ it('normalizes nested empty strings on a cloned model and preserves policy reference fallback', () => {
94
+ const model = { identity: { firstName: '', lastName: 'A' }, values: ['', 'x'] };
95
+ expect(prepareInvestmentFormData(model)).toEqual({ identity: { firstName: undefined, lastName: 'A' }, values: [undefined, 'x'] });
96
+ expect(model.identity.firstName).toBe('');
97
+ expect(getReferenceType({ $ref: '#/$defs/Entity' })).toBe('Entity');
98
+ expect(getReferenceType({ $ref: '#/$defs/Unknown' })).toBe('Individual');
99
+ });
100
+ });
@@ -0,0 +1,109 @@
1
+ import Ajv, { type Options } from 'ajv';
2
+ import ajvErrors from 'ajv-errors';
3
+ import addFormats from 'ajv-formats';
4
+ import {
5
+ CHECKBOX_TRUE_ERROR_MESSAGE, CHECKBOX_TRUE_VALIDATOR_NAME,
6
+ ENUM_NAMES_VALIDATOR_NAME, FUTURE_DATE_ERROR_MESSAGE, FUTURE_DATE_VALIDATOR_NAME,
7
+ MAX_FILE_SIZE_VALIDATOR_NAME, MUST_BE_CITIZEN_ERROR_MESSAGE, MUST_BE_CITIZEN_VALIDATOR_NAME,
8
+ MUST_BE_US_ERROR_MESSAGE, MUST_BE_US_VALIDATOR_NAME, NOT_EMPTY_VALIDATOR_NAME,
9
+ NOT_ZERO_ERROR_MESSAGE, NOT_ZERO_VALIDATOR_NAME, ONLY_LETTERS_ERROR_MESSAGE,
10
+ ONLY_LETTERS_VALIDATOR_NAME, REQUIRED_ERROR_MESSAGE, UNDER_AGE_ERROR_MESSAGE,
11
+ UNDER_AGE_VALIDATOR_NAME, ZIP_REGEX_ERROR_MESSAGE, ZIP_REGEX_VALIDATOR_NAME,
12
+ } from './constants.ts';
13
+
14
+ export interface InvestmentAjvOptions {
15
+ /** Inject a clock for deterministic age and date validation tests. */
16
+ clock?: () => Date;
17
+ }
18
+
19
+ enum CitizenTypes {
20
+ us_citizen = 'U.S. Citizen',
21
+ us_resident = 'U.S. Resident',
22
+ us_non_resident = 'Non Resident',
23
+ }
24
+
25
+ const ONLY_LETTERS_REGEX = /^[A-Za-z\s]*[A-Za-z][A-Za-z\s]*$/;
26
+ const ZIP_REGEX = /^\d{5}(-\d{4})?$/;
27
+
28
+ export function createInvestmentAjv(
29
+ options: InvestmentAjvOptions = {},
30
+ ajvOptions: Options = {},
31
+ ): Ajv {
32
+ const now = options.clock ?? (() => new Date());
33
+ const ajv = new Ajv({ allErrors: true, allowMatchingProperties: true, $data: true, ...ajvOptions });
34
+
35
+ ajvErrors(ajv);
36
+ addFormats(ajv, ['date', 'time', 'float', 'email']);
37
+ // Backend schemas use file as an opaque string. Validation of size/content
38
+ // remains a host presentation concern and never weakens backend validation.
39
+ ajv.addFormat('file', { type: 'string', validate: () => true } as any);
40
+
41
+ const addKeywordWithMessage = (
42
+ keyword: string,
43
+ validate: (schema: unknown, data: unknown) => boolean,
44
+ message: string,
45
+ ) => {
46
+ ajv.addKeyword({ keyword, validate, error: { message } });
47
+ };
48
+
49
+ addKeywordWithMessage(
50
+ NOT_EMPTY_VALIDATOR_NAME,
51
+ (_schema, data) => data !== null && data !== undefined && typeof data === 'string' && data.trim() !== '',
52
+ REQUIRED_ERROR_MESSAGE,
53
+ );
54
+ addKeywordWithMessage(
55
+ MUST_BE_CITIZEN_VALIDATOR_NAME,
56
+ (_schema, data) => data === CitizenTypes.us_citizen || data === CitizenTypes.us_resident,
57
+ MUST_BE_CITIZEN_ERROR_MESSAGE,
58
+ );
59
+ addKeywordWithMessage(
60
+ MUST_BE_US_VALIDATOR_NAME,
61
+ (_schema, data) => {
62
+ const value = data as any;
63
+ const candidate = String(value && typeof value === 'object' && 'code' in value ? value.code : value).toLowerCase();
64
+ return candidate === 'us';
65
+ },
66
+ MUST_BE_US_ERROR_MESSAGE,
67
+ );
68
+ addKeywordWithMessage(CHECKBOX_TRUE_VALIDATOR_NAME, (_schema, data) => data === true, CHECKBOX_TRUE_ERROR_MESSAGE);
69
+ addKeywordWithMessage(
70
+ NOT_ZERO_VALIDATOR_NAME,
71
+ (schema, data) => schema ? typeof data === 'number' && data > 0 : true,
72
+ NOT_ZERO_ERROR_MESSAGE,
73
+ );
74
+ addKeywordWithMessage(
75
+ ONLY_LETTERS_VALIDATOR_NAME,
76
+ (_schema, data) => typeof data === 'string' && ONLY_LETTERS_REGEX.test(data),
77
+ ONLY_LETTERS_ERROR_MESSAGE,
78
+ );
79
+ addKeywordWithMessage(
80
+ ZIP_REGEX_VALIDATOR_NAME,
81
+ (_schema, data) => typeof data !== 'string' || data.length < 5 || ZIP_REGEX.test(data),
82
+ ZIP_REGEX_ERROR_MESSAGE,
83
+ );
84
+ addKeywordWithMessage(
85
+ UNDER_AGE_VALIDATOR_NAME,
86
+ (_schema, data) => {
87
+ const birthDate = new Date(data as any);
88
+ const current = now();
89
+ if (Number.isNaN(birthDate.getTime()) || birthDate.getTime() > current.getTime()) return true;
90
+ const eighteen = new Date(birthDate.getFullYear() + 18, birthDate.getMonth(), birthDate.getDate());
91
+ return eighteen <= current;
92
+ },
93
+ UNDER_AGE_ERROR_MESSAGE,
94
+ );
95
+ addKeywordWithMessage(
96
+ FUTURE_DATE_VALIDATOR_NAME,
97
+ (_schema, data) => {
98
+ const inputDate = new Date(data as any);
99
+ return Number.isNaN(inputDate.getTime()) || inputDate < now();
100
+ },
101
+ FUTURE_DATE_ERROR_MESSAGE,
102
+ );
103
+
104
+ // Legacy presentation annotations stay accepted for compatibility. They
105
+ // carry no file policy and do not alter server or transport validation.
106
+ ajv.addKeyword({ keyword: ENUM_NAMES_VALIDATOR_NAME });
107
+ ajv.addKeyword({ keyword: MAX_FILE_SIZE_VALIDATOR_NAME });
108
+ return ajv;
109
+ }
@@ -0,0 +1,22 @@
1
+ export const PROPERTIES_KEY = 'properties';
2
+ export const ERRORS_KEY = '__errors';
3
+ export const NOT_EMPTY_VALIDATOR_NAME = 'notEmpty';
4
+ export const REQUIRED_ERROR_MESSAGE = 'Please complete';
5
+ export const MUST_BE_CITIZEN_VALIDATOR_NAME = 'mustBeCitizen';
6
+ export const MUST_BE_CITIZEN_ERROR_MESSAGE = 'Sorry, non-resident cannot invest at this moment';
7
+ export const ENUM_NAMES_VALIDATOR_NAME = 'enumNames';
8
+ export const MAX_FILE_SIZE_VALIDATOR_NAME = 'maxFileSize';
9
+ export const ONLY_LETTERS_VALIDATOR_NAME = 'onlyLetters';
10
+ export const ONLY_LETTERS_ERROR_MESSAGE = 'Must contain only letters.';
11
+ export const UNDER_AGE_VALIDATOR_NAME = 'underAge';
12
+ export const UNDER_AGE_ERROR_MESSAGE = 'Must not be under 18';
13
+ export const FUTURE_DATE_VALIDATOR_NAME = 'dateInFuture';
14
+ export const FUTURE_DATE_ERROR_MESSAGE = 'Cannot set date in the future';
15
+ export const ZIP_REGEX_VALIDATOR_NAME = 'zipRegex';
16
+ export const ZIP_REGEX_ERROR_MESSAGE = 'Please enter a valid zip code.';
17
+ export const CHECKBOX_TRUE_VALIDATOR_NAME = 'checkboxTrue';
18
+ export const CHECKBOX_TRUE_ERROR_MESSAGE = 'Please check this box if you want to proceed';
19
+ export const NOT_ZERO_VALIDATOR_NAME = 'numberNotZero';
20
+ export const NOT_ZERO_ERROR_MESSAGE = 'Please complete';
21
+ export const MUST_BE_US_VALIDATOR_NAME = 'mustBeUS';
22
+ export const MUST_BE_US_ERROR_MESSAGE = 'Only US Residents are allowed to invest at this time.';
@@ -0,0 +1,114 @@
1
+ import type { JSONSchemaType } from 'ajv/dist/types/json-schema';
2
+ import cloneDeep from 'lodash/cloneDeep.js';
3
+ import get from 'lodash/get.js';
4
+ import pick from 'lodash/pick.js';
5
+ import set from 'lodash/set.js';
6
+
7
+ interface FilteredObjectElement {
8
+ $ref?: string;
9
+ enum?: Array<any>;
10
+ enumNames?: Array<string>;
11
+ items?: FilteredObjectElement;
12
+ minLength?: number;
13
+ mustBeUS?: boolean;
14
+ properties?: FilteredObject;
15
+ title?: string;
16
+ type?: string;
17
+ }
18
+ type FilteredObject = Record<string, FilteredObjectElement>;
19
+
20
+ function cleanEnums(filteredObject: FilteredObject): FilteredObject {
21
+ Object.keys(filteredObject).forEach((key) => {
22
+ const element = filteredObject[key];
23
+ if (element.type === 'string' && element.enum) delete element.enum;
24
+ });
25
+ return filteredObject;
26
+ }
27
+
28
+ export function resolveRef(ref: string, schema: JSONSchemaType<any>) {
29
+ const refPath = ref.replace(/^#\//, '').split('/').map(segment => segment.replaceAll('~1', '/').replaceAll('~0', '~'));
30
+ return ref === '#' ? schema : get(schema, refPath);
31
+ }
32
+
33
+ export const getFilteredObject = (
34
+ schema: JSONSchemaType<any> | undefined,
35
+ formModel: Record<string, any>,
36
+ refPath: string = schema?.$ref || '',
37
+ ): FilteredObject => {
38
+ if (!schema || !formModel) return {};
39
+ const clonedSchema = cloneDeep(schema);
40
+ const resolvedObject = refPath ? resolveRef(refPath, clonedSchema) : clonedSchema;
41
+ if (!resolvedObject || !Object.keys(resolvedObject).length) return {};
42
+ delete resolvedObject.required;
43
+ set(clonedSchema, [], resolvedObject);
44
+ if (!Object.keys(formModel).length) return resolvedObject.properties;
45
+ return Object.entries(resolvedObject.properties ?? {}).reduce((filteredObject, [key, value]) => {
46
+ const schemaValue = value as FilteredObjectElement;
47
+ if (key in formModel) {
48
+ if (schemaValue.$ref) filteredObject[key] = getFilteredObject(schema, formModel[key], schemaValue.$ref) as FilteredObjectElement;
49
+ else if (schemaValue.type === 'array' && schemaValue.items?.$ref) filteredObject[key] = getFilteredObject(schema, formModel[key], schemaValue.items.$ref) as FilteredObjectElement;
50
+ else filteredObject[key] = schemaValue;
51
+ }
52
+ return filteredObject;
53
+ }, {} as FilteredObject);
54
+ };
55
+
56
+ export function getFieldSchema(path: string | undefined, ref: string | undefined, schema: JSONSchemaType<any>): any | undefined {
57
+ if (!path || !ref) return undefined;
58
+ const objectFromRefPath = resolveRef(ref, schema);
59
+ const pathSegments = path.split('.').filter(segment => Number.isNaN(Number(segment)));
60
+ const firstChild = pathSegments.shift();
61
+ const restSegments = pathSegments.join('.');
62
+ if (!firstChild || !objectFromRefPath?.properties) return undefined;
63
+ const segment0Property = objectFromRefPath.properties[firstChild];
64
+ if (segment0Property?.$ref) return getFieldSchema(restSegments, segment0Property.$ref, schema);
65
+ if (segment0Property?.type === 'array' && segment0Property.items?.$ref) return getFieldSchema(restSegments, segment0Property.items.$ref, schema);
66
+ return objectFromRefPath;
67
+ }
68
+
69
+ function removeRequiredFromDefinitions(schema: any) {
70
+ const visitSchemaRootAndDefinitions = (node: any, removeRootRequired: boolean) => {
71
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
72
+
73
+ if (removeRootRequired) delete node.required;
74
+
75
+ for (const key of ['definitions', '$defs']) {
76
+ const definitions = node[key];
77
+ if (!definitions || typeof definitions !== 'object' || Array.isArray(definitions)) continue;
78
+
79
+ for (const definition of Object.values(definitions)) {
80
+ visitSchemaRootAndDefinitions(definition, true);
81
+ }
82
+ }
83
+ };
84
+
85
+ // Backend projection may omit requirements at the schema root and at the
86
+ // roots of named definitions. Requirements on inline properties, items, and
87
+ // composition branches remain validation policy and must survive.
88
+ visitSchemaRootAndDefinitions(schema, true);
89
+ return schema;
90
+ }
91
+
92
+ export const filterSchema = (schema: JSONSchemaType<any>, formModel: any): any => {
93
+ if (!schema) return schema;
94
+ const newSchema = cloneDeep(schema);
95
+ const path = newSchema.$ref?.replace('#/', '')?.split('/') || [];
96
+ const mainDataObject: any = path.length ? get(newSchema, path.join('.')) : newSchema;
97
+ if (!mainDataObject?.properties) return newSchema;
98
+ delete mainDataObject.required;
99
+ set(newSchema, path, mainDataObject);
100
+ removeRequiredFromDefinitions(newSchema);
101
+ const keys = Object.keys(formModel || {});
102
+ set(newSchema, [...path, 'properties'], cleanEnums((keys.length ? pick(mainDataObject.properties, keys) : {}) as FilteredObject));
103
+ return newSchema;
104
+ };
105
+
106
+ export const undefinedEmptyProp = <T extends object>(data: T): T => {
107
+ const clone = cloneDeep(data) as any;
108
+ const visit = (value: any): any => {
109
+ if (Array.isArray(value)) return value.map(visit);
110
+ if (!value || typeof value !== 'object') return value === '' ? undefined : value;
111
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, visit(item)]));
112
+ };
113
+ return visit(clone) as T;
114
+ };
@@ -0,0 +1,5 @@
1
+ export * from './ajv.ts';
2
+ export * from './constants.ts';
3
+ export * from './general.ts';
4
+ export * from './investment.ts';
5
+ export * from './rules.ts';
@@ -0,0 +1,65 @@
1
+ import type { JSONSchemaType } from 'ajv/dist/types/json-schema';
2
+ import cloneDeep from 'lodash/cloneDeep.js';
3
+ import merge from 'lodash/merge.js';
4
+ import { undefinedEmptyProp } from './general.ts';
5
+
6
+ export type InvestmentSchema<T extends object = object> = JSONSchemaType<T> & Record<string, unknown>;
7
+
8
+ function removeRequiredFromDefinitions(schema: any) {
9
+ // The backend projection is allowed to omit only schema-level requirements
10
+ // and requirements declared by named definitions. Inline field, item, and
11
+ // composition requirements remain validation policy and must survive.
12
+ const visitDefinitions = (node: any, includeRoot: boolean) => {
13
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
14
+ if (includeRoot) delete node.required;
15
+ for (const key of ['definitions', '$defs']) {
16
+ const definitions = node[key];
17
+ if (!definitions || typeof definitions !== 'object' || Array.isArray(definitions)) continue;
18
+ for (const definition of Object.values(definitions)) {
19
+ visitDefinitions(definition, true);
20
+ }
21
+ }
22
+ };
23
+ visitDefinitions(schema, true);
24
+ return schema;
25
+ }
26
+
27
+ function sanitizeContentMediaType(schema: any) {
28
+ const visit = (node: any) => {
29
+ if (!node || typeof node !== 'object') return;
30
+ if (Array.isArray(node)) { node.forEach(visit); return; }
31
+ if ('contentMediaType' in node && typeof node.contentMediaType !== 'string') delete node.contentMediaType;
32
+ Object.values(node).forEach(value => {
33
+ if (value && typeof value === 'object') visit(value);
34
+ });
35
+ };
36
+ visit(schema);
37
+ return schema;
38
+ }
39
+
40
+ /**
41
+ * Compose app-owned frontend rules with backend schema metadata without
42
+ * mutating either input. Frontend fields take precedence while backend
43
+ * definitions retain compatibility with the legacy form projection.
44
+ */
45
+ export function composeInvestmentFormSchema<T extends object>(
46
+ frontend: Readonly<JSONSchemaType<T>>,
47
+ backend: Readonly<JSONSchemaType<T>> | undefined,
48
+ ): JSONSchemaType<T> {
49
+ const frontendClone = cloneDeep(frontend);
50
+ if (!backend) return frontendClone;
51
+ const backendClone = sanitizeContentMediaType(removeRequiredFromDefinitions(cloneDeep(backend)));
52
+ return merge({}, backendClone, frontendClone) as JSONSchemaType<T>;
53
+ }
54
+
55
+ /** Preserve the old investment form empty-string normalization on a clone. */
56
+ export function prepareInvestmentFormData<T extends object>(model: Readonly<T>): T {
57
+ return undefinedEmptyProp(model as T);
58
+ }
59
+
60
+ const REFERENCE_TYPES = new Set(['Individual', 'Entity', 'Trust', 'Sdira', 'Solo401k', 'SdiraEdit']);
61
+
62
+ export function getReferenceType(schema?: { $ref?: string }): string {
63
+ const refType = schema?.$ref?.split('/').pop();
64
+ return refType && REFERENCE_TYPES.has(refType) ? refType : 'Individual';
65
+ }
@@ -0,0 +1,35 @@
1
+ export const cityRule = { onlyLetters: true, title: 'city', type: 'string', minLength: 2 };
2
+ export const stateRule = { onlyLetters: true, title: 'state', type: 'string', maxLength: 2, minLength: 2 };
3
+ export const countryRule = { mustBeUS: true, title: 'country', minLength: 2 };
4
+ export const countryRuleObject = { mustBeUS: true, title: 'country', minLength: 2 };
5
+ export const zipRule = { title: 'zip_code', type: 'string', zipRegex: true };
6
+ export const ssnRule = { maxLength: 9, minLength: 9, title: 'ssn', type: 'string' };
7
+ export const phoneRule = { title: 'phone', type: 'string', minLength: 11 };
8
+ export const address1Rule = { title: 'address1', type: 'string', minLength: 4 };
9
+ export const address2Rule = { title: 'address2', type: 'string', minLength: 3 };
10
+ export const dobRule = {
11
+ title: 'dob', type: 'string', format: 'date', underAge: true, dateInFuture: true,
12
+ errorMessage: { format: 'Please provide a valid date DD.MM.YYYY' },
13
+ };
14
+ export const middleNameRule = { title: 'middle_name', type: 'string', minLength: 2 };
15
+ export const lastNameRule = { title: 'last_name', type: 'string', minLength: 2 };
16
+ export const firstNameRule = { title: 'first_name', type: 'string', minLength: 2 };
17
+ export const passwordRule = { minLength: 8, type: 'string' };
18
+ export const codeRule = {};
19
+ export const citizenshipRule = { mustBeCitizen: true, title: 'citizenship', type: 'string', minLength: 3 };
20
+ export const emailRule = {
21
+ type: 'string', format: 'email', maxLength: 100,
22
+ errorMessage: { format: 'Please provide a valid email' },
23
+ };
24
+ export const descriptionFileRule = { minLength: 10, type: 'string' };
25
+ export const noteFileRule = { minLength: 10, type: 'string' };
26
+ export const accountHolderNameRule = { type: 'string', minLength: 4 };
27
+ export const accountTypeRule = { type: 'string' };
28
+ export const accountNumberRule = { type: 'string', minLength: 9, maxLength: 18 };
29
+ export const routingNumbeRuler = { type: 'string', minLength: 9 };
30
+ export const relationshipTypeRule = { type: 'string', minLength: 2 };
31
+ export const typeProfileRule = { type: 'string', minLength: 3 };
32
+ export const identificationTypeRule = { type: 'string', minLength: 3 };
33
+ export const identificationNumberRule = { minLength: 3 };
34
+ export const documentRule = { errorMessage: { minimum: 'Please provide a document' } };
35
+ export const errorMessageRule = { required: 'Please complete' };
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatBuildDisplay, formatBuildTimestamp } from '../buildInfo.ts';
3
+
4
+ describe('buildInfo', () => {
5
+ it('formats build timestamps in a stable UTC label', () => {
6
+ expect(formatBuildTimestamp('2026-04-03T12:34:56.000Z')).toBe('Apr 3, 2026, 12:34 PM UTC');
7
+ });
8
+
9
+ it('falls back to the raw timestamp when parsing fails', () => {
10
+ expect(formatBuildTimestamp('custom-build-time')).toBe('custom-build-time');
11
+ });
12
+
13
+ it('formats commit labels with optional build details', () => {
14
+ expect(formatBuildDisplay('Commit: ', 'abc123', '2026-04-03T12:34:56.000Z'))
15
+ .toBe('Commit: abc123 (built at Apr 3, 2026, 12:34 PM UTC)');
16
+ expect(formatBuildDisplay('Commit: ', 'abc123')).toBe('Commit: abc123');
17
+ expect(formatBuildDisplay('Commit: ', '', '2026-04-03T12:34:56.000Z')).toBe('');
18
+ });
19
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ formatLocalHoursMinutes,
4
+ formatToFullDate,
5
+ formatToShortMonthDateYear,
6
+ } from '../dateTime.ts';
7
+
8
+ describe('date/time formatting', () => {
9
+ it('formats full numeric dates with the existing en-US formatter', () => {
10
+ expect(formatToFullDate(new Date(2026, 3, 8))).toBe('4/8/2026');
11
+ expect(formatToFullDate('not-a-date')).toBe('-');
12
+ expect(formatToFullDate(undefined)).toBe('-');
13
+ expect(formatToFullDate(undefined, '')).toBe('');
14
+ });
15
+
16
+ it('formats short month dates for profile display', () => {
17
+ expect(formatToShortMonthDateYear(new Date(2026, 3, 8))).toBe('Apr 8, 2026');
18
+ });
19
+
20
+ it('formats local time as unpadded hours and padded minutes', () => {
21
+ expect(formatLocalHoursMinutes(new Date(2026, 3, 8, 9, 5))).toBe('9:05');
22
+ expect(formatLocalHoursMinutes(new Date(2026, 3, 8, 17, 0))).toBe('17:00');
23
+ });
24
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ capitalizeFirstLetter,
4
+ currency,
5
+ } from '../display.ts';
6
+
7
+ describe('display formatting', () => {
8
+ it('formats USD currency with the default two fractional digits', () => {
9
+ expect(currency(1234.5)).toBe('$1,234.50');
10
+ expect(currency(0)).toBe('$0.00');
11
+ });
12
+
13
+ it('uses the provided minimum fractional digits when formatting currency', () => {
14
+ expect(currency(12.5, 0)).toBe('$12.5');
15
+ expect(currency(12, 0)).toBe('$12');
16
+ });
17
+
18
+ it('formats missing, invalid, infinite, and negative currency values as zero', () => {
19
+ expect(currency(undefined)).toBe('$0.00');
20
+ expect(currency(Number.NaN)).toBe('$0.00');
21
+ expect(currency(Number.POSITIVE_INFINITY)).toBe('$0.00');
22
+ expect(currency(-1)).toBe('$0.00');
23
+ });
24
+
25
+ it('capitalizes the first character without changing the rest of the string', () => {
26
+ expect(capitalizeFirstLetter('wallet_update')).toBe('Wallet_update');
27
+ expect(capitalizeFirstLetter('already Capitalized')).toBe('Already Capitalized');
28
+ expect(capitalizeFirstLetter('')).toBe('');
29
+ });
30
+ });
@@ -0,0 +1,31 @@
1
+ const buildDateTimeFormatter = new Intl.DateTimeFormat('en-US', {
2
+ year: 'numeric',
3
+ month: 'short',
4
+ day: 'numeric',
5
+ hour: '2-digit',
6
+ minute: '2-digit',
7
+ timeZone: 'UTC',
8
+ timeZoneName: 'short',
9
+ });
10
+
11
+ export function formatBuildTimestamp(buildTimestamp?: string): string {
12
+ if (!buildTimestamp) return '';
13
+
14
+ const parsedDate = new Date(buildTimestamp);
15
+ return Number.isNaN(parsedDate.getTime())
16
+ ? buildTimestamp
17
+ : buildDateTimeFormatter.format(parsedDate);
18
+ }
19
+
20
+ export function formatBuildDisplay(
21
+ prefix: string,
22
+ commitHash?: string,
23
+ buildTimestamp?: string,
24
+ ): string {
25
+ if (!commitHash) return '';
26
+
27
+ const formattedBuildTimestamp = formatBuildTimestamp(buildTimestamp);
28
+ return formattedBuildTimestamp
29
+ ? `${prefix}${commitHash} (built at ${formattedBuildTimestamp})`
30
+ : `${prefix}${commitHash}`;
31
+ }
@@ -0,0 +1,43 @@
1
+ const FULL_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
2
+ year: 'numeric',
3
+ month: 'numeric',
4
+ day: 'numeric',
5
+ });
6
+
7
+ const SHORT_MONTH_DATE_YEAR_FORMATTER = new Intl.DateTimeFormat('en-US', {
8
+ month: 'short',
9
+ day: 'numeric',
10
+ year: 'numeric',
11
+ });
12
+
13
+ export type DateTimeInput = Date | string;
14
+
15
+ const toDate = (value: DateTimeInput): Date => (
16
+ value instanceof Date ? value : new Date(value)
17
+ );
18
+
19
+ export const formatToFullDate = (
20
+ value?: DateTimeInput | null,
21
+ fallback = '-',
22
+ ): string => {
23
+ if (!value) return fallback;
24
+
25
+ const date = toDate(value);
26
+ if (Number.isNaN(date.getTime())) return fallback;
27
+
28
+ return FULL_DATE_FORMATTER.format(date);
29
+ };
30
+
31
+ export const formatToShortMonthDateYear = (value: DateTimeInput): string => (
32
+ SHORT_MONTH_DATE_YEAR_FORMATTER
33
+ .format(toDate(value))
34
+ .replace(/^[A-Z]/, (match) => match)
35
+ );
36
+
37
+ export const formatLocalHoursMinutes = (value: DateTimeInput): string => {
38
+ const date = toDate(value);
39
+ const hours = date.getHours();
40
+ const minutes = date.getMinutes().toString().padStart(2, '0');
41
+
42
+ return `${hours}:${minutes}`;
43
+ };
@@ -0,0 +1,24 @@
1
+ const defaultCurrencyFormatter = new Intl.NumberFormat('en-US', {
2
+ style: 'currency',
3
+ currency: 'USD',
4
+ minimumFractionDigits: 2,
5
+ });
6
+
7
+ const createCurrencyFormatter = (digits: number = 2) => new Intl.NumberFormat('en-US', {
8
+ style: 'currency',
9
+ currency: 'USD',
10
+ minimumFractionDigits: digits,
11
+ });
12
+
13
+ export function currency(val: number | undefined, digits?: number): string {
14
+ const formatter = digits !== undefined
15
+ ? createCurrencyFormatter(digits)
16
+ : defaultCurrencyFormatter;
17
+ const n = Number(val);
18
+ const safe = Number.isFinite(n) && n >= 0 ? n : 0;
19
+ return formatter.format(safe);
20
+ }
21
+
22
+ export const capitalizeFirstLetter = (str: string): string => (
23
+ str.charAt(0).toUpperCase() + str.slice(1)
24
+ );
@@ -0,0 +1,11 @@
1
+ import { capitalizeFirstLetter } from './text';
2
+
3
+ export function combineArraysToObjects(names: string[], values: string[]): { name: string; value: string }[] {
4
+ // Ensure both arrays have the same length to avoid mismatches
5
+ const length = Math.min(names.length, values.length);
6
+
7
+ return names.slice(0, length).map((name, index) => ({
8
+ name: capitalizeFirstLetter(name),
9
+ value: values[index],
10
+ }));
11
+ }
@@ -0,0 +1,19 @@
1
+ const defaultInstance = new Intl.NumberFormat('en-US', {
2
+ style: 'currency',
3
+ currency: 'USD',
4
+ minimumFractionDigits: 2,
5
+ });
6
+
7
+ const instance = (digits: number = 2) => new Intl.NumberFormat('en-US', {
8
+ style: 'currency',
9
+ currency: 'USD',
10
+ minimumFractionDigits: digits,
11
+ });
12
+
13
+ export function currency(val: number | undefined, digits?: number) {
14
+ const formatter = digits !== undefined ? instance(digits) : defaultInstance;
15
+ const n = Number(val);
16
+ const safe = Number.isFinite(n) && n >= 0 ? n : 0;
17
+ return formatter.format(safe);
18
+ }
19
+