Merge branch 'dev' into feat/lam-1291/stress-testing

* dev: (41 commits)
  build: use bullseye as target build
  chore: remove whitespace
  refactor: simplify denominations list construction
  fix: sort coins by descending denomination
  feat: address prompt feature toggle on ui
  feat: reuse last address option
  fix: performance issues on SystemPerformance
  chore: clarify requirements on comment
  feat: allow address reuse if same customer
  feat: address reuse is now per customer
  fix: hide anon and show phone on customers
  fix: dev environment restarts
  feat: batch diagnostics script
  fix: custom info request returns array
  fix: name on customer if custom data is filled
  build: testing cache hit
  build: server cache improvements
  build: node_modules was ignored on .dockerignored
  build: leftovers from npm
  chore: commented by mistake
  ...
This commit is contained in:
siiky 2025-06-02 13:31:02 +01:00
commit 5feee6d5df
105 changed files with 17323 additions and 31348 deletions

View file

@ -1,4 +1,5 @@
**/node_modules node_modules
packages/*/node_modules
.git .git
.direnv .direnv
.envrc .envrc

View file

@ -4,42 +4,48 @@ on: [ workflow_dispatch ]
jobs: jobs:
everything: everything:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Setup Turbo cache
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: /tmp/.buildx-cache path: .turbo
key: ${{ runner.os }}-buildx-updatetar key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: | restore-keys: |
${{ runner.os }}-buildx-updatetar ${{ runner.os }}-turbo-
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
file: build/ci.Dockerfile
load: true
tags: ci_image:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Extract artifact from Docker image - name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.11.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Build packages with Turbo
run: pnpm run build
- name: Package production build
run: | run: |
docker create --name extract_artifact ci_image:latest # Create production-ready server package using pnpm deploy
docker cp extract_artifact:/lamassu-server.tar.gz ./lamassu-server.tar.gz pnpm deploy --filter=./packages/server --prod lamassu-server --legacy
docker rm extract_artifact
# Copy built admin UI to public directory
cp -r packages/admin-ui/build lamassu-server/public
# Create tarball
tar -zcf lamassu-server.tar.gz lamassu-server/
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: lamassu-server.tar.gz name: lamassu-server.tar.gz
path: lamassu-server.tar.gz path: lamassu-server.tar.gz
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

View file

@ -11,22 +11,57 @@ env:
jobs: jobs:
build-and-publish: build-and-publish:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Setup Turbo cache
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: /tmp/.buildx-cache path: .turbo
key: ${{ runner.os }}-buildx-updatetar key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: | restore-keys: |
${{ runner.os }}-buildx-updatetar ${{ runner.os }}-turbo-
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.11.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Build packages with Turbo
run: pnpm run build
- name: Verify build artifacts
run: |
echo "=== Verifying typesafe-db build ==="
ls -la packages/typesafe-db/lib/
echo "=== Verifying admin-ui build ==="
ls -la packages/admin-ui/build/
- name: Package production build
run: |
# Create production-ready server package using pnpm deploy
pnpm deploy --filter=./packages/server --prod lamassu-server --legacy
# Copy built admin UI to public directory
cp -r packages/admin-ui/build lamassu-server/public
# Copy Dockerfile to lamassu-server context
cp build/server.Dockerfile lamassu-server/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
@ -34,29 +69,24 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }} password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build and push - name: Build and push server image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: lamassu-server
push: true push: true
target: l-s target: l-s
file: ./build/server.Dockerfile file: lamassu-server/server.Dockerfile
tags: ${{ env.DOCKERHUB_SERVER_REPO }}:latest tags: ${{ env.DOCKERHUB_SERVER_REPO }}:latest
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=gha
cache-to: type=local,dest=/tmp/.buildx-cache-new cache-to: type=gha,mode=max
- name: Build and push - name: Build and push admin server image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: lamassu-server
push: true push: true
target: l-a-s target: l-a-s
file: ./build/server.Dockerfile file: lamassu-server/server.Dockerfile
tags: ${{ env.DOCKERHUB_ADMIN_REPO }}:latest tags: ${{ env.DOCKERHUB_ADMIN_REPO }}:latest
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=gha
cache-to: type=local,dest=/tmp/.buildx-cache-new cache-to: type=gha,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

40
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Tests
on:
pull_request:
branches: [ dev ]
push:
branches: [ dev ]
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Turbo cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.11.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm run test

4
.gitignore vendored
View file

@ -1,9 +1,13 @@
**/node_modules **/node_modules
**/.env **/.env
.pnpm-store/
.idea/ .idea/
.settings/ .settings/
.turbo/
packages/server/.lamassu
packages/server/certs/ packages/server/certs/
packages/server/tests/stress/machines packages/server/tests/stress/machines
packages/server/tests/stress/config.json packages/server/tests/stress/config.json
packages/typesafe-db/lib/

View file

@ -1,4 +1,5 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh" . "$(dirname -- "$0")/_/husky.sh"
# Run linting
npx lint-staged npx lint-staged

View file

@ -1 +1,3 @@
nodejs 22 nodejs 22
pnpm 10
python 3

View file

@ -1,115 +0,0 @@
# Install - nix
## Preliminaries for using nix
For a dev environment with nix package manager a postgres install on the base system is required, this guide does not cover a postgresql server running with nix-shell.
### Set up PostgreSQL
```
sudo -u postgres createdb lamassu
sudo -u postgres psql postgres
```
In `psql`, run the following and set password to `postgres123`:
```
\password postgres
ctrl-d
```
### Starting up environment
shell.nix script provided, all you need to do to setup the environment is to run `nix-shell` on the folder.
## Installation
### Install node modules
```
npm install
```
### Generate certificates
```
bash packages/server/tools/cert-gen.sh
```
Notes:
- This will create a `.lamassu` directory in your home directory.
### Set up database
Important: lamassu-migrate currently gripes about a QueryResultError. Ignore this, it works anyway.
```
node packages/server/bin/lamassu-migrate
```
### Run new-lamassu-admin
```
cd packages/admin-ui/
npm run start
```
### Run lamassu-admin-server
In a second terminal window:
```
node packages/server/bin/lamassu-admin-server --dev
```
### Register admin user
In a third terminal window:
```
node packages/server/bin/lamassu-register admin@example.com superuser
```
You'll use this generated URL in the brower in a moment.
### Complete configuration
Paste the URL from lamassu-register exactly as output, into a browser (chrome or firefox).
**Important**: the host must be localhost. Tell your browser to trust the certificate even though it's not signed by a recognized CA. If you get an "expired" error, try opening https://localhost:8070/graphql in another tab and trust the certificate.
Go to all the required, unconfigured red fields and choose some values. Choose mock services whenever available.
### Run lamassu-server
```
node packages/server/bin/lamassu-server --mockScoring
```
### Add a lamassu-machine
Click on `+ Add Machine` in the sidebar. Type in a name for your machine and click **Pair**. Open up development tools to show the JavaScript console and copy the totem. You will use this to run lamassu-machine. This pairing totem expires after an hour.
Now continue with lamassu-machine instructions from the `INSTALL.md` file in [lamassu-machine repository](https://github.com/lamassu/lamassu-machine).
## Subsequent runs
To start the Lamassu server run:
```
node packages/server/bin/lamassu-server --mockScoring
```
To start the Lamassu Admin run:
```
node packages/server/bin/lamassu-admin-server --dev
```
and
```
cd packages/admin-ui/
npm run start
```

View file

@ -1,126 +0,0 @@
# Install
## Preliminaries for Ubuntu 16.04
Installation for other distros may be slightly different. This assumes NodeJS 22 or higher is already installed. All of this is done in the lamassu-server directory.
### Packages
```
sudo apt-get update
sudo apt-get install postgresql postgresql-contrib postgresql-server-dev-9.5 libpq-dev git
```
### Set up PostgreSQL
```
sudo -u postgres createdb lamassu
sudo -u postgres psql postgres
```
In `psql`, run the following and set password to `postgres123`:
```
\password postgres
ctrl-d
```
## Installation
### Install node modules
Make sure you're running NodeJS 22 or higher. Ignore any warnings.
```
npm install
```
### Generate certificates
```
bash packages/server/tools/cert-gen.sh
```
Notes:
- This will create a `.lamassu` directory in your home directory.
### Set up database
Important: lamassu-migrate currently gripes about a QueryResultError. Ignore this, it works anyway.
```
node packages/server/bin/lamassu-migrate
```
### Run new-lamassu-admin
```
cd packages/admin-ui/
npm run start
```
### Run lamassu-admin-server
In a second terminal window:
```
node packages/server/bin/lamassu-admin-server --dev
```
### Register admin user
In a third terminal window:
```
node packages/server/bin/lamassu-register admin@example.com superuser
```
You'll use this generated URL in the brower in a moment.
### Complete configuration
Paste the URL from lamassu-register exactly as output, into a browser (chrome or firefox).
**Important**: the host must be localhost. Tell your browser to trust the certificate even though it's not signed by a recognized CA. If you get an "expired" error, try opening https://localhost:8070/graphql in another tab and trust the certificate.
Go to all the required, unconfigured red fields and choose some values. Choose mock services whenever available.
### Run lamassu-server
```
node packages/server/bin/lamassu-server --mockScoring
```
### Add a lamassu-machine
Click on `+ Add Machine` in the sidebar. Type in a name for your machine and click **Pair**. Open up development tools to show the JavaScript console and copy the totem. You will use this to run lamassu-machine. This pairing totem expires after an hour.
Now continue with lamassu-machine instructions from the `INSTALL.md` file in [lamassu-machine repository](https://github.com/lamassu/lamassu-machine).
### Run a local coin node (BTC supported)
Run `node packages/server/bin/lamassu-coins` in the project root and select `Bitcoin`. This process will require the existence of certain environment variables that the setup will warn about.
Once that is done, the node needs to be run in a terminal with the following command `<YOUR_BLOCKCHAIN_DIR_ENV_VAR>/bin/bitcoind -datadir=<YOUR_BLOCKCHAIN_DIR_ENV_VAR>/bitcoin`
## Subsequent runs
To start the Lamassu server run:
```
node packages/server/bin/lamassu-server --mockScoring
```
To start the Lamassu Admin run:
```
node packages/server/bin/lamassu-admin-server --dev
```
and
```
cd packages/admin-ui/
npm run start
```

View file

@ -6,14 +6,57 @@ Lamassu remote server.
We do not generally accept outside pull requests for new features. Please consult with us before putting a lot of work into a pull request. We do not generally accept outside pull requests for new features. Please consult with us before putting a lot of work into a pull request.
## Installation ## Development
See [INSTALL.md](INSTALL.md), or [INSTALL-NIX.md](INSTALL-NIX.md) for nix environment. ### Requirements
## Installation on remote server (only for production) - Nodejs 22
See [lamassu-remote-install/README.md](lamassu-remote-install/README.md). - PNPM 10+
- Postgres Database
- Python 3 (to be deprecated, required by a single dependency installation)
- OpenSSL (for cert-gen.sh, it will set up the server self-signed certificates)
There's a shell.nix file that you can use to set up your env in case you're a nix user. (most reliable way of installing native deps)
There's also a .tool-versions for asdf and mise users.
This project uses Turbo for monorepo management. Install dependencies:
## Running
```bash ```bash
node bin/lamassu-server --mockScoring pnpm install
``` ```
Prepare environment files:
```bash
bash packages/server/tools/cert-gen.sh
```
On packages/server/.env you can alter variables such as the postgres connection info.
After configuring the postgres connection, run:
```bash
node packages/server/bin/lamassu-migrate
```
### Start development environment:
If you've already done the setup, you can run:
```bash
pnpm run dev
```
### Creating a user
```bash
node packages/server/bin/lamassu-register admin@example.com superuser
```
### Pairing a machine
To get the pairing token from the QRCode open the browser console before picking the name of the machine, the token should appear on the terminal.
It's also possible to inspect the qrCode, the token is on the data-cy="" attr.
Lastly, you can always scan it with a phone and copy the contents over.
Now continue with lamassu-machine instructions from the `INSTALL.md` file in [lamassu-machine repository](https://github.com/lamassu/lamassu-machine)

View file

@ -1,41 +0,0 @@
FROM node:22-alpine AS build-ui
RUN apk add --no-cache npm git curl build-base python3
COPY ["packages/admin-ui/package.json", "package-lock.json", "./"]
RUN npm version --allow-same-version --git-tag-version false --commit-hooks false 1.0.0
RUN npm install
COPY packages/admin-ui/ ./
RUN npm run build
FROM ubuntu:20.04 as base
ARG VERSION
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Europe/Lisbon
RUN apt-get update
RUN apt-get install -y -q curl \
sudo \
git \
python2-minimal \
build-essential \
libpq-dev \
net-tools \
tar
RUN curl -sL https://deb.nodesource.com/setup_22.x | sudo -E bash -
RUN apt-get install nodejs -y -q
WORKDIR lamassu-server
COPY ["packages/server/package.json", "package-lock.json", "./"]
RUN npm version --allow-same-version --git-tag-version false --commit-hooks false 1.0.0
RUN npm install --production
COPY ./packages/server/ ./
COPY --from=build-ui /build /lamassu-server/public
RUN cd .. && tar -zcvf lamassu-server.tar.gz ./lamassu-server

View file

@ -2,20 +2,16 @@ version: "3.8"
services: services:
lamassu-server: lamassu-server:
build: image: lamassu/lamassu-server:latest
context: .
dockerfile: build/server.Dockerfile
target: l-s
restart: on-failure restart: on-failure
ports: network_mode: host
- 3000:3000
volumes: volumes:
- ./lamassu-data:/lamassu-data - ./lamassu-data:/lamassu-data
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres123 - POSTGRES_PASSWORD=postgres123
- POSTGRES_HOST=host.docker.internal - POSTGRES_HOST=localhost
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
- POSTGRES_DB=lamassu - POSTGRES_DB=lamassu
- CA_PATH=/lamassu-data/certs/Lamassu_OP_Root_CA.pem - CA_PATH=/lamassu-data/certs/Lamassu_OP_Root_CA.pem
@ -31,20 +27,16 @@ services:
- LOG_LEVEL=info - LOG_LEVEL=info
lamassu-admin-server: lamassu-admin-server:
build: image: lamassu/lamassu-admin-server:latest
context: .
dockerfile: build/server.Dockerfile
target: l-a-s
restart: on-failure restart: on-failure
ports: network_mode: host
- 443:443
volumes: volumes:
- ./lamassu-data:/lamassu-data - ./lamassu-data:/lamassu-data
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres123 - POSTGRES_PASSWORD=postgres123
- POSTGRES_HOST=host.docker.internal - POSTGRES_HOST=localhost
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
- POSTGRES_DB=lamassu - POSTGRES_DB=lamassu
- CA_PATH=/lamassu-data/certs/Lamassu_OP_Root_CA.pem - CA_PATH=/lamassu-data/certs/Lamassu_OP_Root_CA.pem

View file

@ -1,49 +1,17 @@
FROM node:22-alpine AS build FROM node:22-bullseye AS base
RUN apk add --no-cache npm git curl build-base net-tools python3 postgresql-dev RUN apt install openssl ca-certificates
WORKDIR /lamassu-server WORKDIR /lamassu-server
COPY ["packages/server/package.json", "package-lock.json", "./"] # Copy the pre-built production package from CI (with node_modules)
RUN npm version --allow-same-version --git-tag-version false --commit-hooks false 1.0.0 COPY . ./
RUN npm install --production
COPY packages/server/ ./
FROM node:22-alpine AS l-s-base
RUN apk add --no-cache npm git curl bash libpq openssl ca-certificates
COPY --from=build /lamassu-server /lamassu-server
FROM l-s-base AS l-s
FROM base AS l-s
RUN chmod +x /lamassu-server/bin/lamassu-server-entrypoint.sh RUN chmod +x /lamassu-server/bin/lamassu-server-entrypoint.sh
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT [ "/lamassu-server/bin/lamassu-server-entrypoint.sh" ] ENTRYPOINT [ "/lamassu-server/bin/lamassu-server-entrypoint.sh" ]
FROM base AS l-a-s
FROM node:22-alpine AS build-ui
RUN apk add --no-cache npm git curl build-base python3
WORKDIR /app
COPY ["packages/admin-ui/package.json", "package-lock.json", "./"]
RUN npm version --allow-same-version --git-tag-version false --commit-hooks false 1.0.0
RUN npm install
COPY packages/admin-ui/ ./
RUN npm run build
FROM l-s-base AS l-a-s
COPY --from=build-ui /app/build /lamassu-server/public
RUN chmod +x /lamassu-server/bin/lamassu-admin-server-entrypoint.sh RUN chmod +x /lamassu-server/bin/lamassu-admin-server-entrypoint.sh
EXPOSE 443 EXPOSE 443
ENTRYPOINT [ "/lamassu-server/bin/lamassu-admin-server-entrypoint.sh" ] ENTRYPOINT [ "/lamassu-server/bin/lamassu-admin-server-entrypoint.sh" ]

View file

@ -5,10 +5,11 @@ import json from '@eslint/json'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
import reactCompiler from 'eslint-plugin-react-compiler' import reactCompiler from 'eslint-plugin-react-compiler'
import eslintConfigPrettier from 'eslint-config-prettier/flat' import eslintConfigPrettier from 'eslint-config-prettier/flat'
import pluginJest from 'eslint-plugin-jest' import vitest from 'eslint-plugin-vitest'
export default defineConfig([ export default defineConfig([
globalIgnores([ globalIgnores([
'**/.lamassu',
'**/build', '**/build',
'**/package.json', '**/package.json',
'**/package-lock.json', '**/package-lock.json',
@ -59,16 +60,12 @@ export default defineConfig([
{ {
// update this to match your test files // update this to match your test files
files: ['**/*.spec.js', '**/*.test.js'], files: ['**/*.spec.js', '**/*.test.js'],
plugins: { jest: pluginJest }, plugins: {
languageOptions: { vitest,
globals: pluginJest.environments.globals.globals,
}, },
rules: { rules: {
'jest/no-disabled-tests': 'warn', ...vitest.configs.recommended.rules, // you can also use vitest.configs.all.rules to enable all rules
'jest/no-focused-tests': 'error', 'vitest/max-nested-describe': ['error', { max: 3 }], // you can also modify rules' behavior using option like this
'jest/no-identical-title': 'error',
'jest/prefer-to-have-length': 'warn',
'jest/valid-expect': 'error',
}, },
}, },
]) ])

30375
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,30 +4,34 @@
"version": "11.0.0-beta.1", "version": "11.0.0-beta.1",
"license": "./LICENSE", "license": "./LICENSE",
"author": "Lamassu (https://lamassu.is)", "author": "Lamassu (https://lamassu.is)",
"packageManager": "pnpm@10.11.0",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/lamassu/lamassu-server.git" "url": "https://github.com/lamassu/lamassu-server.git"
}, },
"workspaces": [ "engines": {
"packages/server", "node": ">=22.0.0"
"packages/admin-ui" },
],
"devDependencies": { "devDependencies": {
"@eslint/css": "^0.7.0", "@eslint/css": "^0.7.0",
"@eslint/js": "^9.26.0", "@eslint/js": "^9.26.0",
"@eslint/json": "^0.12.0", "@eslint/json": "^0.12.0",
"eslint": "^9.26.0", "eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.1", "eslint-plugin-react-compiler": "^19.1.0-rc.1",
"eslint-plugin-vitest": "^0.5.4",
"globals": "^16.1.0", "globals": "^16.1.0",
"husky": "^8.0.0", "husky": "^8.0.0",
"lint-staged": "^16.0.0", "lint-staged": "^16.0.0",
"prettier": "^3.5.3" "prettier": "^3.5.3",
"turbo": "^2.5.3"
}, },
"scripts": { "scripts": {
"prepare": "husky install" "prepare": "husky install",
"build": "turbo build",
"dev": "turbo dev",
"test": "turbo test"
}, },
"husky": { "husky": {
"hooks": { "hooks": {

View file

@ -10,31 +10,27 @@ To take advantage of that make sure to run `git commit` from within this folder.
## Available Scripts ## Available Scripts
In the project directory, you can run: From the root directory (recommended with Turbo):
### `npm start` - `pnpm run dev` - Start development environment
- `pnpm run build` - Build for production
- `pnpm run admin:dev` - Start only admin UI development
Runs the app in the development mode.<br> In the admin-ui package directory, you can run:
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br> ### `pnpm start` or `pnpm run dev`
Runs the app in development mode with Vite.
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
The page will reload if you make edits.
You will also see any lint errors in the console. You will also see any lint errors in the console.
### `npm fix` ### `pnpm test`
Runs eslint --fix on the src folder Launches the test runner with vitest.
### `npm test` ### `pnpm run build`
Launches the test runner in the interactive watch mode.<br> Builds the app for production to the `build` folder.
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br>
It correctly bundles React in production mode and optimizes the build for the best performance. It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.

View file

@ -10,6 +10,7 @@
"@lamassu/coins": "v1.6.1", "@lamassu/coins": "v1.6.1",
"@mui/icons-material": "^7.1.0", "@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0", "@mui/material": "^7.1.0",
"@mui/x-date-pickers": "^8.3.1",
"@simplewebauthn/browser": "^3.0.0", "@simplewebauthn/browser": "^3.0.0",
"apollo-upload-client": "^18.0.0", "apollo-upload-client": "^18.0.0",
"bignumber.js": "9.0.0", "bignumber.js": "9.0.0",
@ -25,9 +26,10 @@
"jszip": "^3.6.0", "jszip": "^3.6.0",
"libphonenumber-js": "^1.11.15", "libphonenumber-js": "^1.11.15",
"match-sorter": "^4.2.0", "match-sorter": "^4.2.0",
"material-react-table": "^3.2.1",
"pretty-ms": "^2.1.0", "pretty-ms": "^2.1.0",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"ramda": "^0.26.1", "ramda": "^0.30.1",
"react": "18.3.1", "react": "18.3.1",
"react-copy-to-clipboard": "^5.0.2", "react-copy-to-clipboard": "^5.0.2",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@ -49,12 +51,15 @@
"prettier": "3.4.1", "prettier": "3.4.1",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"vite": "^6.0.1", "vite": "^6.0.1",
"vite-plugin-svgr": "^4.3.0" "vite-plugin-svgr": "^4.3.0",
"vitest": "^3.1.4"
}, },
"scripts": { "scripts": {
"start": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View file

@ -3,6 +3,8 @@ import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles'
import React, { useState } from 'react' import React, { useState } from 'react'
import { Router } from 'wouter' import { Router } from 'wouter'
import ApolloProvider from './utils/apollo' import ApolloProvider from './utils/apollo'
import { LocalizationProvider } from '@mui/x-date-pickers'
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV2'
import AppContext from './AppContext' import AppContext from './AppContext'
import theme from './styling/theme' import theme from './styling/theme'
@ -33,16 +35,18 @@ const App = () => {
isDirtyForm, isDirtyForm,
setDirtyForm, setDirtyForm,
}}> }}>
<Router hook={useLocationWithConfirmation}> <LocalizationProvider dateAdapter={AdapterDateFns}>
<ApolloProvider> <Router hook={useLocationWithConfirmation}>
<StyledEngineProvider enableCssLayer> <ApolloProvider>
<ThemeProvider theme={theme}> <StyledEngineProvider enableCssLayer>
<CssBaseline /> <ThemeProvider theme={theme}>
<Main /> <CssBaseline />
</ThemeProvider> <Main />
</StyledEngineProvider> </ThemeProvider>
</ApolloProvider> </StyledEngineProvider>
</Router> </ApolloProvider>
</Router>
</LocalizationProvider>
</AppContext.Provider> </AppContext.Provider>
) )
} }

View file

@ -1,4 +1,3 @@
import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
@ -18,9 +17,5 @@ const CollapsibleCard = ({ className, state, shrunkComponent, children }) => {
) )
} }
CollapsibleCard.propTypes = {
shrunkComponent: PropTypes.node.isRequired,
}
export default CollapsibleCard export default CollapsibleCard
export { cardState } export { cardState }

View file

@ -88,7 +88,7 @@ const NotificationCenter = ({
const notificationsToShow = const notificationsToShow =
!showingUnread || !hasUnread !showingUnread || !hasUnread
? notifications ? notifications
: R.filter(R.propEq('read', false))(notifications) : R.filter(R.propEq(false, 'read'))(notifications)
return notificationsToShow.map(n => { return notificationsToShow.map(n => {
return ( return (
<NotificationRow <NotificationRow

View file

@ -213,7 +213,7 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
} }
const groupStriped = elements => { const groupStriped = elements => {
const [toStripe, noStripe] = R.partition(R.propEq('stripe', true))(elements) const [toStripe, noStripe] = R.partition(R.propEq(true, 'stripe'))(elements)
if (!toStripe.length) { if (!toStripe.length) {
return elements return elements

View file

@ -73,7 +73,7 @@ const ETable = ({
setSaving(true) setSaving(true)
const it = validationSchema.cast(value, { assert: 'ignore-optionality' }) const it = validationSchema.cast(value, { assert: 'ignore-optionality' })
const index = R.findIndex(R.propEq('id', it.id))(data) const index = R.findIndex(R.propEq(it.id, 'id'))(data)
const list = index !== -1 ? R.update(index, it, data) : R.prepend(it, data) const list = index !== -1 ? R.update(index, it, data) : R.prepend(it, data)
if (!R.equals(data[index], it)) { if (!R.equals(data[index], it)) {

View file

@ -24,7 +24,7 @@ const Autocomplete = ({
autoFocus, autoFocus,
...props ...props
}) => { }) => {
const mapFromValue = options => it => R.find(R.propEq(valueProp, it))(options) const mapFromValue = options => it => R.find(R.propEq(it, valueProp))(options)
const mapToValue = R.prop(valueProp) const mapToValue = R.prop(valueProp)
const getValue = () => { const getValue = () => {

View file

@ -14,6 +14,7 @@ const ToggleButtonGroup = ({
}) => { }) => {
return ( return (
<MUIToggleButtonGroup <MUIToggleButtonGroup
className="flex flex-col gap-4"
size={size} size={size}
name={name} name={name}
orientation={orientation} orientation={orientation}

View file

@ -27,8 +27,9 @@
margin-top: 8px; margin-top: 8px;
} }
/*TODO important because of tailwind integration with MUI*/
.fullPartP { .fullPartP {
color: white; color: white !important;
display: inline; display: inline;
} }

View file

@ -1,7 +1,7 @@
import { useLazyQuery, useQuery, gql } from '@apollo/client' import { useLazyQuery, useQuery, gql } from '@apollo/client'
import { subMinutes } from 'date-fns' import { subMinutes } from 'date-fns'
import FileSaver from 'file-saver' import FileSaver from 'file-saver'
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import Modal from '../Modal' import Modal from '../Modal'
import { H3, P } from '../typography' import { H3, P } from '../typography'
@ -56,7 +56,7 @@ const createCsv = async ({ machineLogsCsv }) => {
const DiagnosticsModal = ({ onClose, deviceId, sendAction }) => { const DiagnosticsModal = ({ onClose, deviceId, sendAction }) => {
const [state, setState] = useState(STATES.INITIAL) const [state, setState] = useState(STATES.INITIAL)
const [timestamp, setTimestamp] = useState(null) const [timestamp, setTimestamp] = useState(null)
let timeout = null const timeoutRef = useRef(null)
const [fetchSummary, { loading }] = useLazyQuery(MACHINE_LOGS, { const [fetchSummary, { loading }] = useLazyQuery(MACHINE_LOGS, {
onCompleted: data => createCsv(data), onCompleted: data => createCsv(data),
@ -76,24 +76,41 @@ const DiagnosticsModal = ({ onClose, deviceId, sendAction }) => {
data.machine.diagnostics.timestamp && data.machine.diagnostics.timestamp &&
data.machine.diagnostics.timestamp !== timestamp data.machine.diagnostics.timestamp !== timestamp
) { ) {
clearTimeout(timeout) if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setTimestamp(data.machine.diagnostics.timestamp) setTimestamp(data.machine.diagnostics.timestamp)
setState(STATES.FILLED) setState(STATES.FILLED)
stopPolling() stopPolling()
} }
}, [data, stopPolling, timeout, timestamp]) }, [data, stopPolling, timestamp])
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
const path = `/operator-data/diagnostics/${deviceId}/` const path = `/operator-data/diagnostics/${deviceId}/`
function runDiagnostics() { const runDiagnostics = () => {
setState(STATES.RUNNING)
startPolling(2000) startPolling(2000)
timeout = setTimeout(() => { if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
setState(STATES.FAILURE) setState(STATES.FAILURE)
stopPolling() stopPolling()
timeoutRef.current = null
}, 60 * 1000) }, 60 * 1000)
setState(STATES.RUNNING)
sendAction() sendAction()
} }
@ -140,7 +157,7 @@ const DiagnosticsModal = ({ onClose, deviceId, sendAction }) => {
<H3>Scan</H3> <H3>Scan</H3>
<img <img
className="w-88" className="w-88"
src={path + 'scan.jpg'} src={`${path}scan.jpg?${Date.now()}`}
alt="Failure getting photo" alt="Failure getting photo"
/> />
</div> </div>
@ -148,7 +165,7 @@ const DiagnosticsModal = ({ onClose, deviceId, sendAction }) => {
<H3>Front</H3> <H3>Front</H3>
<img <img
className="w-88" className="w-88"
src={path + 'front.jpg'} src={`${path}front.jpg?${Date.now()}`}
alt="Failure getting photo" alt="Failure getting photo"
/> />
<P></P> <P></P>

View file

@ -4,7 +4,8 @@ import React, { memo } from 'react'
const TableRow = memo( const TableRow = memo(
({ className, children, header, error, success, size = 'sm', ...props }) => { ({ className, children, header, error, success, size = 'sm', ...props }) => {
const classnamesObj = { const classnamesObj = {
'p-1 h-12 bg-white': !header, 'p-1 bg-white': !header,
'h-12': !header && size !== 'sm' && size !== 'lg',
'h-8': !header && size === 'sm', 'h-8': !header && size === 'sm',
'h-9 font-bold text-base ': !header && size === 'lg', 'h-9 font-bold text-base ': !header && size === 'lg',
'bg-misty-rose': error, 'bg-misty-rose': error,

View file

@ -163,7 +163,7 @@ const Analytics = () => {
const convertFiatToLocale = item => { const convertFiatToLocale = item => {
if (item.fiatCode === fiatLocale) return item if (item.fiatCode === fiatLocale) return item
const itemRate = R.find(R.propEq('code', item.fiatCode))(rates) const itemRate = R.find(R.propEq(item.fiatCode, 'code'))(rates)
const localeRate = R.find(R.propEq('code', fiatLocale))(rates) const localeRate = R.find(R.propEq('code', fiatLocale))(rates)
const multiplier = localeRate?.rate / itemRate?.rate const multiplier = localeRate?.rate / itemRate?.rate
return { ...item, fiat: parseFloat(item.fiat) * multiplier } return { ...item, fiat: parseFloat(item.fiat) * multiplier }

View file

@ -139,7 +139,7 @@ const CashOut = ({ name: SCREEN_KEY }) => {
{R.isEmpty(machines) && <EmptyTable message="No machines so far" />} {R.isEmpty(machines) && <EmptyTable message="No machines so far" />}
{wizard && ( {wizard && (
<Wizard <Wizard
machine={R.find(R.propEq('deviceId', wizard))(machines)} machine={R.find(R.propEq(wizard, 'deviceId'))(machines)}
onClose={() => setWizard(false)} onClose={() => setWizard(false)}
save={save} save={save}
error={error?.message} error={error?.message}

View file

@ -39,7 +39,7 @@ const Wizard = ({ machine, locale, onClose, save, error }) => {
) )
} }
const newConfig = R.merge(config, it) const newConfig = R.mergeRight(config, it)
setState({ setState({
step: step + 1, step: step + 1,

View file

@ -42,7 +42,7 @@ const CommissionsDetails = memo(
initialValues={commission} initialValues={commission}
save={save} save={save}
validationSchema={getSchema(locale)} validationSchema={getSchema(locale)}
data={R.of(commission)} data={R.of(Array, commission)}
elements={mainFields(currency)} elements={mainFields(currency)}
setEditing={onEditingDefault} setEditing={onEditingDefault}
forceDisable={isEditingOverrides} forceDisable={isEditingOverrides}

View file

@ -50,7 +50,7 @@ const getElement = (code, display) => ({
const sortCommissionsBy = prop => { const sortCommissionsBy = prop => {
switch (prop) { switch (prop) {
case ORDER_OPTIONS[0]: case ORDER_OPTIONS[0]:
return R.sortBy(R.find(R.propEq('code', R.prop('machine')))) return R.sortBy(R.find(R.propEq(R.prop('machine'), 'code')))
case ORDER_OPTIONS[1]: case ORDER_OPTIONS[1]:
return R.sortBy(R.path(['cryptoCurrencies', 0])) return R.sortBy(R.path(['cryptoCurrencies', 0]))
default: default:
@ -80,7 +80,7 @@ const CommissionsList = memo(
const getMachineCoins = deviceId => { const getMachineCoins = deviceId => {
const override = R.prop('overrides', localeConfig)?.find( const override = R.prop('overrides', localeConfig)?.find(
R.propEq('machine', deviceId), R.propEq(deviceId, 'machine'),
) )
const machineCoins = override const machineCoins = override

View file

@ -42,7 +42,7 @@ const getView = (data, code, compare) => it => {
if (!data) return '' if (!data) return ''
// The following boolean should come undefined if it is rendering an unpaired machine // The following boolean should come undefined if it is rendering an unpaired machine
const attribute = R.find(R.propEq(compare ?? 'code', it))(data) const attribute = R.find(R.propEq(it, compare ?? 'code'))(data)
return attribute ? R.prop(code, attribute) : 'Unpaired machine' return attribute ? R.prop(code, attribute) : 'Unpaired machine'
} }
@ -294,8 +294,8 @@ const getAlreadyUsed = (id, machine, values) => {
const getCrypto = R.prop('cryptoCurrencies') const getCrypto = R.prop('cryptoCurrencies')
const getMachineId = R.prop('machine') const getMachineId = R.prop('machine')
const filteredOverrides = R.filter(R.propEq('machine', machine))(values) const filteredOverrides = R.filter(R.propEq(machine, 'machine'))(values)
const originalValue = R.find(R.propEq('id', id))(values) const originalValue = R.find(R.propEq(id, 'id'))(values)
const originalCryptos = getCrypto(originalValue) const originalCryptos = getCrypto(originalValue)
const originalMachineId = getMachineId(originalValue) const originalMachineId = getMachineId(originalValue)
@ -407,7 +407,7 @@ const overridesDefaults = {
const getOrder = ({ machine, cryptoCurrencies }) => { const getOrder = ({ machine, cryptoCurrencies }) => {
const isAllMachines = machine === ALL_MACHINES.deviceId const isAllMachines = machine === ALL_MACHINES.deviceId
const isAllCoins = R.contains(ALL_COINS.code, cryptoCurrencies) const isAllCoins = R.includes(ALL_COINS.code, cryptoCurrencies)
if (isAllMachines && isAllCoins) return 0 if (isAllMachines && isAllCoins) return 0
if (isAllMachines) return 1 if (isAllMachines) return 1

View file

@ -144,7 +144,7 @@ const CustomerData = ({
deleteEditedData: () => deleteEditedData({ idCardData: null }), deleteEditedData: () => deleteEditedData({ idCardData: null }),
save: values => save: values =>
editCustomer({ editCustomer({
idCardData: R.merge(idData, formatDates(values)), idCardData: R.mergeRight(idData, formatDates(values)),
}), }),
validationSchema: customerDataSchemas.idCardData, validationSchema: customerDataSchemas.idCardData,
checkAgainstSanctions: () => checkAgainstSanctions: () =>
@ -167,7 +167,7 @@ const CustomerData = ({
save: values => { save: values => {
editCustomer({ editCustomer({
subscriberInfo: { subscriberInfo: {
result: R.merge(smsData, R.omit(['phoneNumber'])(values)), result: R.mergeRight(smsData, R.omit(['phoneNumber'])(values)),
}, },
}) })
}, },

View file

@ -2,8 +2,6 @@ import { useQuery, useMutation, gql } from '@apollo/client'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import { useLocation } from 'wouter' import { useLocation } from 'wouter'
import SearchBox from '../../components/SearchBox'
import SearchFilter from '../../components/SearchFilter'
import TitleSection from '../../components/layout/TitleSection' import TitleSection from '../../components/layout/TitleSection'
import TxInIcon from '../../styling/icons/direction/cash-in.svg?react' import TxInIcon from '../../styling/icons/direction/cash-in.svg?react'
import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react' import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react'
@ -15,15 +13,6 @@ import CustomersList from './CustomersList'
import CreateCustomerModal from './components/CreateCustomerModal' import CreateCustomerModal from './components/CreateCustomerModal'
import { getAuthorizedStatus } from './helper' import { getAuthorizedStatus } from './helper'
const GET_CUSTOMER_FILTERS = gql`
query filters {
customerFilters {
type
value
}
}
`
const GET_CUSTOMERS = gql` const GET_CUSTOMERS = gql`
query configAndCustomers( query configAndCustomers(
$phone: String $phone: String
@ -91,38 +80,27 @@ const CREATE_CUSTOMER = gql`
} }
` `
const getFiltersObj = filters =>
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
const Customers = () => { const Customers = () => {
const [, navigate] = useLocation() const [, navigate] = useLocation()
const handleCustomerClicked = customer => const handleCustomerClicked = customer =>
navigate(`/compliance/customer/${customer.id}`) navigate(`/compliance/customer/${customer.id}`)
const [filteredCustomers, setFilteredCustomers] = useState([]) const [customers, setCustomers] = useState([])
const [variables, setVariables] = useState({})
const [filters, setFilters] = useState([])
const [showCreationModal, setShowCreationModal] = useState(false) const [showCreationModal, setShowCreationModal] = useState(false)
const { const { data: customersResponse, loading: customerLoading } = useQuery(
data: customersResponse, GET_CUSTOMERS,
loading: customerLoading, {
refetch, onCompleted: data => setCustomers(R.path(['customers'])(data)),
} = useQuery(GET_CUSTOMERS, { },
variables, )
onCompleted: data => setFilteredCustomers(R.path(['customers'])(data)),
})
const { data: filtersResponse, loading: loadingFilters } =
useQuery(GET_CUSTOMER_FILTERS)
const [createNewCustomer] = useMutation(CREATE_CUSTOMER, { const [createNewCustomer] = useMutation(CREATE_CUSTOMER, {
onCompleted: () => setShowCreationModal(false), onCompleted: () => setShowCreationModal(false),
refetchQueries: () => [ refetchQueries: () => [
{ {
query: GET_CUSTOMERS, query: GET_CUSTOMERS,
variables,
}, },
], ],
}) })
@ -145,76 +123,12 @@ const Customers = () => {
const customersData = R.pipe( const customersData = R.pipe(
R.map(setAuthorizedStatus), R.map(setAuthorizedStatus),
R.sortWith([R.ascend(byAuthorized), R.descend(byLastActive)]), R.sortWith([R.ascend(byAuthorized), R.descend(byLastActive)]),
)(filteredCustomers ?? []) )(customers ?? [])
const onFilterChange = filters => {
const filtersObject = getFiltersObj(filters)
setFilters(filters)
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id,
})
refetch && refetch()
}
const onFilterDelete = filter => {
const newFilters = R.filter(
f => !R.whereEq(R.pick(['type', 'value'], f), filter),
)(filters)
setFilters(newFilters)
const filtersObject = getFiltersObj(newFilters)
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id,
})
refetch && refetch()
}
const deleteAllFilters = () => {
setFilters([])
const filtersObject = getFiltersObj([])
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id,
})
refetch && refetch()
}
const filterOptions = R.path(['customerFilters'])(filtersResponse)
return ( return (
<> <>
<TitleSection <TitleSection
title="Customers" title="Customers"
appendix={
<div className="flex ml-4">
<SearchBox
loading={loadingFilters}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search customers'}
onChange={onFilterChange}
/>
</div>
}
appendixRight={ appendixRight={
<div className="flex"> <div className="flex">
<Link color="primary" onClick={() => setShowCreationModal(true)}> <Link color="primary" onClick={() => setShowCreationModal(true)}>
@ -227,21 +141,11 @@ const Customers = () => {
{ label: 'Cash-out', icon: <TxOutIcon /> }, { label: 'Cash-out', icon: <TxOutIcon /> },
]} ]}
/> />
{filters.length > 0 && (
<SearchFilter
entries={customersData.length}
filters={filters}
onFilterDelete={onFilterDelete}
deleteAllFilters={deleteAllFilters}
/>
)}
<CustomersList <CustomersList
data={customersData} data={customersData}
locale={locale} country={locale?.country}
onClick={handleCustomerClicked} onClick={handleCustomerClicked}
loading={customerLoading} loading={customerLoading}
triggers={triggers}
customRequests={customRequirementsData}
/> />
<CreateCustomerModal <CreateCustomerModal
showModal={showCreationModal} showModal={showCreationModal}

View file

@ -1,78 +1,141 @@
import Visibility from '@mui/icons-material/Visibility'
import { format } from 'date-fns/fp' import { format } from 'date-fns/fp'
import * as R from 'ramda' import * as R from 'ramda'
import React from 'react' import React, { useMemo } from 'react'
import {
MaterialReactTable,
MRT_ActionMenuItem,
useMaterialReactTable,
} from 'material-react-table'
import { MainStatus } from '../../components/Status' import { MainStatus } from '../../components/Status'
import DataTable from '../../components/tables/DataTable'
import TxInIcon from '../../styling/icons/direction/cash-in.svg?react' import TxInIcon from '../../styling/icons/direction/cash-in.svg?react'
import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react' import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react'
import {
defaultMaterialTableOpts,
alignRight,
} from '../../utils/materialReactTableOpts'
import { getFormattedPhone, getName } from './helper' import { getFormattedPhone, getName } from './helper'
const CustomersList = ({ data, locale, onClick, loading }) => { const CustomersList = ({ data, country, onClick, loading }) => {
const elements = [ const columns = useMemo(
{ () => [
header: 'Phone/email', {
width: 199, accessorKey: 'id',
view: it => `${getFormattedPhone(it.phone, locale.country) || ''} header: 'ID',
${it.email || ''}`, size: 315,
},
{
header: 'Name',
width: 241,
view: getName,
},
{
header: 'Total Txs',
width: 126,
textAlign: 'right',
view: it => `${Number.parseInt(it.totalTxs)}`,
},
{
header: 'Total spent',
width: 152,
textAlign: 'right',
view: it =>
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`,
},
{
header: 'Last active',
width: 133,
view: it =>
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? '',
},
{
header: 'Last transaction',
width: 161,
textAlign: 'right',
view: it => {
const hasLastTx = !R.isNil(it.lastTxFiatCode)
const LastTxIcon = it.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
const lastIcon = <LastTxIcon className="ml-3" />
return (
<>
{hasLastTx &&
`${parseFloat(it.lastTxFiat)} ${it.lastTxFiatCode ?? ''}`}
{hasLastTx && lastIcon}
</>
)
}, },
{
id: 'phone-email',
accessorFn: it =>
`${getFormattedPhone(it.phone, country) || ''} ${it.email || ''}`,
size: 180,
header: 'Phone/email',
},
{
id: 'name',
header: 'Name',
accessorFn: getName,
},
{
accessorKey: 'totalTxs',
header: 'Total txs',
size: 126,
enableColumnFilter: false,
...alignRight,
},
{
accessorKey: 'totalSpent',
size: 152,
enableColumnFilter: false,
Cell: ({ cell, row }) =>
`${Number.parseFloat(cell.getValue())} ${row.original.lastTxFiatCode ?? ''}`,
header: 'Total spent',
...alignRight,
},
{
header: 'Last transaction',
...alignRight,
size: 170,
enableColumnFilter: false,
accessorKey: 'lastTxFiat',
Cell: ({ cell, row }) => {
const hasLastTx = !R.isNil(row.original.lastTxFiatCode)
const LastTxIcon =
row.original.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
const lastIcon = <LastTxIcon className="ml-3" />
return (
<>
{hasLastTx &&
`${parseFloat(cell.getValue())} ${row.original.lastTxFiatCode ?? ''}`}
{hasLastTx && lastIcon}
</>
)
},
},
{
accessorKey: 'lastActive',
header: 'Last active',
size: 133,
enableColumnFilter: false,
Cell: ({ cell }) =>
(cell.getValue() &&
format('yyyy-MM-dd', new Date(cell.getValue()))) ??
'',
},
{
header: 'Status',
size: 150,
enableColumnFilter: false,
accessorKey: 'authorizedStatus',
sortingFn: (rowA, rowB) => {
const statusOrder = { success: 0, warning: 1, error: 2 }
const statusA = rowA.original.authorizedStatus.type
const statusB = rowB.original.authorizedStatus.type
if (statusA === statusB) {
return rowA.original.authorizedStatus.label.localeCompare(
rowB.original.authorizedStatus.label,
)
}
return statusOrder[statusA] - statusOrder[statusB]
},
Cell: ({ cell }) => <MainStatus statuses={[cell.getValue()]} />,
},
],
[],
)
const table = useMaterialReactTable({
...defaultMaterialTableOpts,
columns: columns,
data,
initialState: {
...defaultMaterialTableOpts.initialState,
columnVisibility: {
id: false,
},
sorting: [{ id: 'lastActive', desc: true }],
columnPinning: { right: ['mrt-row-actions'] },
}, },
{ state: { isLoading: loading },
header: 'Status', getRowId: it => it.id,
width: 191, enableRowActions: true,
view: it => <MainStatus statuses={[it.authorizedStatus]} />, renderRowActionMenuItems: ({ row }) => [
}, <MRT_ActionMenuItem //or just use a normal MUI MenuItem component
] icon={<Visibility />}
key="view"
label="View"
onClick={() => onClick(row)}
table={table}
/>,
],
})
return ( return (
<> <>
<DataTable <MaterialReactTable table={table} />
loading={loading}
emptyText="No customers so far"
elements={elements}
data={data}
onClick={onClick}
/>
</> </>
) )
} }

View file

@ -45,43 +45,28 @@ const getAuthorizedStatus = (it, triggers, customRequests) => {
) )
} }
const pendingFieldStatus = R.map(ite => { const getFieldsByStatus = status =>
if (isManualField(ite)) { R.map(ite => {
if (uuidValidate(ite)) { if (isManualField(ite)) {
const request = R.find( if (uuidValidate(ite)) {
iter => iter.infoRequestId === ite, const request = R.find(
it.customInfoRequests, iter => iter.infoRequestId === ite,
) it.customInfoRequests,
return !R.isNil(request) && R.equals(request.override, 'automatic') )
return !R.isNil(request) && R.equals(request.override, status)
}
const regularFieldValue = R.includes(ite, fieldsWithPathSuffix)
? it[`${ite}Path`]
: it[`${ite}`]
if (R.isNil(regularFieldValue)) return false
return R.equals(it[`${ite}Override`], status)
} }
return false
}, fields)
const regularFieldValue = R.includes(ite, fieldsWithPathSuffix) const pendingFieldStatus = getFieldsByStatus('automatic')
? it[`${ite}Path`] const rejectedFieldStatus = getFieldsByStatus('blocked')
: it[`${ite}`]
if (R.isNil(regularFieldValue)) return false
return R.equals(it[`${ite}Override`], 'automatic')
}
return false
}, fields)
const rejectedFieldStatus = R.map(ite => {
if (isManualField(ite)) {
if (uuidValidate(ite)) {
const request = R.find(
iter => iter.infoRequestId === ite,
it.customInfoRequests,
)
return !R.isNil(request) && R.equals(request.override, 'blocked')
}
const regularFieldValue = R.includes(ite, fieldsWithPathSuffix)
? it[`${ite}Path`]
: it[`${ite}`]
if (R.isNil(regularFieldValue)) return false
return R.equals(it[`${ite}Override`], 'blocked')
}
return false
}, fields)
if (it.authorizedOverride === CUSTOMER_BLOCKED) if (it.authorizedOverride === CUSTOMER_BLOCKED)
return { label: 'Blocked', type: 'error' } return { label: 'Blocked', type: 'error' }
@ -235,7 +220,7 @@ const ManualDataEntry = ({ selectedValues, customInfoRequirementOptions }) => {
: requirementOptions : requirementOptions
const requirementName = displayRequirements const requirementName = displayRequirements
? R.find(R.propEq('code', requirementSelected))(updatedRequirementOptions) ? R.find(R.propEq(requirementSelected, 'code'))(updatedRequirementOptions)
.display .display
: '' : ''

View file

@ -0,0 +1,323 @@
import { describe, it, expect } from 'vitest'
import { getAuthorizedStatus } from './helper'
describe('getAuthorizedStatus', () => {
const mockTriggers = {
automation: 'automatic',
overrides: [],
}
const mockCustomRequests = [{ id: 'custom-req-1' }, { id: 'custom-req-2' }]
it('should return blocked status when authorizedOverride is blocked', () => {
const customer = {
authorizedOverride: 'blocked',
}
const result = getAuthorizedStatus(
customer,
mockTriggers,
mockCustomRequests,
)
expect(result).toEqual({
label: 'Blocked',
type: 'error',
})
})
it('should return suspension status when customer is suspended with days > 0', () => {
const customer = {
authorizedOverride: null,
isSuspended: true,
daysSuspended: 5,
}
const result = getAuthorizedStatus(
customer,
mockTriggers,
mockCustomRequests,
)
expect(result).toEqual({
label: '5 day suspension',
type: 'warning',
})
})
it('should return short suspension status when customer is suspended with days <= 0', () => {
const customer = {
authorizedOverride: null,
isSuspended: true,
daysSuspended: 0,
}
const result = getAuthorizedStatus(
customer,
mockTriggers,
mockCustomRequests,
)
expect(result).toEqual({
label: '< 1 day suspension',
type: 'warning',
})
})
it('should return rejected status when any field has blocked override', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
emailOverride: 'blocked',
email: 'test@example.com',
}
const triggers = {
automation: 'manual',
overrides: [],
}
const result = getAuthorizedStatus(customer, triggers, mockCustomRequests)
expect(result).toEqual({
label: 'Rejected',
type: 'error',
})
})
it('should return pending status when any field has automatic override', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
emailOverride: 'automatic',
email: 'test@example.com',
}
const triggers = {
automation: 'manual',
overrides: [],
}
const result = getAuthorizedStatus(customer, triggers, mockCustomRequests)
expect(result).toEqual({
label: 'Pending',
type: 'warning',
})
})
it('should return authorized status when no blocking conditions exist', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
}
const result = getAuthorizedStatus(
customer,
mockTriggers,
mockCustomRequests,
)
expect(result).toEqual({
label: 'Authorized',
type: 'success',
})
})
it('should handle customers with idCardData', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
idCardData: { firstName: 'John', lastName: 'Doe' },
idCardDataOverride: 'automatic',
}
const triggers = {
automation: 'manual',
overrides: [],
}
const result = getAuthorizedStatus(customer, triggers, mockCustomRequests)
expect(result).toEqual({
label: 'Pending',
type: 'warning',
})
})
it('should handle customers with photo fields using path suffix', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
frontCameraPath: '/path/to/photo.jpg',
frontCameraOverride: 'blocked',
}
const triggers = {
automation: 'manual',
overrides: [],
}
const result = getAuthorizedStatus(customer, triggers, mockCustomRequests)
expect(result).toEqual({
label: 'Rejected',
type: 'error',
})
})
it('should handle custom info requests with UUID validation', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
customInfoRequests: [
{
infoRequestId: '550e8400-e29b-41d4-a716-446655440000',
override: 'automatic',
},
],
}
const triggers = {
automation: 'manual',
overrides: [],
}
const customRequests = [{ id: '550e8400-e29b-41d4-a716-446655440000' }]
const result = getAuthorizedStatus(customer, triggers, customRequests)
expect(result).toEqual({
label: 'Pending',
type: 'warning',
})
})
it('should handle manual overrides for specific requirements', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
frontCameraPath: '/path/to/photo.jpg',
frontCameraOverride: 'automatic',
}
const triggers = {
automation: 'automatic',
overrides: [
{
requirement: 'facephoto',
automation: 'manual',
},
],
}
const result = getAuthorizedStatus(customer, triggers, mockCustomRequests)
expect(result).toEqual({
label: 'Pending',
type: 'warning',
})
})
it('should handle null or undefined triggers gracefully', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
}
const triggers = {
automation: 'automatic',
overrides: [],
}
const result = getAuthorizedStatus(customer, triggers, mockCustomRequests)
expect(result).toEqual({
label: 'Authorized',
type: 'success',
})
})
it('should handle empty custom requests array', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
}
const result = getAuthorizedStatus(customer, mockTriggers, [])
expect(result).toEqual({
label: 'Authorized',
type: 'success',
})
})
it('should prioritize blocked status over suspension', () => {
const customer = {
authorizedOverride: 'blocked',
isSuspended: true,
daysSuspended: 5,
}
const result = getAuthorizedStatus(
customer,
mockTriggers,
mockCustomRequests,
)
expect(result).toEqual({
label: 'Blocked',
type: 'error',
})
})
it('should prioritize rejection over pending status', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
emailOverride: 'blocked',
email: 'test@example.com',
usSsnOverride: 'automatic',
usSsn: '123-45-6789',
}
const triggers = {
automation: 'manual',
overrides: [],
}
const result = getAuthorizedStatus(customer, triggers, mockCustomRequests)
expect(result).toEqual({
label: 'Rejected',
type: 'error',
})
})
it('should return rejected status for blocked custom info request', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
customInfoRequests: [
{
infoRequestId: '550e8400-e29b-41d4-a716-446655440000',
override: 'blocked',
},
],
}
const triggers = {
automation: 'manual',
overrides: [],
}
const customRequests = [{ id: '550e8400-e29b-41d4-a716-446655440000' }]
const result = getAuthorizedStatus(customer, triggers, customRequests)
expect(result).toEqual({
label: 'Rejected',
type: 'error',
})
})
})

View file

@ -1,6 +1,5 @@
import { useQuery, gql } from '@apollo/client' import { useQuery, gql } from '@apollo/client'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Grid from '@mui/material/Grid'
import classnames from 'classnames' import classnames from 'classnames'
import * as R from 'ramda' import * as R from 'ramda'
import React from 'react' import React from 'react'
@ -56,11 +55,9 @@ const Alerts = ({ onReset, onExpand, size }) => {
</Label1> </Label1>
)} )}
</div> </div>
<Grid <div
className={classnames({ 'm-0': true, 'max-h-115': showAllItems })} className={classnames({ 'm-0 mt-2': true, 'max-h-115': showAllItems })}>
container <div className="w-full flex-1">
spacing={1}>
<Grid item xs={12}>
{!alerts.length && ( {!alerts.length && (
<Label1 className="text-comet -ml-1 h-30"> <Label1 className="text-comet -ml-1 h-30">
No new alerts. Your system is running smoothly. No new alerts. Your system is running smoothly.
@ -71,10 +68,10 @@ const Alerts = ({ onReset, onExpand, size }) => {
alerts={alerts} alerts={alerts}
machines={machines} machines={machines}
/> />
</Grid> </div>
</Grid> </div>
{!showAllItems && alertsLength > NUM_TO_RENDER && ( {!showAllItems && alertsLength > NUM_TO_RENDER && (
<Grid item xs={12}> <div>
<Label1 className="text-center mb-0"> <Label1 className="text-center mb-0">
<Button <Button
onClick={() => onExpand('alerts')} onClick={() => onExpand('alerts')}
@ -85,7 +82,7 @@ const Alerts = ({ onReset, onExpand, size }) => {
{`Show all (${alerts.length})`} {`Show all (${alerts.length})`}
</Button> </Button>
</Label1> </Label1>
</Grid> </div>
)} )}
</> </>
) )

View file

@ -59,7 +59,7 @@ const Dashboard = () => {
</TitleSection> </TitleSection>
<div className="flex mb-30 gap-4"> <div className="flex mb-30 gap-4">
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<Paper className="p-6"> <Paper className="p-6 flex-1">
<SystemPerformance /> <SystemPerformance />
</Paper> </Paper>
</div> </div>

View file

@ -40,9 +40,9 @@ const Footer = () => {
const localeFiatCurrency = R.path(['locale_fiatCurrency'])(config) ?? '' const localeFiatCurrency = R.path(['locale_fiatCurrency'])(config) ?? ''
const renderFooterItem = key => { const renderFooterItem = key => {
const idx = R.findIndex(R.propEq('code', key))(cryptoCurrencies) const idx = R.findIndex(R.propEq(key, 'code'))(cryptoCurrencies)
const tickerCode = wallets[`${key}_ticker`] const tickerCode = wallets[`${key}_ticker`]
const tickerIdx = R.findIndex(R.propEq('code', tickerCode))(accountsConfig) const tickerIdx = R.findIndex(R.propEq(tickerCode, 'code'))(accountsConfig)
const tickerName = tickerIdx > -1 ? accountsConfig[tickerIdx].display : '' const tickerName = tickerIdx > -1 ? accountsConfig[tickerIdx].display : ''

View file

@ -1,7 +1,6 @@
import { useQuery, gql } from '@apollo/client' import { useQuery, gql } from '@apollo/client'
import BigNumber from 'bignumber.js' import BigNumber from 'bignumber.js'
import classnames from 'classnames' import classnames from 'classnames'
import { isAfter } from 'date-fns/fp'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import { Info2, Label1, Label2, P } from '../../../components/typography/index' import { Info2, Label1, Label2, P } from '../../../components/typography/index'
@ -14,7 +13,6 @@ import { java, neon } from '../../../styling/variables'
import { fromNamespace } from '../../../utils/config' import { fromNamespace } from '../../../utils/config'
import { DAY, WEEK, MONTH } from '../../../utils/time' import { DAY, WEEK, MONTH } from '../../../utils/time'
import { timezones } from '../../../utils/timezone-list' import { timezones } from '../../../utils/timezone-list'
import { toTimezone } from '../../../utils/timezones'
import PercentageChart from './Graphs/PercentageChart' import PercentageChart from './Graphs/PercentageChart'
import LineChart from './Graphs/RefLineChart' import LineChart from './Graphs/RefLineChart'
@ -27,8 +25,11 @@ BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_HALF_UP })
const getFiats = R.map(R.prop('fiat')) const getFiats = R.map(R.prop('fiat'))
const GET_DATA = gql` const GET_DATA = gql`
query getData($excludeTestingCustomers: Boolean) { query getData($excludeTestingCustomers: Boolean, $from: DateTimeISO) {
transactions(excludeTestingCustomers: $excludeTestingCustomers) { transactions(
excludeTestingCustomers: $excludeTestingCustomers
from: $from
) {
fiatCode fiatCode
fiat fiat
fixedFee fixedFee
@ -49,10 +50,17 @@ const GET_DATA = gql`
} }
` `
const twoMonthsAgo = new Date()
twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2)
const SystemPerformance = () => { const SystemPerformance = () => {
const [selectedRange, setSelectedRange] = useState('Day') const [selectedRange, setSelectedRange] = useState('Day')
const { data, loading } = useQuery(GET_DATA, { const { data, loading } = useQuery(GET_DATA, {
variables: { excludeTestingCustomers: true }, variables: {
excludeTestingCustomers: true,
from: twoMonthsAgo.toISOString(),
},
}) })
const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency
const timezone = fromNamespace('locale')(data?.config).timezone const timezone = fromNamespace('locale')(data?.config).timezone
@ -69,38 +77,41 @@ const SystemPerformance = () => {
if (t.error !== null) return false if (t.error !== null) return false
if (t.txClass === 'cashOut' && !t.dispense) return false if (t.txClass === 'cashOut' && !t.dispense) return false
if (t.txClass === 'cashIn' && !t.sendConfirmed) return false if (t.txClass === 'cashIn' && !t.sendConfirmed) return false
if (!getLastTimePeriod) {
const createdTimestamp = new Date(t.created).getTime()
const [rangeStart, rangeEnd] = periodDomains[selectedRange]
if (getLastTimePeriod) {
const duration = rangeEnd - rangeStart
return ( return (
t.error === null && t.error === null &&
isAfter( createdTimestamp >= rangeStart - duration &&
toTimezone(t.created, timezone), createdTimestamp < rangeStart
toTimezone(periodDomains[selectedRange][1], timezone),
) &&
isAfter(
toTimezone(periodDomains[selectedRange][0], timezone),
toTimezone(t.created, timezone),
)
) )
} }
return ( return (
t.error === null && t.error === null &&
isAfter( createdTimestamp >= rangeStart &&
toTimezone(periodDomains[selectedRange][1], timezone), createdTimestamp <= rangeEnd
toTimezone(t.created, timezone),
) &&
isAfter(
toTimezone(t.created, timezone),
toTimezone(periodDomains[selectedRange][0], timezone),
)
) )
} }
const convertFiatToLocale = item => { const convertFiatToLocale = item => {
if (item.fiatCode === fiatLocale) return item if (item.fiatCode === fiatLocale)
const itemRate = R.find(R.propEq('code', item.fiatCode))(data.fiatRates) return {
const localeRate = R.find(R.propEq('code', fiatLocale))(data.fiatRates) ...item,
fiat: parseFloat(item.fiat),
profit: parseFloat(item.profit),
}
const itemRate = R.find(R.propEq(item.fiatCode, 'code'))(data.fiatRates)
const localeRate = R.find(R.propEq(fiatLocale, 'code'))(data.fiatRates)
const multiplier = localeRate.rate / itemRate.rate const multiplier = localeRate.rate / itemRate.rate
return { ...item, fiat: parseFloat(item.fiat) * multiplier } return {
...item,
fiat: parseFloat(item.fiat) * multiplier,
profit: parseFloat(item.profit) * multiplier,
}
} }
const transactionsToShow = R.map(convertFiatToLocale)( const transactionsToShow = R.map(convertFiatToLocale)(
@ -140,7 +151,7 @@ const SystemPerformance = () => {
} }
const getDirectionPercent = () => { const getDirectionPercent = () => {
const [cashIn, cashOut] = R.partition(R.propEq('txClass', 'cashIn'))( const [cashIn, cashOut] = R.partition(R.propEq('cashIn', 'txClass'))(
transactionsToShow, transactionsToShow,
) )
const totalLength = cashIn.length + cashOut.length const totalLength = cashIn.length + cashOut.length
@ -177,7 +188,10 @@ const SystemPerformance = () => {
handleSetRange={setSelectedRange} handleSetRange={setSelectedRange}
/> />
{!loading && R.isEmpty(data.transactions) && ( {!loading && R.isEmpty(data.transactions) && (
<EmptyTable className="pt-10" message="No transactions so far" /> <EmptyTable
className="pt-10"
message="No transactions during the last month"
/>
)} )}
{!loading && !R.isEmpty(data.transactions) && ( {!loading && !R.isEmpty(data.transactions) && (
<div className="flex flex-col gap-12"> <div className="flex flex-col gap-12">

View file

@ -207,7 +207,7 @@ const Locales = ({ name: SCREEN_KEY }) => {
initialValues={locale} initialValues={locale}
save={handleSave} save={handleSave}
validationSchema={LocaleSchema} validationSchema={LocaleSchema}
data={R.of(locale)} data={R.of(Array, locale)}
elements={mainFields(data, onChangeCoin)} elements={mainFields(data, onChangeCoin)}
setEditing={onEditingDefault} setEditing={onEditingDefault}
forceDisable={isEditingOverrides} forceDisable={isEditingOverrides}
@ -238,7 +238,7 @@ const Locales = ({ name: SCREEN_KEY }) => {
{wizard && ( {wizard && (
<Wizard <Wizard
schemas={schemas} schemas={schemas}
coin={R.find(R.propEq('code', wizard))(cryptoCurrencies)} coin={R.find(R.propEq(wizard, 'code'))(cryptoCurrencies)}
onClose={() => setWizard(false)} onClose={() => setWizard(false)}
save={wizardSave} save={wizardSave}
error={error?.message} error={error?.message}

View file

@ -17,7 +17,7 @@ const allFields = (getData, onChange, auxElements = []) => {
return R.compose( return R.compose(
it => `${R.prop(code)(it)} ${it?.isBeta ? '(Beta)' : ''}`, it => `${R.prop(code)(it)} ${it?.isBeta ? '(Beta)' : ''}`,
R.find(R.propEq(compare ?? 'code', it)), R.find(R.propEq(it, compare ?? 'code')),
)(data) )(data)
} }
@ -45,7 +45,7 @@ const allFields = (getData, onChange, auxElements = []) => {
const timezonesData = timezoneList const timezonesData = timezoneList
const findSuggestion = it => { const findSuggestion = it => {
const machine = R.find(R.propEq('deviceId', it.machine))(machineData) const machine = R.find(R.propEq(it.machine, 'deviceId'))(machineData)
return machine ? [machine] : [] return machine ? [machine] : []
} }

View file

@ -52,8 +52,8 @@ const Commissions = ({ name: SCREEN_KEY, id: deviceId }) => {
const overrides = config.overrides const overrides = config.overrides
? R.concat( ? R.concat(
R.filter(R.propEq('machine', 'ALL_MACHINES'), config.overrides), R.filter(R.propEq('ALL_MACHINES', 'machine'), config.overrides),
R.filter(R.propEq('machine', deviceId), config.overrides), R.filter(R.propEq(deviceId, 'machine'), config.overrides),
) )
: [] : []

View file

@ -100,7 +100,7 @@ const Machines = ({ data, refetch, reload }) => {
const machineID = R.path(['deviceId'])(machine) ?? null const machineID = R.path(['deviceId'])(machine) ?? null
return ( return (
<div className="flex flex-1 h-full gap-12"> <div className="flex flex-1 h-full gap-12 mb-12">
<div className="basis-1/4 min-w-1/4 pt-8"> <div className="basis-1/4 min-w-1/4 pt-8">
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" />}> <Breadcrumbs separator={<NavigateNextIcon fontSize="small" />}>
<Link to="/dashboard" className="no-underline"> <Link to="/dashboard" className="no-underline">

View file

@ -296,7 +296,7 @@ const CashCassettes = () => {
/> />
{wizard && ( {wizard && (
<Wizard <Wizard
machine={R.find(R.propEq('id', machineId), machines)} machine={R.find(R.propEq(machineId, 'id'), machines)}
cashoutSettings={getCashoutSettings(machineId)} cashoutSettings={getCashoutSettings(machineId)}
onClose={() => { onClose={() => {
setWizard(false) setWizard(false)

View file

@ -78,7 +78,7 @@ const CashboxHistory = ({ machines, currency, timezone }) => {
textAlign: 'left', textAlign: 'left',
view: R.pipe( view: R.pipe(
R.prop('deviceId'), R.prop('deviceId'),
id => R.find(R.propEq('id', id), machines), id => R.find(R.propEq(id, 'id'), machines),
R.defaultTo({ name: <i>Unpaired device</i> }), R.defaultTo({ name: <i>Unpaired device</i> }),
R.prop('name'), R.prop('name'),
), ),

View file

@ -112,7 +112,7 @@ const MachineStatus = () => {
] ]
const machines = R.path(['machines'])(machinesResponse) ?? [] const machines = R.path(['machines'])(machinesResponse) ?? []
const expandedIndex = R.findIndex(R.propEq('deviceId', addedMachineId))( const expandedIndex = R.findIndex(R.propEq(addedMachineId, 'deviceId'))(
machines, machines,
) )

View file

@ -56,7 +56,7 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
R.pipe(R.pickAll(fields), R.map(defaultToZero))(cassetteInput) R.pipe(R.pickAll(fields), R.map(defaultToZero))(cassetteInput)
const onContinue = it => { const onContinue = it => {
const newConfig = R.merge(config, it) const newConfig = R.mergeRight(config, it)
if (isLastStep) { if (isLastStep) {
const wasCashboxEmptied = [ const wasCashboxEmptied = [
config?.wasCashboxEmptied, config?.wasCashboxEmptied,
@ -158,7 +158,7 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
: {} : {}
const makeInitialValues = () => const makeInitialValues = () =>
R.merge(makeCassettesInitialValues(), makeRecyclersInitialValues()) R.mergeRight(makeCassettesInitialValues(), makeRecyclersInitialValues())
const steps = R.pipe( const steps = R.pipe(
R.concat( R.concat(

View file

@ -37,12 +37,12 @@ const CryptoBalanceOverrides = ({ section }) => {
const overriddenCryptos = R.map(R.prop(CRYPTOCURRENCY_KEY))(setupValues) const overriddenCryptos = R.map(R.prop(CRYPTOCURRENCY_KEY))(setupValues)
const suggestionFilter = R.filter( const suggestionFilter = R.filter(
it => !R.contains(it.code, overriddenCryptos), it => !R.includes(it.code, overriddenCryptos),
) )
const suggestions = suggestionFilter(cryptoCurrencies) const suggestions = suggestionFilter(cryptoCurrencies)
const findSuggestion = it => { const findSuggestion = it => {
const coin = R.compose(R.find(R.propEq('code', it?.cryptoCurrency)))( const coin = R.compose(R.find(R.propEq(it?.cryptoCurrency, 'code')))(
cryptoCurrencies, cryptoCurrencies,
) )
return coin ? [coin] : [] return coin ? [coin] : []
@ -90,7 +90,7 @@ const CryptoBalanceOverrides = ({ section }) => {
const viewCrypto = it => const viewCrypto = it =>
R.compose( R.compose(
R.path(['display']), R.path(['display']),
R.find(R.propEq('code', it)), R.find(R.propEq(it, 'code')),
)(cryptoCurrencies) )(cryptoCurrencies)
const elements = [ const elements = [

View file

@ -54,7 +54,7 @@ const FiatBalanceOverrides = ({ config, section }) => {
) )
const findSuggestion = it => { const findSuggestion = it => {
const coin = R.find(R.propEq('deviceId', it?.machine), machines) const coin = R.find(R.propEq(it?.machine, 'deviceId'), machines)
return coin ? [coin] : [] return coin ? [coin] : []
} }
@ -127,7 +127,7 @@ const FiatBalanceOverrides = ({ config, section }) => {
) )
const viewMachine = it => const viewMachine = it =>
R.compose(R.path(['name']), R.find(R.propEq('deviceId', it)))(machines) R.compose(R.path(['name']), R.find(R.propEq(it, 'deviceId')))(machines)
const elements = R.concat( const elements = R.concat(
[ [

View file

@ -25,7 +25,7 @@ const ThirdPartyProvider = () => {
const getDisplayName = type => it => const getDisplayName = type => it =>
R.compose( R.compose(
R.prop('display'), R.prop('display'),
R.find(R.propEq('code', it)), R.find(R.propEq(it, 'code')),
)(filterOptions(type)) )(filterOptions(type))
const innerSave = async value => { const innerSave = async value => {
@ -73,7 +73,7 @@ const ThirdPartyProvider = () => {
<EditableTable <EditableTable
name="thirdParty" name="thirdParty"
initialValues={values} initialValues={values}
data={R.of(values)} data={R.of(Array, values)}
error={error?.message} error={error?.message}
enableEdit enableEdit
editWidth={174} editWidth={174}

View file

@ -131,7 +131,7 @@ const ContactInfo = ({ wizard }) => {
}, },
] ]
const findField = name => R.find(R.propEq('name', name))(fields) const findField = name => R.find(R.propEq(name, 'name'))(fields)
const findValue = name => findField(name).value const findValue = name => findField(name).value
const displayTextValue = value => value const displayTextValue = value => value

View file

@ -136,7 +136,7 @@ const TermsConditions = () => {
}, },
] ]
const findField = name => R.find(R.propEq('name', name))(fields) const findField = name => R.find(R.propEq(name, 'name'))(fields)
const findValue = name => findField(name).value const findValue = name => findField(name).value
const initialValues = { const initialValues = {

View file

@ -20,7 +20,7 @@ const FormRenderer = ({
R.map(({ code }) => ({ [code]: (value && value[code]) ?? '' })), R.map(({ code }) => ({ [code]: (value && value[code]) ?? '' })),
)(elements) )(elements)
const values = R.merge(initialValues, value) const values = R.mergeRight(initialValues, value)
const [saveError, setSaveError] = useState([]) const [saveError, setSaveError] = useState([])

View file

@ -83,7 +83,7 @@ const Services = () => {
const getAccounts = ({ elements, code }) => { const getAccounts = ({ elements, code }) => {
const account = accounts[code] const account = accounts[code]
const filterBySecretComponent = R.filter(R.propEq('component', SecretInput)) const filterBySecretComponent = R.filter(R.propEq(SecretInput, 'component'))
const mapToCode = R.map(R.prop(['code'])) const mapToCode = R.map(R.prop(['code']))
const passwordFields = R.compose( const passwordFields = R.compose(
mapToCode, mapToCode,

View file

@ -70,13 +70,13 @@ const Triggers = () => {
const enabledCustomInfoRequests = R.pipe( const enabledCustomInfoRequests = R.pipe(
R.path(['customInfoRequests']), R.path(['customInfoRequests']),
R.defaultTo([]), R.defaultTo([]),
R.filter(R.propEq('enabled', true)), R.filter(R.propEq(true, 'enabled')),
)(customInfoReqData) )(customInfoReqData)
const emailAuth = const emailAuth =
data?.config?.triggersConfig_customerAuthentication === 'EMAIL' data?.config?.triggersConfig_customerAuthentication === 'EMAIL'
const complianceServices = R.filter(R.propEq('class', 'compliance'))( const complianceServices = R.filter(R.propEq('compliance', 'class'))(
data?.accountsConfig || [], data?.accountsConfig || [],
) )
const triggers = fromServer(data?.config?.triggers ?? []) const triggers = fromServer(data?.config?.triggers ?? [])

View file

@ -207,7 +207,7 @@ const Wizard = ({
) )
const onContinue = async it => { const onContinue = async it => {
const newConfig = R.merge(config, stepOptions.schema.cast(it)) const newConfig = R.mergeRight(config, stepOptions.schema.cast(it))
if (isLastStep) { if (isLastStep) {
return save(newConfig) return save(newConfig)
@ -221,7 +221,7 @@ const Wizard = ({
const createErrorMessage = (errors, touched, values) => { const createErrorMessage = (errors, touched, values) => {
const triggerType = values?.triggerType const triggerType = values?.triggerType
const containsType = R.contains(triggerType) const containsType = R.includes(triggerType)
const isSuspend = values?.requirement?.requirement === 'suspend' const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom' const isCustom = values?.requirement?.requirement === 'custom'

View file

@ -49,7 +49,7 @@ const AdvancedTriggersSettings = memo(() => {
const customInfoRequests = const customInfoRequests =
R.path(['customInfoRequests'])(customInfoReqData) ?? [] R.path(['customInfoRequests'])(customInfoReqData) ?? []
const enabledCustomInfoRequests = R.filter(R.propEq('enabled', true))( const enabledCustomInfoRequests = R.filter(R.propEq(true, 'enabled'))(
customInfoRequests, customInfoRequests,
) )
@ -98,7 +98,7 @@ const AdvancedTriggersSettings = memo(() => {
initialValues={requirementsDefaults} initialValues={requirementsDefaults}
save={saveDefaults} save={saveDefaults}
validationSchema={defaultSchema} validationSchema={defaultSchema}
data={R.of(requirementsDefaults)} data={R.of(Array, requirementsDefaults)}
elements={getDefaultSettings()} elements={getDefaultSettings()}
setEditing={onEditingDefault} setEditing={onEditingDefault}
forceDisable={isEditingOverrides} forceDisable={isEditingOverrides}

View file

@ -23,7 +23,7 @@ const buildAdvancedRequirementOptions = customInfoRequests => {
const displayRequirement = (code, customInfoRequests) => { const displayRequirement = (code, customInfoRequests) => {
return R.prop( return R.prop(
'display', 'display',
R.find(R.propEq('code', code))( R.find(R.propEq(code, 'code'))(
buildAdvancedRequirementOptions(customInfoRequests), buildAdvancedRequirementOptions(customInfoRequests),
), ),
) )
@ -47,7 +47,7 @@ const getOverridesSchema = (values, customInfoRequests) => {
const { id, requirement } = this.parent const { id, requirement } = this.parent
// If we're editing, filter out the override being edited so that validation schemas don't enter in circular conflicts // If we're editing, filter out the override being edited so that validation schemas don't enter in circular conflicts
const _values = R.filter(it => it.id !== id, values) const _values = R.filter(it => it.id !== id, values)
if (R.find(R.propEq('requirement', requirement))(_values)) { if (R.find(R.propEq(requirement, 'requirement'))(_values)) {
return this.createError({ return this.createError({
message: `Requirement '${displayRequirement( message: `Requirement '${displayRequirement(
requirement, requirement,

View file

@ -161,7 +161,7 @@ const Type = ({ ...props }) => {
'text-tomato': errors.triggerType && touched.triggerType, 'text-tomato': errors.triggerType && touched.triggerType,
} }
const containsType = R.contains(values?.triggerType) const containsType = R.includes(values?.triggerType)
const isThresholdCurrencyEnabled = containsType(['txAmount', 'txVolume']) const isThresholdCurrencyEnabled = containsType(['txAmount', 'txVolume'])
const isTransactionAmountEnabled = containsType(['txVelocity']) const isTransactionAmountEnabled = containsType(['txVelocity'])
const isThresholdDaysEnabled = containsType(['txVolume', 'txVelocity']) const isThresholdDaysEnabled = containsType(['txVolume', 'txVelocity'])
@ -542,7 +542,7 @@ const requirements = (
const getView = (data, code, compare) => it => { const getView = (data, code, compare) => it => {
if (!data) return '' if (!data) return ''
return R.compose(R.prop(code), R.find(R.propEq(compare ?? 'code', it)))(data) return R.compose(R.prop(code), R.find(R.propEq(it, compare ?? 'code')))(data)
} }
const customReqIdMatches = customReqId => it => { const customReqIdMatches = customReqId => it => {

View file

@ -69,12 +69,12 @@ const AdvancedWallet = () => {
AdvancedWalletSettingsOverrides, AdvancedWalletSettingsOverrides,
) )
const suggestionFilter = R.filter( const suggestionFilter = R.filter(
it => !R.contains(it.code, overriddenCryptos), it => !R.includes(it.code, overriddenCryptos),
) )
const coinSuggestions = suggestionFilter(cryptoCurrencies) const coinSuggestions = suggestionFilter(cryptoCurrencies)
const findSuggestion = it => { const findSuggestion = it => {
const coin = R.compose(R.find(R.propEq('code', it?.cryptoCurrency)))( const coin = R.compose(R.find(R.propEq(it?.cryptoCurrency, 'code')))(
cryptoCurrencies, cryptoCurrencies,
) )
return coin ? [coin] : [] return coin ? [coin] : []
@ -85,13 +85,13 @@ const AdvancedWallet = () => {
<Section> <Section>
<EditableTable <EditableTable
name="wallets" name="wallets"
data={R.of(AdvancedWalletSettings)} data={R.of(Array, AdvancedWalletSettings)}
error={error?.message} error={error?.message}
enableEdit enableEdit
editWidth={174} editWidth={174}
save={save} save={save}
stripeWhen={it => !AdvancedWalletSchema.isValidSync(it)} stripeWhen={it => !AdvancedWalletSchema.isValidSync(it)}
inialValues={R.of(AdvancedWalletSettings)} inialValues={R.of(Array, AdvancedWalletSettings)}
validationSchema={AdvancedWalletSchema} validationSchema={AdvancedWalletSchema}
elements={getAdvancedWalletElements()} elements={getAdvancedWalletElements()}
setEditing={onEditingDefault} setEditing={onEditingDefault}

View file

@ -167,7 +167,7 @@ const Wallet = ({ name: SCREEN_KEY }) => {
/> />
{wizard && ( {wizard && (
<Wizard <Wizard
coin={R.find(R.propEq('code', wizard))(cryptoCurrencies)} coin={R.find(R.propEq(wizard, 'code'))(cryptoCurrencies)}
onClose={() => setWizard(false)} onClose={() => setWizard(false)}
save={save} save={save}
schemas={schemas} schemas={schemas}

View file

@ -13,8 +13,8 @@ import { has0Conf } from './helper'
const MAX_STEPS = 5 const MAX_STEPS = 5
const MODAL_WIDTH = 554 const MODAL_WIDTH = 554
const contains = crypto => R.compose(R.contains(crypto), R.prop('cryptos')) const contains = crypto => R.compose(R.includes(crypto), R.prop('cryptos'))
const sameClass = type => R.propEq('class', type) const sameClass = type => R.propEq(type, 'class')
const filterConfig = (crypto, type) => const filterConfig = (crypto, type) =>
R.filter(it => sameClass(type)(it) && contains(crypto)(it)) R.filter(it => sameClass(type)(it) && contains(crypto)(it))
const removeDeprecated = R.filter(({ deprecated }) => !deprecated) const removeDeprecated = R.filter(({ deprecated }) => !deprecated)
@ -59,7 +59,7 @@ const Wizard = ({
const exchanges = getItems(accountsConfig, accounts, 'exchange', coin.code) const exchanges = getItems(accountsConfig, accounts, 'exchange', coin.code)
const zeroConfs = getItems(accountsConfig, accounts, 'zeroConf', coin.code) const zeroConfs = getItems(accountsConfig, accounts, 'zeroConf', coin.code)
const getValue = code => R.find(R.propEq('code', code))(accounts) const getValue = code => R.find(R.propEq(code, 'code'))(accounts)
const commonWizardSteps = [ const commonWizardSteps = [
{ type: 'ticker', ...tickers }, { type: 'ticker', ...tickers },
@ -99,9 +99,9 @@ const Wizard = ({
const stepData = step > 0 ? wizardSteps[step - 1] : null const stepData = step > 0 ? wizardSteps[step - 1] : null
const onContinue = async (stepConfig, stepAccount) => { const onContinue = async (stepConfig, stepAccount) => {
const newConfig = R.merge(config, stepConfig) const newConfig = R.mergeRight(config, stepConfig)
const newAccounts = stepAccount const newAccounts = stepAccount
? R.merge(accountsToSave, stepAccount) ? R.mergeRight(accountsToSave, stepAccount)
: accountsToSave : accountsToSave
if (isLastStep) { if (isLastStep) {

View file

@ -38,7 +38,7 @@ const reducer = (state, action) => {
iError: false, iError: false,
} }
case 'error': case 'error':
return R.merge(state, { innerError: true }) return R.mergeRight(state, { innerError: true })
case 'reset': case 'reset':
return initialState return initialState
default: default:

View file

@ -12,7 +12,7 @@ import { CURRENCY_MAX } from '../../utils/constants'
import { defaultToZero } from '../../utils/number' import { defaultToZero } from '../../utils/number'
const filterClass = type => R.filter(it => it.class === type) const filterClass = type => R.filter(it => it.class === type)
const filterCoins = ({ id }) => R.filter(it => R.contains(id)(it.cryptos)) const filterCoins = ({ id }) => R.filter(it => R.includes(id)(it.cryptos))
const WalletSchema = Yup.object().shape({ const WalletSchema = Yup.object().shape({
ticker: Yup.string('The ticker must be a string').required( ticker: Yup.string('The ticker must be a string').required(
@ -36,6 +36,7 @@ const AdvancedWalletSchema = Yup.object().shape({
cryptoUnits: Yup.string().required(), cryptoUnits: Yup.string().required(),
feeMultiplier: Yup.string().required(), feeMultiplier: Yup.string().required(),
allowTransactionBatching: Yup.boolean(), allowTransactionBatching: Yup.boolean(),
enableLastUsedAddress: Yup.boolean(),
}) })
const OverridesSchema = Yup.object().shape({ const OverridesSchema = Yup.object().shape({
@ -57,7 +58,7 @@ const OverridesDefaults = {
} }
const viewFeeMultiplier = it => const viewFeeMultiplier = it =>
R.compose(R.prop(['display']), R.find(R.propEq('code', it)))(feeOptions) R.compose(R.prop(['display']), R.find(R.propEq(it, 'code')))(feeOptions)
const feeOptions = [ const feeOptions = [
{ display: '+60%', code: '1.6' }, { display: '+60%', code: '1.6' },
@ -127,6 +128,17 @@ const getAdvancedWalletElements = () => {
labelProp: 'display', labelProp: 'display',
}, },
}, },
{
name: 'enableLastUsedAddress',
header: `Allow last used address prompt`,
size: 'sm',
stripe: true,
width: 260,
view: (_, ite) => {
return ite.enableLastUsedAddress ? 'Yes' : `No`
},
input: Checkbox,
},
] ]
} }
@ -204,7 +216,7 @@ const getElements = (cryptoCurrencies, accounts, onChange, wizard = false) => {
const viewCryptoCurrency = it => { const viewCryptoCurrency = it => {
const currencyDisplay = R.compose( const currencyDisplay = R.compose(
it => `${R.prop(['display'])(it)} ${it?.isBeta ? '(Beta)' : ''}`, it => `${R.prop(['display'])(it)} ${it?.isBeta ? '(Beta)' : ''}`,
R.find(R.propEq('code', it)), R.find(R.propEq(it, 'code')),
)(cryptoCurrencies) )(cryptoCurrencies)
return currencyDisplay return currencyDisplay
} }
@ -213,7 +225,7 @@ const getElements = (cryptoCurrencies, accounts, onChange, wizard = false) => {
const getDisplayName = type => it => const getDisplayName = type => it =>
R.compose( R.compose(
R.prop('display'), R.prop('display'),
R.find(R.propEq('code', it)), R.find(R.propEq(it, 'code')),
)(filterOptions(type)) )(filterOptions(type))
const getOptions = R.curry((option, it) => const getOptions = R.curry((option, it) =>

View file

@ -36,7 +36,7 @@ const SAVE_ACCOUNTS = gql`
` `
const isConfigurable = it => const isConfigurable = it =>
!R.isNil(it) && !R.contains(it)(['mock-exchange', 'no-exchange']) !R.isNil(it) && !R.includes(it)(['mock-exchange', 'no-exchange'])
const ChooseExchange = ({ data: currentData, addData }) => { const ChooseExchange = ({ data: currentData, addData }) => {
const { data } = useQuery(GET_CONFIG) const { data } = useQuery(GET_CONFIG)

View file

@ -36,10 +36,10 @@ const SAVE_ACCOUNTS = gql`
` `
const isConfigurable = it => const isConfigurable = it =>
R.contains(it)(['infura', 'bitgo', 'trongrid', 'galoy']) R.includes(it)(['infura', 'bitgo', 'trongrid', 'galoy'])
const isLocalHosted = it => const isLocalHosted = it =>
R.contains(it)([ R.includes(it)([
'bitcoind', 'bitcoind',
'geth', 'geth',
'litecoind', 'litecoind',

View file

@ -46,7 +46,7 @@ const Wallet = ({ doContinue }) => {
const Component = mySteps[step].component const Component = mySteps[step].component
const addData = it => { const addData = it => {
setData(R.merge(data, it)) setData(R.mergeRight(data, it))
setStep(step + 1) setStep(step + 1)
} }

View file

@ -2,8 +2,8 @@ import * as R from 'ramda'
import _schema from '../../../Services/schemas' import _schema from '../../../Services/schemas'
const contains = crypto => R.compose(R.contains(crypto), R.prop('cryptos')) const contains = crypto => R.compose(R.includes(crypto), R.prop('cryptos'))
const sameClass = type => R.propEq('class', type) const sameClass = type => R.propEq(type, 'class')
const filterConfig = (crypto, type) => const filterConfig = (crypto, type) =>
R.filter(it => sameClass(type)(it) && contains(crypto)(it)) R.filter(it => sameClass(type)(it) && contains(crypto)(it))
export const getItems = (accountsConfig, accounts, type, crypto) => { export const getItems = (accountsConfig, accounts, type, crypto) => {

View file

@ -41,11 +41,11 @@ const hasSidebar = route =>
const getParent = route => const getParent = route =>
R.find( R.find(
R.propEq( R.propEq(
'route',
R.dropLast( R.dropLast(
1, 1,
R.dropLastWhile(x => x !== '/', route), R.dropLastWhile(x => x !== '/', route),
), ),
'route',
), ),
)(flattened) )(flattened)
@ -69,8 +69,8 @@ const Routes = () => {
Transition === Slide Transition === Slide
? { ? {
direction: direction:
R.findIndex(R.propEq('route', history.state.prev))(leafRoutes) > R.findIndex(R.propEq(history.state.prev, 'route'))(leafRoutes) >
R.findIndex(R.propEq('route', location))(leafRoutes) R.findIndex(R.propEq(location, 'route'))(leafRoutes)
? 'right' ? 'right'
: 'left', : 'left',
} }

View file

@ -30,6 +30,8 @@ const { p } = typographyStyles
let theme = createTheme({ let theme = createTheme({
typography: { typography: {
fontFamily: inputFontFamily, fontFamily: inputFontFamily,
root: { ...p },
body1: { ...p },
}, },
palette: { palette: {
primary: { primary: {
@ -56,6 +58,18 @@ theme = createTheme(theme, {
body1: { ...p }, body1: { ...p },
}, },
}, },
MuiCircularProgress: {
styleOverrides: {
root: {
color: primaryColor,
},
},
},
MuiTableCell: {
styleOverrides: {
root: { ...p },
},
},
MuiIconButtonBase: { MuiIconButtonBase: {
defaultProps: { defaultProps: {
disableRipple: true, disableRipple: true,

View file

@ -0,0 +1,33 @@
const defaultMaterialTableOpts = {
enableGlobalFilter: false,
paginationDisplayMode: 'pages',
enableColumnActions: false,
initialState: { density: 'compact' },
mrtTheme: it => ({
...it,
baseBackgroundColor: '#fff',
}),
muiTopToolbarProps: () => ({
sx: {
backgroundColor: 'var(--zodiac)',
'& .MuiButtonBase-root': { color: '#fff' },
},
}),
muiTableHeadRowProps: () => ({
sx: { backgroundColor: 'var(--zircon)' },
}),
}
const alignRight = {
muiTableHeadCellProps: {
align: 'right',
},
muiTableBodyCellProps: {
align: 'right',
},
muiTableFooterCellProps: {
align: 'right',
},
}
export { defaultMaterialTableOpts, alignRight }

View file

@ -0,0 +1,36 @@
#!/usr/bin/env node
require('../lib/environment-helper')
const db = require('../lib/db')
const machineLoader = require('../lib/machine-loader')
const operator = require('../lib/operator')
console.log('Running diagnostics on all paired devices...\n')
operator.getOperatorId('middleware')
.then(operatorId => {
if (!operatorId) {
throw new Error('Operator ID not found in database')
}
return db.any('SELECT device_id, name FROM devices')
.then(devices => ({ operatorId, devices }))
})
.then(({ operatorId, devices }) => {
if (devices.length === 0) {
console.log('No paired devices found.')
process.exit(0)
}
const deviceIds = devices.map(d => d.device_id)
return machineLoader.batchDiagnostics(deviceIds, operatorId)
})
.then(() => {
console.log('\n✓ Diagnostics initiated for all devices. It can take a few minutes for the results to appear on the admin.')
process.exit(0)
})
.catch(err => {
console.error('Error:', err.message)
process.exit(1)
})

View file

@ -13,11 +13,32 @@ const createMigration = `CREATE TABLE IF NOT EXISTS migrations (
// no need to log the migration process // no need to log the migration process
process.env.SKIP_SERVER_LOGS = true process.env.SKIP_SERVER_LOGS = true
db.none(createMigration) function checkPostgresVersion () {
.then(() => migrate.run()) return db.one('SHOW server_version;')
.then(() => { .then(result => {
console.log('DB Migration succeeded.') console.log(result)
process.exit(0) const versionString = result.server_version
const match = versionString.match(/(\d+)\.(\d+)/i)
if (!match) {
throw new Error(`Could not parse PostgreSQL version: ${versionString}`)
}
return parseInt(match[1], 10)
})
}
checkPostgresVersion()
.then(majorVersion => {
if (majorVersion < 12) {
console.error('PostgreSQL version must be 12 or higher. Current version:', majorVersion)
process.exit(1)
}
return db.none(createMigration)
.then(() => migrate.run())
.then(() => {
console.log('DB Migration succeeded.')
process.exit(0)
})
}) })
.catch(err => { .catch(err => {
console.error('DB Migration failed: %s', err) console.error('DB Migration failed: %s', err)

View file

@ -9,6 +9,7 @@ const logger = require('../logger')
const settingsLoader = require('../new-settings-loader') const settingsLoader = require('../new-settings-loader')
const configManager = require('../new-config-manager') const configManager = require('../new-config-manager')
const notifier = require('../notifier') const notifier = require('../notifier')
const constants = require('../constants')
const cashInAtomic = require('./cash-in-atomic') const cashInAtomic = require('./cash-in-atomic')
const cashInLow = require('./cash-in-low') const cashInLow = require('./cash-in-low')
@ -194,14 +195,27 @@ function postProcess(r, pi, isBlacklisted, addressReuse, walletScore) {
}) })
} }
// This feels like it can be simplified,
// but it's the most concise query to express the requirement and its edge cases.
// At most only one authenticated customer can use an address.
// If the current customer is anon, we can still allow one other customer to use the address,
// So we count distinct customers plus the current customer if they are not anonymous.
// To prevent malicious blocking of address, we only check for txs with actual fiat
function doesTxReuseAddress(tx) { function doesTxReuseAddress(tx) {
const sql = ` const sql = `
SELECT EXISTS ( SELECT COUNT(*) > 1 as exists
SELECT DISTINCT to_address FROM ( FROM (SELECT DISTINCT customer_id
SELECT to_address FROM cash_in_txs WHERE id != $1 FROM cash_in_txs
) AS x WHERE to_address = $2 WHERE to_address = $1
)` AND customer_id != $3
return db.one(sql, [tx.id, tx.toAddress]).then(({ exists }) => exists) AND fiat > 0
UNION
SELECT $2
WHERE $2 != $3) t;
`
return db
.one(sql, [tx.toAddress, tx.customerId, constants.anonymousCustomer.uuid])
.then(({ exists }) => exists)
} }
function getWalletScore(tx, pi) { function getWalletScore(tx, pi) {

View file

@ -9,11 +9,12 @@
*/ */
const prepare_denominations = denominations => const prepare_denominations = denominations =>
JSON.parse(JSON.stringify(denominations)) JSON.parse(JSON.stringify(denominations))
.sort(([d1], [d2]) => d1 < d2) .sort(([d1], [d2]) => d2 - d1)
.reduce( .reduce(
([csum, denoms], [denom, count]) => { ([csum, denoms], [denom, count]) => {
csum += denom * count csum += denom * count
return [csum, [{ denom, count, csum }].concat(denoms)] denoms.push({ denom, count, csum })
return [csum, denoms]
}, },
[0, []], [0, []],
)[1] /* ([csum, denoms]) => denoms */ )[1] /* ([csum, denoms]) => denoms */

View file

@ -8,16 +8,17 @@ const fs = require('fs')
const util = require('util') const util = require('util')
const db = require('./db') const db = require('./db')
const anonymous = require('../lib/constants').anonymousCustomer
const complianceOverrides = require('./compliance_overrides') const complianceOverrides = require('./compliance_overrides')
const writeFile = util.promisify(fs.writeFile) const writeFile = util.promisify(fs.writeFile)
const notifierQueries = require('./notifier/queries') const notifierQueries = require('./notifier/queries')
const notifierUtils = require('./notifier/utils') const notifierUtils = require('./notifier/utils')
const NUM_RESULTS = 1000
const sms = require('./sms') const sms = require('./sms')
const settingsLoader = require('./new-settings-loader') const settingsLoader = require('./new-settings-loader')
const logger = require('./logger') const logger = require('./logger')
const externalCompliance = require('./compliance-external') const externalCompliance = require('./compliance-external')
const {
customers: { getCustomerList },
} = require('typesafe-db')
const { APPROVED, RETRY } = require('./plugins/compliance/consts') const { APPROVED, RETRY } = require('./plugins/compliance/consts')
@ -483,28 +484,6 @@ function addComplianceOverrides(id, customer, userToken) {
) )
} }
/**
* Query all customers
*
* Add status as computed column,
* which will indicate the name of the latest
* compliance verfication completed by user.
*
* @returns {array} Array of customers populated with status field
*/
function batch() {
const sql = `select * from customers
where id != $1
order by created desc limit $2`
return db.any(sql, [anonymous.uuid, NUM_RESULTS]).then(customers =>
Promise.all(
_.map(customer => {
return getCustomInfoRequestsData(customer).then(camelize)
}, customers),
),
)
}
function getSlimCustomerByIdBatch(ids) { function getSlimCustomerByIdBatch(ids) {
const sql = `SELECT id, phone, id_card_data const sql = `SELECT id, phone, id_card_data
FROM customers FROM customers
@ -512,88 +491,8 @@ function getSlimCustomerByIdBatch(ids) {
return db.any(sql, [ids]).then(customers => _.map(camelize, customers)) return db.any(sql, [ids]).then(customers => _.map(camelize, customers))
} }
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored function getCustomersList() {
return getCustomerList({ withCustomInfoRequest: true })
/**
* Query all customers, ordered by last activity
* and with aggregate columns based on their
* transactions
*
* @returns {array} Array of customers with it's transactions aggregations
*/
function getCustomersList(
phone = null,
name = null,
address = null,
id = null,
email = null,
) {
const passableErrorCodes = _.map(
Pgp.as.text,
TX_PASSTHROUGH_ERROR_CODES,
).join(',')
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
phone, email, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, GREATEST(created, last_transaction, last_data_provided, last_auth_attempt) AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes, is_test_customer
FROM (
SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended,
c.suspended_until > NOW() AS is_suspended,
c.front_camera_path, c.front_camera_override,
c.phone, c.email, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions, c.last_auth_attempt,
GREATEST(c.phone_at, c.email_at, c.id_card_data_at, c.front_camera_at, c.id_card_photo_at, c.us_ssn_at) AS last_data_provided,
c.sanctions_at, c.sanctions_override, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
row_number() OVER (partition by c.id order by t.created desc) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs,
coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields
FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true OR batched = true UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) AS t ON c.id = t.customer_id
LEFT OUTER JOIN (
SELECT cf.customer_id, json_agg(json_build_object('id', cf.custom_field_id, 'label', cf.label, 'value', cf.value)) AS custom_fields FROM (
SELECT ccfp.custom_field_id, ccfp.customer_id, cfd.label, ccfp.value FROM custom_field_definitions cfd
LEFT OUTER JOIN customer_custom_field_pairs ccfp ON cfd.id = ccfp.custom_field_id
) cf GROUP BY cf.customer_id
) ccf ON c.id = ccf.customer_id
LEFT OUTER JOIN (
SELECT customer_id, coalesce(json_agg(customer_notes.*), '[]'::json) AS notes FROM customer_notes
GROUP BY customer_notes.customer_id
) cn ON c.id = cn.customer_id
WHERE c.id != $2
) AS cl WHERE rn = 1
AND ($4 IS NULL OR phone = $4)
AND ($5 IS NULL OR CONCAT(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') = $5 OR id_card_data::json->>'firstName' = $5 OR id_card_data::json->>'lastName' = $5)
AND ($6 IS NULL OR id_card_data::json->>'address' = $6)
AND ($7 IS NULL OR id_card_data::json->>'documentNumber' = $7)
AND ($8 IS NULL OR email = $8)
ORDER BY last_active DESC
limit $3`
return db
.any(sql, [
passableErrorCodes,
anonymous.uuid,
NUM_RESULTS,
phone,
name,
address,
id,
email,
])
.then(customers =>
Promise.all(
_.map(
customer => getCustomInfoRequestsData(customer).then(camelizeDeep),
customers,
),
),
)
} }
/** /**
@ -1081,12 +980,10 @@ function notifyApprovedExternalCompliance(settings, customerId) {
function checkExternalCompliance(settings) { function checkExternalCompliance(settings) {
return getOpenExternalCompliance().then(externals => { return getOpenExternalCompliance().then(externals => {
console.log(externals)
const promises = _.map(external => { const promises = _.map(external => {
return externalCompliance return externalCompliance
.getStatus(settings, external.service, external.customer_id) .getStatus(settings, external.service, external.customer_id)
.then(status => { .then(status => {
console.log('status', status, external.customer_id, external.service)
if (status.status.answer === RETRY) if (status.status.answer === RETRY)
notifyRetryExternalCompliance( notifyRetryExternalCompliance(
settings, settings,
@ -1112,12 +1009,16 @@ function addExternalCompliance(customerId, service, id) {
return db.none(sql, [customerId, id, service]) return db.none(sql, [customerId, id, service])
} }
function getLastUsedAddress(id, cryptoCode) {
const sql = `SELECT to_address FROM cash_in_txs WHERE customer_id=$1 AND crypto_code=$2 AND fiat > 0 ORDER BY created DESC LIMIT 1`
return db.oneOrNone(sql, [id, cryptoCode]).then(it => it?.to_address)
}
module.exports = { module.exports = {
add, add,
addWithEmail, addWithEmail,
get, get,
getWithEmail, getWithEmail,
batch,
getSlimCustomerByIdBatch, getSlimCustomerByIdBatch,
getCustomersList, getCustomersList,
getCustomerById, getCustomerById,
@ -1139,4 +1040,5 @@ module.exports = {
updateLastAuthAttempt, updateLastAuthAttempt,
addExternalCompliance, addExternalCompliance,
checkExternalCompliance, checkExternalCompliance,
getLastUsedAddress,
} }

View file

@ -9,7 +9,6 @@ const eventBus = require('./event-bus')
const DATABASE_NOT_REACHABLE = 'Database not reachable.' const DATABASE_NOT_REACHABLE = 'Database not reachable.'
const pgp = Pgp({ const pgp = Pgp({
pgNative: true,
schema: 'public', schema: 'public',
error: (err, e) => { error: (err, e) => {
if (e.cn) logger.error(DATABASE_NOT_REACHABLE) if (e.cn) logger.error(DATABASE_NOT_REACHABLE)

View file

@ -30,7 +30,7 @@ function getBitPayFxRate(
fiatCodeProperty, fiatCodeProperty,
rateProperty, rateProperty,
) { ) {
return getFiatRates().then(({ data: fxRates }) => { return getFiatRates().then(fxRates => {
const defaultFiatRate = findCurrencyRates( const defaultFiatRate = findCurrencyRates(
fxRates, fxRates,
defaultFiatMarket, defaultFiatMarket,
@ -69,14 +69,15 @@ const getRate = (retries = 1, fiatCode, defaultFiatMarket) => {
defaultFiatMarket, defaultFiatMarket,
fiatCodeProperty, fiatCodeProperty,
rateProperty, rateProperty,
).catch(() => { ).catch(err => {
// Switch service
const erroredService = API_QUEUE.shift() const erroredService = API_QUEUE.shift()
API_QUEUE.push(erroredService) API_QUEUE.push(erroredService)
if (retries >= MAX_ROTATIONS) if (retries >= MAX_ROTATIONS)
throw new Error(`FOREX API error from ${erroredService.name}`) throw new Error(
`FOREX API error from ${erroredService.name} ${err?.message}`,
)
return getRate(++retries, fiatCode) return getRate(++retries, fiatCode, defaultFiatMarket)
}) })
} }

View file

@ -523,6 +523,43 @@ function diagnostics(rec) {
) )
} }
function batchDiagnostics(deviceIds, operatorId) {
const diagnosticsDir = `${OPERATOR_DATA_DIR}/diagnostics/`
const removeDir = fsPromises
.rm(diagnosticsDir, { recursive: true })
.catch(err => {
if (err.code !== 'ENOENT') {
throw err
}
})
const sql = `UPDATE devices
SET diagnostics_timestamp = NULL,
diagnostics_scan_updated_at = NULL,
diagnostics_front_updated_at = NULL
WHERE device_id = ANY($1)`
// Send individual notifications for each machine
const sendNotifications = deviceIds.map(deviceId =>
db.none('NOTIFY $1:name, $2', [
'machineAction',
JSON.stringify({
action: 'diagnostics',
value: {
deviceId,
operatorId,
action: 'diagnostics',
},
}),
]),
)
return removeDir
.then(() => db.none(sql, [deviceIds]))
.then(() => Promise.all(sendNotifications))
}
function setMachine(rec, operatorId) { function setMachine(rec, operatorId) {
rec.operatorId = operatorId rec.operatorId = operatorId
switch (rec.action) { switch (rec.action) {
@ -681,4 +718,5 @@ module.exports = {
refillMachineUnits, refillMachineUnits,
updateDiagnostics, updateDiagnostics,
updateFailedQRScans, updateFailedQRScans,
batchDiagnostics,
} }

View file

@ -27,18 +27,5 @@ function transaction() {
return db.any(sql) return db.any(sql)
} }
function customer() {
const sql = `SELECT DISTINCT * FROM (
SELECT 'phone' AS type, phone AS value FROM customers WHERE phone IS NOT NULL UNION
SELECT 'email' AS type, email AS value FROM customers WHERE email IS NOT NULL UNION
SELECT 'name' AS type, id_card_data::json->>'firstName' AS value FROM customers WHERE id_card_data::json->>'firstName' IS NOT NULL AND id_card_data::json->>'lastName' IS NULL UNION
SELECT 'name' AS type, id_card_data::json->>'lastName' AS value FROM customers WHERE id_card_data::json->>'firstName' IS NULL AND id_card_data::json->>'lastName' IS NOT NULL UNION
SELECT 'name' AS type, concat(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') AS value FROM customers WHERE id_card_data::json->>'firstName' IS NOT NULL AND id_card_data::json->>'lastName' IS NOT NULL UNION
SELECT 'address' as type, id_card_data::json->>'address' AS value FROM customers WHERE id_card_data::json->>'address' IS NOT NULL UNION
SELECT 'id' AS type, id_card_data::json->>'documentNumber' AS value FROM customers WHERE id_card_data::json->>'documentNumber' IS NOT NULL
) f`
return db.any(sql) module.exports = { transaction }
}
module.exports = { transaction, customer }

View file

@ -1,7 +1,6 @@
const authentication = require('../modules/userManagement') const authentication = require('../modules/userManagement')
const anonymous = require('../../../constants').anonymousCustomer const anonymous = require('../../../constants').anonymousCustomer
const customers = require('../../../customers') const customers = require('../../../customers')
const filters = require('../../filters')
const customerNotes = require('../../../customer-notes') const customerNotes = require('../../../customer-notes')
const machineLoader = require('../../../machine-loader') const machineLoader = require('../../../machine-loader')
@ -18,11 +17,9 @@ const resolvers = {
isAnonymous: parent => parent.customerId === anonymous.uuid, isAnonymous: parent => parent.customerId === anonymous.uuid,
}, },
Query: { Query: {
customers: (...[, { phone, email, name, address, id }]) => customers: () => customers.getCustomersList(),
customers.getCustomersList(phone, name, address, id, email),
customer: (...[, { customerId }]) => customer: (...[, { customerId }]) =>
customers.getCustomerById(customerId).then(addLastUsedMachineName), customers.getCustomerById(customerId).then(addLastUsedMachineName),
customerFilters: () => filters.customer(),
}, },
Mutation: { Mutation: {
setCustomer: (root, { customerId, customerInput }, context) => { setCustomer: (root, { customerId, customerInput }, context) => {

View file

@ -34,7 +34,7 @@ function ticker(fiatCode, cryptoCode, tickerName) {
return getCurrencyRates(ticker, fiatCode, cryptoCode) return getCurrencyRates(ticker, fiatCode, cryptoCode)
} }
return getRate(RETRIES, tickerName, defaultFiatMarket(tickerName)).then( return getRate(RETRIES, fiatCode, defaultFiatMarket(tickerName)).then(
({ fxRate }) => { ({ fxRate }) => {
try { try {
return getCurrencyRates( return getCurrencyRates(

View file

@ -55,7 +55,7 @@ const loadRoutes = async () => {
app.use(compression({ threshold: 500 })) app.use(compression({ threshold: 500 }))
app.use(helmet()) app.use(helmet())
app.use(nocache()) app.use(nocache())
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '25mb' }))
morgan.token('bytesRead', (_req, res) => res.bytesRead) morgan.token('bytesRead', (_req, res) => res.bytesRead)
morgan.token('bytesWritten', (_req, res) => res.bytesWritten) morgan.token('bytesWritten', (_req, res) => res.bytesWritten)

View file

@ -311,7 +311,13 @@ function getExternalComplianceLink(req, res, next) {
.then(url => respond(req, res, { url })) .then(url => respond(req, res, { url }))
} }
function addOrUpdateCustomer(customerData, deviceId, config, isEmailAuth) { function addOrUpdateCustomer(
customerData,
deviceId,
config,
isEmailAuth,
cryptoCode,
) {
const triggers = configManager.getTriggers(config) const triggers = configManager.getTriggers(config)
const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers) const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers)
@ -346,6 +352,18 @@ function addOrUpdateCustomer(customerData, deviceId, config, isEmailAuth) {
.getCustomerActiveIndividualDiscount(customer.id) .getCustomerActiveIndividualDiscount(customer.id)
.then(discount => ({ ...customer, discount })) .then(discount => ({ ...customer, discount }))
}) })
.then(customer => {
const enableLastUsedAddress = !!configManager.getWalletSettings(
cryptoCode,
config,
).enableLastUsedAddress
if (!cryptoCode || !enableLastUsedAddress) return customer
return customers
.getLastUsedAddress(customer.id, cryptoCode)
.then(lastUsedAddress => {
return { ...customer, lastUsedAddress }
})
})
} }
function getOrAddCustomerPhone(req, res, next) { function getOrAddCustomerPhone(req, res, next) {
@ -354,6 +372,7 @@ function getOrAddCustomerPhone(req, res, next) {
const pi = plugins(req.settings, deviceId) const pi = plugins(req.settings, deviceId)
const phone = req.body.phone const phone = req.body.phone
const cryptoCode = req.query.cryptoCode
return pi return pi
.getPhoneCode(phone) .getPhoneCode(phone)
@ -363,6 +382,7 @@ function getOrAddCustomerPhone(req, res, next) {
deviceId, deviceId,
req.settings.config, req.settings.config,
false, false,
cryptoCode,
).then(customer => respond(req, res, { code, customer })) ).then(customer => respond(req, res, { code, customer }))
}) })
.catch(err => { .catch(err => {
@ -375,6 +395,7 @@ function getOrAddCustomerPhone(req, res, next) {
function getOrAddCustomerEmail(req, res, next) { function getOrAddCustomerEmail(req, res, next) {
const deviceId = req.deviceId const deviceId = req.deviceId
const customerData = req.body const customerData = req.body
const cryptoCode = req.query.cryptoCode
const pi = plugins(req.settings, req.deviceId) const pi = plugins(req.settings, req.deviceId)
const email = req.body.email const email = req.body.email
@ -387,6 +408,7 @@ function getOrAddCustomerEmail(req, res, next) {
deviceId, deviceId,
req.settings.config, req.settings.config,
true, true,
cryptoCode,
).then(customer => respond(req, res, { code, customer })) ).then(customer => respond(req, res, { code, customer }))
}) })
.catch(err => { .catch(err => {

View file

@ -53,7 +53,12 @@ function getTx(req, res, next) {
return helpers return helpers
.fetchStatusTx(req.params.id, req.query.status) .fetchStatusTx(req.params.id, req.query.status)
.then(r => res.json(r)) .then(r => res.json(r))
.catch(next) .catch(err => {
if (err.name === 'HTTPError') {
return res.status(err.code).send(err.message)
}
next(err)
})
} }
return next(httpError('Not Found', 404)) return next(httpError('Not Found', 404))

View file

@ -8,6 +8,7 @@ const T = require('./time')
// FP operations on Postgres result in very big errors. // FP operations on Postgres result in very big errors.
// E.g.: 1853.013808 * 1000 = 1866149.494 // E.g.: 1853.013808 * 1000 = 1866149.494
const REDEEMABLE_AGE = T.day / 1000 const REDEEMABLE_AGE = T.day / 1000
const MAX_THRESHOLD_DAYS = 365 * 50 // 50 years maximum
function process(tx, pi) { function process(tx, pi) {
const mtx = massage(tx) const mtx = massage(tx)
@ -92,7 +93,9 @@ function customerHistory(customerId, thresholdDays) {
AND fiat > 0 AND fiat > 0
) ch WHERE NOT ch.expired ORDER BY ch.created` ) ch WHERE NOT ch.expired ORDER BY ch.created`
const days = _.isNil(thresholdDays) ? 0 : thresholdDays const days = _.isNil(thresholdDays)
? 0
: Math.min(thresholdDays, MAX_THRESHOLD_DAYS)
return db.any(sql, [customerId, `${days} days`, '60 minutes', REDEEMABLE_AGE]) return db.any(sql, [customerId, `${days} days`, '60 minutes', REDEEMABLE_AGE])
} }

View file

@ -0,0 +1,12 @@
const db = require('./db')
exports.up = next =>
db.multi(
[
`CREATE INDEX cash_in_txs_customer_id_idx ON cash_in_txs (customer_id);`,
`CREATE INDEX cash_out_txs_customer_id_idx ON cash_out_txs (customer_id);`,
],
next,
)
exports.down = next => next()

View file

@ -0,0 +1,16 @@
const { saveConfig } = require('../lib/new-settings-loader')
exports.up = function (next) {
const newConfig = {
wallets_advanced_enableLastUsedAddress: false,
}
return saveConfig(newConfig)
.then(next)
.catch(err => {
return next(err)
})
}
module.exports.down = function (next) {
next()
}

View file

@ -76,7 +76,6 @@
"p-each-series": "^1.0.0", "p-each-series": "^1.0.0",
"p-queue": "^6.6.2", "p-queue": "^6.6.2",
"p-retry": "^4.4.0", "p-retry": "^4.4.0",
"pg-native": "^3.0.0",
"pg-promise": "^10.10.2", "pg-promise": "^10.10.2",
"pify": "^3.0.0", "pify": "^3.0.0",
"pretty-ms": "^2.1.0", "pretty-ms": "^2.1.0",
@ -89,6 +88,7 @@
"telnyx": "^1.25.5", "telnyx": "^1.25.5",
"tronweb": "^5.3.0", "tronweb": "^5.3.0",
"twilio": "^3.6.1", "twilio": "^3.6.1",
"typesafe-db": "workspace:*",
"uuid": "8.3.2", "uuid": "8.3.2",
"web3": "1.7.1", "web3": "1.7.1",
"winston": "^2.4.2", "winston": "^2.4.2",
@ -123,34 +123,16 @@
"lamassu-eth-recovery": "./bin/lamassu-eth-recovery", "lamassu-eth-recovery": "./bin/lamassu-eth-recovery",
"lamassu-trx-recovery": "./bin/lamassu-trx-recovery", "lamassu-trx-recovery": "./bin/lamassu-trx-recovery",
"lamassu-update-cassettes": "./bin/lamassu-update-cassettes", "lamassu-update-cassettes": "./bin/lamassu-update-cassettes",
"lamassu-clean-parsed-id": "./bin/lamassu-clean-parsed-id" "lamassu-clean-parsed-id": "./bin/lamassu-clean-parsed-id",
"lamassu-batch-diagnostics": "./bin/lamassu-batch-diagnostics"
}, },
"scripts": { "scripts": {
"start": "node bin/lamassu-server", "dev": "concurrently \"npm:server\" \"npm:admin-server\"",
"test": "mocha --recursive tests", "server": "node --watch bin/lamassu-server --mockScoring --logLevel silly",
"jtest": "jest --detectOpenHandles", "admin-server": "node --watch bin/lamassu-admin-server --dev --logLevel silly",
"build-admin": "npm run build-admin:css && npm run build-admin:main && npm run build-admin:lamassu",
"server": "nodemon bin/lamassu-server --mockScoring --logLevel silly",
"admin-server": "nodemon bin/lamassu-admin-server --dev --logLevel silly",
"watch": "concurrently \"npm:server\" \"npm:admin-server\"",
"stress-test": "cd tests/stress/ && node index.js 50 -v" "stress-test": "cd tests/stress/ && node index.js 50 -v"
}, },
"nodemonConfig": {
"ignore": [
"new-lamassu-admin/*"
]
},
"devDependencies": { "devDependencies": {
"concurrently": "^5.3.0", "concurrently": "^5.3.0"
"jest": "^26.6.3",
"nodemon": "^2.0.6",
"standard": "^12.0.1"
},
"standard": {
"ignore": [
"/lamassu-admin-elm",
"/public",
"/new-lamassu-admin"
]
} }
} }

View file

@ -22,14 +22,14 @@ setEnvVariable('KEY_PATH', `${process.env.PWD}/certs/Lamassu_OP.key`)
setEnvVariable( setEnvVariable(
'MNEMONIC_PATH', 'MNEMONIC_PATH',
`${process.env.HOME}/.lamassu/mnemonics/mnemonic.txt`, `${process.env.PWD}/.lamassu/mnemonics/mnemonic.txt`,
) )
setEnvVariable('BLOCKCHAIN_DIR', `${process.env.PWD}/blockchains`) setEnvVariable('BLOCKCHAIN_DIR', `${process.env.PWD}/blockchains`)
setEnvVariable('OFAC_DATA_DIR', `${process.env.HOME}/.lamassu/ofac`) setEnvVariable('OFAC_DATA_DIR', `${process.env.PWD}/.lamassu/ofac`)
setEnvVariable('ID_PHOTO_CARD_DIR', `${process.env.HOME}/.lamassu/idphotocard`) setEnvVariable('ID_PHOTO_CARD_DIR', `${process.env.PWD}/.lamassu/idphotocard`)
setEnvVariable('FRONT_CAMERA_DIR', `${process.env.HOME}/.lamassu/frontcamera`) setEnvVariable('FRONT_CAMERA_DIR', `${process.env.PWD}/.lamassu/frontcamera`)
setEnvVariable('OPERATOR_DATA_DIR', `${process.env.HOME}/.lamassu/operatordata`) setEnvVariable('OPERATOR_DATA_DIR', `${process.env.PWD}/.lamassu/operatordata`)
setEnvVariable('BTC_NODE_LOCATION', 'remote') setEnvVariable('BTC_NODE_LOCATION', 'remote')
setEnvVariable('BTC_WALLET_LOCATION', 'local') setEnvVariable('BTC_WALLET_LOCATION', 'local')

View file

@ -5,11 +5,13 @@ set -e
DOMAIN=localhost DOMAIN=localhost
[ ! -z "$1" ] && DOMAIN=$1 [ ! -z "$1" ] && DOMAIN=$1
CONFIG_DIR=$HOME/.lamassu SERVER_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG_DIR=$SERVER_DIR/.lamassu
LOG_FILE=/tmp/cert-gen.log LOG_FILE=/tmp/cert-gen.log
CERT_DIR=$PWD/certs CERT_DIR=$SERVER_DIR/certs
KEY_DIR=$PWD/certs KEY_DIR=$SERVER_DIR/certs
LAMASSU_CA_PATH=$PWD/Lamassu_CA.pem LAMASSU_CA_PATH=$SERVER_DIR/Lamassu_CA.pem
POSTGRES_PASS=postgres123 POSTGRES_PASS=postgres123
OFAC_DATA_DIR=$CONFIG_DIR/ofac OFAC_DATA_DIR=$CONFIG_DIR/ofac
IDPHOTOCARD_DIR=$CONFIG_DIR/idphotocard IDPHOTOCARD_DIR=$CONFIG_DIR/idphotocard
@ -24,7 +26,7 @@ MNEMONIC_DIR=$CONFIG_DIR/mnemonics
MNEMONIC_FILE=$MNEMONIC_DIR/mnemonic.txt MNEMONIC_FILE=$MNEMONIC_DIR/mnemonic.txt
mkdir -p $MNEMONIC_DIR >> $LOG_FILE 2>&1 mkdir -p $MNEMONIC_DIR >> $LOG_FILE 2>&1
SEED=$(openssl rand -hex 32) SEED=$(openssl rand -hex 32)
MNEMONIC=$($PWD/bin/bip39 $SEED) MNEMONIC=$($SERVER_DIR/bin/bip39 $SEED)
echo "$MNEMONIC" > $MNEMONIC_FILE echo "$MNEMONIC" > $MNEMONIC_FILE
echo "Generating SSL certificates..." echo "Generating SSL certificates..."
@ -90,6 +92,6 @@ rm /tmp/Lamassu_OP.csr.pem
mkdir -p $OFAC_DATA_DIR/sources mkdir -p $OFAC_DATA_DIR/sources
touch $OFAC_DATA_DIR/etags.json touch $OFAC_DATA_DIR/etags.json
node tools/build-dev-env.js (cd $SERVER_DIR && node tools/build-dev-env.js)
echo "Done." echo "Done."

View file

@ -0,0 +1,28 @@
{
"name": "typesafe-db",
"version": "11.0.0-beta.0",
"license": "../LICENSE",
"type": "module",
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.11.10",
"kysely-codegen": "^0.18.5",
"typescript": "^5.8.3"
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./lib/index.js"
}
},
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch",
"generate-types": "kysely-codegen --camel-case --out-file ./src/types/types.d.ts",
"postinstall": "npm run build"
},
"dependencies": {
"kysely": "^0.28.2",
"pg": "^8.16.0"
}
}

View file

@ -0,0 +1,184 @@
import db from './db.js'
import { ExpressionBuilder } from 'kysely'
import { Customers, DB, EditedCustomerData } from './types/types.js'
import { jsonArrayFrom } from 'kysely/helpers/postgres'
type CustomerEB = ExpressionBuilder<DB & { c: Customers }, 'c'>
type CustomerWithEditedEB = ExpressionBuilder<
DB & { c: Customers } & { e: EditedCustomerData | null },
'c' | 'e'
>
const ANON_ID = '47ac1184-8102-11e7-9079-8f13a7117867'
const TX_PASSTHROUGH_ERROR_CODES = [
'operatorCancel',
'scoreThresholdReached',
'walletScoringError',
]
function transactionUnion(eb: CustomerEB) {
return eb
.selectFrom('cashInTxs')
.select([
'created',
'fiat',
'fiatCode',
'errorCode',
eb.val('cashIn').as('txClass'),
])
.where(({ eb, and, or, ref }) =>
and([
eb('customerId', '=', ref('c.id')),
or([eb('sendConfirmed', '=', true), eb('batched', '=', true)]),
]),
)
.unionAll(
eb
.selectFrom('cashOutTxs')
.select([
'created',
'fiat',
'fiatCode',
'errorCode',
eb.val('cashOut').as('txClass'),
])
.where(({ eb, and, ref }) =>
and([
eb('customerId', '=', ref('c.id')),
eb('confirmedAt', 'is not', null),
]),
),
)
}
function joinLatestTx(eb: CustomerEB) {
return eb
.selectFrom(eb =>
transactionUnion(eb).orderBy('created', 'desc').limit(1).as('lastTx'),
)
.select(['fiatCode', 'fiat', 'txClass', 'created'])
.as('lastTx')
}
function joinTxsTotals(eb: CustomerEB) {
return eb
.selectFrom(eb => transactionUnion(eb).as('combinedTxs'))
.select([
eb => eb.fn.coalesce(eb.fn.countAll(), eb.val(0)).as('totalTxs'),
eb =>
eb.fn
.coalesce(
eb.fn.sum(
eb
.case()
.when(
eb.or([
eb('combinedTxs.errorCode', 'is', null),
eb(
'combinedTxs.errorCode',
'not in',
TX_PASSTHROUGH_ERROR_CODES,
),
]),
)
.then(eb.ref('combinedTxs.fiat'))
.else(0)
.end(),
),
eb.val(0),
)
.as('totalSpent'),
])
.as('txStats')
}
function selectNewestIdCardData(eb: CustomerWithEditedEB, ref: any) {
return eb
.case()
.when(
eb.and([
eb(ref('e.idCardDataAt'), 'is not', null),
eb.or([
eb(ref('c.idCardDataAt'), 'is', null),
eb(ref('e.idCardDataAt'), '>', ref('c.idCardDataAt')),
]),
]),
)
.then(ref('e.idCardData'))
.else(ref('c.idCardData'))
.end()
}
interface GetCustomerListOptions {
withCustomInfoRequest: boolean
}
const defaultOptions: GetCustomerListOptions = {
withCustomInfoRequest: false,
}
// TODO left join lateral is having issues deriving type
function getCustomerList(
options: GetCustomerListOptions = defaultOptions,
): Promise<any[]> {
return db
.selectFrom('customers as c')
.leftJoin('editedCustomerData as e', 'e.customerId', 'c.id')
.leftJoinLateral(joinTxsTotals, join => join.onTrue())
.leftJoinLateral(joinLatestTx, join => join.onTrue())
.select(({ eb, fn, val, ref }) => [
'c.id',
'c.phone',
'c.authorizedOverride',
'c.frontCameraPath',
'c.frontCameraOverride',
'c.idCardPhotoPath',
'c.idCardPhotoOverride',
selectNewestIdCardData(eb, ref).as('idCardData'),
'c.idCardDataOverride',
'c.email',
'c.usSsn',
'c.usSsnOverride',
'c.sanctions',
'c.sanctionsOverride',
'txStats.totalSpent',
'txStats.totalTxs',
ref('lastTx.fiatCode').as('lastTxFiatCode'),
ref('lastTx.fiat').as('lastTxFiat'),
ref('lastTx.txClass').as('lastTxClass'),
fn<Date>('GREATEST', [
'c.created',
'lastTx.created',
'c.phoneAt',
'c.emailAt',
'c.idCardDataAt',
'c.frontCameraAt',
'c.idCardPhotoAt',
'c.usSsnAt',
'c.lastAuthAttempt',
]).as('lastActive'),
eb('c.suspendedUntil', '>', fn<Date>('NOW', [])).as('isSuspended'),
fn<number>('GREATEST', [
val(0),
fn<number>('date_part', [
val('day'),
eb('c.suspendedUntil', '-', fn<Date>('NOW', [])),
]),
]).as('daysSuspended'),
])
.where('c.id', '!=', ANON_ID)
.$if(options.withCustomInfoRequest, qb =>
qb.select(({ eb, ref }) =>
jsonArrayFrom(
eb
.selectFrom('customersCustomInfoRequests')
.selectAll()
.where('customerId', '=', ref('c.id')),
).as('customInfoRequestData'),
),
)
.orderBy('lastActive', 'desc')
.execute()
}
export { getCustomerList }

View file

@ -0,0 +1,23 @@
import { DB } from './types/types.js'
import { Pool } from 'pg'
import { Kysely, PostgresDialect, CamelCasePlugin } from 'kysely'
const POSTGRES_USER = process.env.POSTGRES_USER
const POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD
const POSTGRES_HOST = process.env.POSTGRES_HOST
const POSTGRES_PORT = process.env.POSTGRES_PORT
const POSTGRES_DB = process.env.POSTGRES_DB
const PSQL_URL = `postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`
const dialect = new PostgresDialect({
pool: new Pool({
connectionString: PSQL_URL,
max: 5,
}),
})
export default new Kysely<DB>({
dialect,
plugins: [new CamelCasePlugin()],
})

View file

@ -0,0 +1 @@
export * as customers from './customers.js'

View file

@ -0,0 +1,745 @@
/**
* This file was generated by kysely-codegen.
* Please do not edit it manually.
*/
import type { ColumnType } from 'kysely'
export type AuthTokenType = 'reset_password' | 'reset_twofa'
export type CashUnit =
| 'cashbox'
| 'cassette1'
| 'cassette2'
| 'cassette3'
| 'cassette4'
| 'recycler1'
| 'recycler2'
| 'recycler3'
| 'recycler4'
| 'recycler5'
| 'recycler6'
export type CashUnitOperationType =
| 'cash-box-empty'
| 'cash-box-refill'
| 'cash-cassette-1-count-change'
| 'cash-cassette-1-empty'
| 'cash-cassette-1-refill'
| 'cash-cassette-2-count-change'
| 'cash-cassette-2-empty'
| 'cash-cassette-2-refill'
| 'cash-cassette-3-count-change'
| 'cash-cassette-3-empty'
| 'cash-cassette-3-refill'
| 'cash-cassette-4-count-change'
| 'cash-cassette-4-empty'
| 'cash-cassette-4-refill'
| 'cash-recycler-1-count-change'
| 'cash-recycler-1-empty'
| 'cash-recycler-1-refill'
| 'cash-recycler-2-count-change'
| 'cash-recycler-2-empty'
| 'cash-recycler-2-refill'
| 'cash-recycler-3-count-change'
| 'cash-recycler-3-empty'
| 'cash-recycler-3-refill'
| 'cash-recycler-4-count-change'
| 'cash-recycler-4-empty'
| 'cash-recycler-4-refill'
| 'cash-recycler-5-count-change'
| 'cash-recycler-5-empty'
| 'cash-recycler-5-refill'
| 'cash-recycler-6-count-change'
| 'cash-recycler-6-empty'
| 'cash-recycler-6-refill'
export type ComplianceType =
| 'authorized'
| 'front_camera'
| 'hard_limit'
| 'id_card_data'
| 'id_card_photo'
| 'sanctions'
| 'sms'
| 'us_ssn'
export type DiscountSource = 'individualDiscount' | 'promoCode'
export type ExternalComplianceStatus =
| 'APPROVED'
| 'PENDING'
| 'REJECTED'
| 'RETRY'
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>
export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>
export type Json = JsonValue
export type JsonArray = JsonValue[]
export type JsonObject = {
[x: string]: JsonValue | undefined
}
export type JsonPrimitive = boolean | number | string | null
export type JsonValue = JsonArray | JsonObject | JsonPrimitive
export type NotificationType =
| 'compliance'
| 'cryptoBalance'
| 'error'
| 'fiatBalance'
| 'highValueTransaction'
| 'security'
| 'transaction'
export type Numeric = ColumnType<string, number | string, number | string>
export type Role = 'superuser' | 'user'
export type SmsNoticeEvent =
| 'cash_out_dispense_ready'
| 'sms_code'
| 'sms_receipt'
export type StatusStage =
| 'authorized'
| 'confirmed'
| 'instant'
| 'insufficientFunds'
| 'notSeen'
| 'published'
| 'rejected'
export type Timestamp = ColumnType<Date, Date | string, Date | string>
export type TradeType = 'buy' | 'sell'
export type TransactionBatchStatus = 'failed' | 'open' | 'ready' | 'sent'
export type VerificationType = 'automatic' | 'blocked' | 'verified'
export interface AuthTokens {
expire: Generated<Timestamp>
token: string
type: AuthTokenType
userId: string | null
}
export interface Bills {
cashboxBatchId: string | null
cashInFee: Numeric
cashInTxsId: string
created: Generated<Timestamp>
cryptoCode: Generated<string | null>
destinationUnit: Generated<CashUnit>
deviceTime: Int8
fiat: number
fiatCode: string
id: string
legacy: Generated<boolean | null>
}
export interface Blacklist {
address: string
blacklistMessageId: Generated<string>
}
export interface BlacklistMessages {
allowToggle: Generated<boolean>
content: string
id: string
label: string
}
export interface CashInActions {
action: string
created: Generated<Timestamp>
error: string | null
errorCode: string | null
id: Generated<number>
txHash: string | null
txId: string
}
export interface CashInTxs {
batched: Generated<boolean>
batchId: string | null
batchTime: Timestamp | null
cashInFee: Numeric
commissionPercentage: Generated<Numeric | null>
created: Generated<Timestamp>
cryptoAtoms: Numeric
cryptoCode: string
customerId: Generated<string | null>
deviceId: string
discount: number | null
discountSource: DiscountSource | null
email: string | null
error: string | null
errorCode: string | null
fee: Int8 | null
fiat: Numeric
fiatCode: string
id: string
isPaperWallet: Generated<boolean | null>
minimumTx: number
operatorCompleted: Generated<boolean>
phone: string | null
rawTickerPrice: Generated<Numeric | null>
send: Generated<boolean>
sendConfirmed: Generated<boolean>
sendPending: Generated<boolean>
sendTime: Timestamp | null
termsAccepted: Generated<boolean>
timedout: Generated<boolean>
toAddress: string
txCustomerPhotoAt: Timestamp | null
txCustomerPhotoPath: string | null
txHash: string | null
txVersion: number
walletScore: number | null
}
export interface CashinTxTrades {
tradeId: Generated<number>
txId: string
}
export interface CashOutActions {
action: string
created: Generated<Timestamp>
denomination1: number | null
denomination2: number | null
denomination3: number | null
denomination4: number | null
denominationRecycler1: number | null
denominationRecycler2: number | null
denominationRecycler3: number | null
denominationRecycler4: number | null
denominationRecycler5: number | null
denominationRecycler6: number | null
deviceId: Generated<string>
deviceTime: Int8 | null
dispensed1: number | null
dispensed2: number | null
dispensed3: number | null
dispensed4: number | null
dispensedRecycler1: number | null
dispensedRecycler2: number | null
dispensedRecycler3: number | null
dispensedRecycler4: number | null
dispensedRecycler5: number | null
dispensedRecycler6: number | null
error: string | null
errorCode: string | null
id: Generated<number>
layer2Address: string | null
provisioned1: number | null
provisioned2: number | null
provisioned3: number | null
provisioned4: number | null
provisionedRecycler1: number | null
provisionedRecycler2: number | null
provisionedRecycler3: number | null
provisionedRecycler4: number | null
provisionedRecycler5: number | null
provisionedRecycler6: number | null
redeem: Generated<boolean>
rejected1: number | null
rejected2: number | null
rejected3: number | null
rejected4: number | null
rejectedRecycler1: number | null
rejectedRecycler2: number | null
rejectedRecycler3: number | null
rejectedRecycler4: number | null
rejectedRecycler5: number | null
rejectedRecycler6: number | null
toAddress: string | null
txHash: string | null
txId: string
}
export interface CashOutTxs {
commissionPercentage: Generated<Numeric | null>
confirmedAt: Timestamp | null
created: Generated<Timestamp>
cryptoAtoms: Numeric
cryptoCode: string
customerId: Generated<string | null>
denomination1: number | null
denomination2: number | null
denomination3: number | null
denomination4: number | null
denominationRecycler1: number | null
denominationRecycler2: number | null
denominationRecycler3: number | null
denominationRecycler4: number | null
denominationRecycler5: number | null
denominationRecycler6: number | null
deviceId: string
discount: number | null
discountSource: DiscountSource | null
dispense: Generated<boolean>
dispenseConfirmed: Generated<boolean | null>
email: string | null
error: string | null
errorCode: string | null
fiat: Numeric
fiatCode: string
fixedFee: Generated<Numeric>
hdIndex: Generated<number | null>
id: string
layer2Address: string | null
notified: Generated<boolean>
phone: string | null
provisioned1: number | null
provisioned2: number | null
provisioned3: number | null
provisioned4: number | null
provisionedRecycler1: number | null
provisionedRecycler2: number | null
provisionedRecycler3: number | null
provisionedRecycler4: number | null
provisionedRecycler5: number | null
provisionedRecycler6: number | null
publishedAt: Timestamp | null
rawTickerPrice: Generated<Numeric | null>
receivedCryptoAtoms: Generated<Numeric | null>
redeem: Generated<boolean>
status: Generated<StatusStage>
swept: Generated<boolean>
termsAccepted: Generated<boolean>
timedout: Generated<boolean>
toAddress: string
txCustomerPhotoAt: Timestamp | null
txCustomerPhotoPath: string | null
txVersion: number
walletScore: number | null
}
export interface CashoutTxTrades {
tradeId: Generated<number>
txId: string
}
export interface CashUnitOperation {
billCountOverride: number | null
created: Generated<Timestamp>
deviceId: string | null
id: string
operationType: CashUnitOperationType
performedBy: string | null
}
export interface ComplianceOverrides {
complianceType: ComplianceType
customerId: string | null
id: string
overrideAt: Timestamp
overrideBy: string | null
verification: VerificationType
}
export interface Coupons {
code: string
discount: number
id: string
softDeleted: Generated<boolean | null>
}
export interface CustomerCustomFieldPairs {
customerId: string
customFieldId: string
value: string
}
export interface CustomerExternalCompliance {
customerId: string
externalId: string
lastKnownStatus: ExternalComplianceStatus | null
lastUpdated: Generated<Timestamp>
service: string
}
export interface CustomerNotes {
content: Generated<string>
created: Generated<Timestamp>
customerId: string
id: string
lastEditedAt: Timestamp | null
lastEditedBy: string | null
title: Generated<string>
}
export interface Customers {
address: string | null
authorizedAt: Timestamp | null
authorizedOverride: Generated<VerificationType>
authorizedOverrideAt: Timestamp | null
authorizedOverrideBy: string | null
created: Generated<Timestamp>
email: string | null
emailAt: Timestamp | null
frontCameraAt: Timestamp | null
frontCameraOverride: Generated<VerificationType>
frontCameraOverrideAt: Timestamp | null
frontCameraOverrideBy: string | null
frontCameraPath: string | null
id: string
idCardData: Json | null
idCardDataAt: Timestamp | null
idCardDataExpiration: Timestamp | null
idCardDataNumber: string | null
idCardDataOverride: Generated<VerificationType>
idCardDataOverrideAt: Timestamp | null
idCardDataOverrideBy: string | null
idCardDataRaw: string | null
idCardPhotoAt: Timestamp | null
idCardPhotoOverride: Generated<VerificationType>
idCardPhotoOverrideAt: Timestamp | null
idCardPhotoOverrideBy: string | null
idCardPhotoPath: string | null
isTestCustomer: Generated<boolean>
lastAuthAttempt: Timestamp | null
lastUsedMachine: string | null
name: string | null
phone: string | null
phoneAt: Timestamp | null
phoneOverride: Generated<VerificationType>
phoneOverrideAt: Timestamp | null
phoneOverrideBy: string | null
sanctions: boolean | null
sanctionsAt: Timestamp | null
sanctionsOverride: Generated<VerificationType>
sanctionsOverrideAt: Timestamp | null
sanctionsOverrideBy: string | null
smsOverride: Generated<VerificationType>
smsOverrideAt: Timestamp | null
smsOverrideBy: string | null
subscriberInfo: Json | null
subscriberInfoAt: Timestamp | null
subscriberInfoBy: string | null
suspendedUntil: Timestamp | null
usSsn: string | null
usSsnAt: Timestamp | null
usSsnOverride: Generated<VerificationType>
usSsnOverrideAt: Timestamp | null
usSsnOverrideBy: string | null
}
export interface CustomersCustomInfoRequests {
customerData: Json
customerId: string
infoRequestId: string
override: Generated<VerificationType>
overrideAt: Timestamp | null
overrideBy: string | null
}
export interface CustomFieldDefinitions {
active: Generated<boolean | null>
id: string
label: string
}
export interface CustomInfoRequests {
customRequest: Json | null
enabled: Generated<boolean>
id: string
}
export interface Devices {
cassette1: Generated<number>
cassette2: Generated<number>
cassette3: Generated<number>
cassette4: Generated<number>
created: Generated<Timestamp>
deviceId: string
diagnosticsFrontUpdatedAt: Timestamp | null
diagnosticsScanUpdatedAt: Timestamp | null
diagnosticsTimestamp: Timestamp | null
display: Generated<boolean>
lastOnline: Generated<Timestamp>
location: Generated<Json>
model: string | null
name: string
numberOfCassettes: Generated<number>
numberOfRecyclers: Generated<number>
paired: Generated<boolean>
recycler1: Generated<number>
recycler2: Generated<number>
recycler3: Generated<number>
recycler4: Generated<number>
recycler5: Generated<number>
recycler6: Generated<number>
userConfigId: number | null
version: string | null
}
export interface EditedCustomerData {
created: Generated<Timestamp>
customerId: string
frontCameraAt: Timestamp | null
frontCameraBy: string | null
frontCameraPath: string | null
idCardData: Json | null
idCardDataAt: Timestamp | null
idCardDataBy: string | null
idCardPhotoAt: Timestamp | null
idCardPhotoBy: string | null
idCardPhotoPath: string | null
name: string | null
nameAt: Timestamp | null
nameBy: string | null
subscriberInfo: Json | null
subscriberInfoAt: Timestamp | null
subscriberInfoBy: string | null
usSsn: string | null
usSsnAt: Timestamp | null
usSsnBy: string | null
}
export interface EmptyUnitBills {
cashboxBatchId: string | null
created: Generated<Timestamp>
deviceId: string
fiat: number
fiatCode: string
id: string
}
export interface HardwareCredentials {
created: Generated<Timestamp | null>
data: Json
id: string
lastUsed: Generated<Timestamp | null>
userId: string
}
export interface IndividualDiscounts {
customerId: string
discount: number
id: string
softDeleted: Generated<boolean | null>
}
export interface Logs {
deviceId: string | null
id: string
logLevel: string | null
message: string | null
serial: Generated<number>
serverTimestamp: Generated<Timestamp>
timestamp: Timestamp | null
}
export interface MachineEvents {
created: Generated<Timestamp>
deviceId: string
deviceTime: Timestamp | null
eventType: string
id: string
note: string | null
}
export interface MachineNetworkHeartbeat {
averagePacketLoss: Numeric
averageResponseTime: Numeric
created: Generated<Timestamp>
deviceId: string
id: string
}
export interface MachineNetworkPerformance {
created: Generated<Timestamp>
deviceId: string
downloadSpeed: Numeric
}
export interface MachinePings {
deviceId: string
deviceTime: Timestamp
updated: Generated<Timestamp>
}
export interface Migrations {
data: Json
id: Generated<number>
}
export interface Notifications {
created: Generated<Timestamp>
detail: Json | null
id: string
message: string
read: Generated<boolean>
type: NotificationType
valid: Generated<boolean>
}
export interface OperatorIds {
id: Generated<number>
operatorId: string
service: string
}
export interface PairingTokens {
created: Generated<Timestamp>
id: Generated<number>
name: string
token: string | null
}
export interface SanctionsLogs {
created: Generated<Timestamp>
customerId: string
deviceId: string
id: string
sanctionedAliasFullName: string
sanctionedAliasId: string | null
sanctionedId: string
}
export interface ServerLogs {
deviceId: string | null
id: string
logLevel: string | null
message: string | null
meta: Json | null
timestamp: Generated<Timestamp | null>
}
export interface SmsNotices {
allowToggle: Generated<boolean>
created: Generated<Timestamp>
enabled: Generated<boolean>
event: SmsNoticeEvent
id: string
message: string
messageName: string
}
export interface Trades {
created: Generated<Timestamp>
cryptoAtoms: Numeric
cryptoCode: string
error: string | null
fiatCode: string
id: Generated<number>
type: TradeType
}
export interface TransactionBatches {
closedAt: Timestamp | null
createdAt: Generated<Timestamp>
cryptoCode: string
errorMessage: string | null
id: string
status: Generated<TransactionBatchStatus>
}
export interface UnpairedDevices {
deviceId: string
id: string
model: string | null
name: string | null
paired: Timestamp
unpaired: Timestamp
}
export interface UserConfig {
created: Generated<Timestamp>
data: Json
id: Generated<number>
schemaVersion: Generated<number>
type: string
valid: boolean
}
export interface UserRegisterTokens {
expire: Generated<Timestamp>
role: Generated<Role | null>
token: string
useFido: Generated<boolean | null>
username: string
}
export interface Users {
created: Generated<Timestamp>
enabled: Generated<boolean | null>
id: string
lastAccessed: Generated<Timestamp>
lastAccessedAddress: string | null
lastAccessedFrom: string | null
password: string | null
role: Generated<Role>
tempTwofaCode: string | null
twofaCode: string | null
username: string
}
export interface UserSessions {
expire: Timestamp
sess: Json
sid: string
}
export interface DB {
authTokens: AuthTokens
bills: Bills
blacklist: Blacklist
blacklistMessages: BlacklistMessages
cashInActions: CashInActions
cashInTxs: CashInTxs
cashinTxTrades: CashinTxTrades
cashOutActions: CashOutActions
cashOutTxs: CashOutTxs
cashoutTxTrades: CashoutTxTrades
cashUnitOperation: CashUnitOperation
complianceOverrides: ComplianceOverrides
coupons: Coupons
customerCustomFieldPairs: CustomerCustomFieldPairs
customerExternalCompliance: CustomerExternalCompliance
customerNotes: CustomerNotes
customers: Customers
customersCustomInfoRequests: CustomersCustomInfoRequests
customFieldDefinitions: CustomFieldDefinitions
customInfoRequests: CustomInfoRequests
devices: Devices
editedCustomerData: EditedCustomerData
emptyUnitBills: EmptyUnitBills
hardwareCredentials: HardwareCredentials
individualDiscounts: IndividualDiscounts
logs: Logs
machineEvents: MachineEvents
machineNetworkHeartbeat: MachineNetworkHeartbeat
machineNetworkPerformance: MachineNetworkPerformance
machinePings: MachinePings
migrations: Migrations
notifications: Notifications
operatorIds: OperatorIds
pairingTokens: PairingTokens
sanctionsLogs: SanctionsLogs
serverLogs: ServerLogs
smsNotices: SmsNotices
trades: Trades
transactionBatches: TransactionBatches
unpairedDevices: UnpairedDevices
userConfig: UserConfig
userRegisterTokens: UserRegisterTokens
users: Users
userSessions: UserSessions
}

Some files were not shown because too many files have changed in this diff Show more