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
.direnv
.envrc

View file

@ -4,42 +4,48 @@ on: [ workflow_dispatch ]
jobs:
everything:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
- name: Setup Turbo cache
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-updatetar
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-updatetar
- 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
${{ runner.os }}-turbo-
- 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: |
docker create --name extract_artifact ci_image:latest
docker cp extract_artifact:/lamassu-server.tar.gz ./lamassu-server.tar.gz
docker rm extract_artifact
# 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
# Create tarball
tar -zcf lamassu-server.tar.gz lamassu-server/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: 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:
build-and-publish:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
- name: Setup Turbo cache
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-updatetar
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
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
uses: docker/login-action@v3
@ -34,29 +69,24 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build and push
- name: Build and push server image
uses: docker/build-push-action@v5
with:
context: .
context: lamassu-server
push: true
target: l-s
file: ./build/server.Dockerfile
file: lamassu-server/server.Dockerfile
tags: ${{ env.DOCKERHUB_SERVER_REPO }}:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push
- name: Build and push admin server image
uses: docker/build-push-action@v5
with:
context: .
context: lamassu-server
push: true
target: l-a-s
file: ./build/server.Dockerfile
file: lamassu-server/server.Dockerfile
tags: ${{ env.DOCKERHUB_ADMIN_REPO }}:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
cache-from: type=gha
cache-to: type=gha,mode=max

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
**/.env
.pnpm-store/
.idea/
.settings/
.turbo/
packages/server/.lamassu
packages/server/certs/
packages/server/tests/stress/machines
packages/server/tests/stress/config.json
packages/typesafe-db/lib/

View file

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

View file

@ -1 +1,3 @@
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.
## 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)
See [lamassu-remote-install/README.md](lamassu-remote-install/README.md).
- Nodejs 22
- 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
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:
lamassu-server:
build:
context: .
dockerfile: build/server.Dockerfile
target: l-s
image: lamassu/lamassu-server:latest
restart: on-failure
ports:
- 3000:3000
network_mode: host
volumes:
- ./lamassu-data:/lamassu-data
environment:
- NODE_ENV=production
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres123
- POSTGRES_HOST=host.docker.internal
- POSTGRES_HOST=localhost
- POSTGRES_PORT=5432
- POSTGRES_DB=lamassu
- CA_PATH=/lamassu-data/certs/Lamassu_OP_Root_CA.pem
@ -31,20 +27,16 @@ services:
- LOG_LEVEL=info
lamassu-admin-server:
build:
context: .
dockerfile: build/server.Dockerfile
target: l-a-s
image: lamassu/lamassu-admin-server:latest
restart: on-failure
ports:
- 443:443
network_mode: host
volumes:
- ./lamassu-data:/lamassu-data
environment:
- NODE_ENV=production
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres123
- POSTGRES_HOST=host.docker.internal
- POSTGRES_HOST=localhost
- POSTGRES_PORT=5432
- POSTGRES_DB=lamassu
- CA_PATH=/lamassu-data/certs/Lamassu_OP_Root_CA.pem

View file

@ -1,49 +1,17 @@
FROM node:22-alpine AS build
RUN apk add --no-cache npm git curl build-base net-tools python3 postgresql-dev
FROM node:22-bullseye AS base
RUN apt install openssl ca-certificates
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/ ./
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
# Copy the pre-built production package from CI (with node_modules)
COPY . ./
FROM base AS l-s
RUN chmod +x /lamassu-server/bin/lamassu-server-entrypoint.sh
EXPOSE 3000
ENTRYPOINT [ "/lamassu-server/bin/lamassu-server-entrypoint.sh" ]
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
FROM base AS l-a-s
RUN chmod +x /lamassu-server/bin/lamassu-admin-server-entrypoint.sh
EXPOSE 443
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 reactCompiler from 'eslint-plugin-react-compiler'
import eslintConfigPrettier from 'eslint-config-prettier/flat'
import pluginJest from 'eslint-plugin-jest'
import vitest from 'eslint-plugin-vitest'
export default defineConfig([
globalIgnores([
'**/.lamassu',
'**/build',
'**/package.json',
'**/package-lock.json',
@ -59,16 +60,12 @@ export default defineConfig([
{
// update this to match your test files
files: ['**/*.spec.js', '**/*.test.js'],
plugins: { jest: pluginJest },
languageOptions: {
globals: pluginJest.environments.globals.globals,
plugins: {
vitest,
},
rules: {
'jest/no-disabled-tests': 'warn',
'jest/no-focused-tests': 'error',
'jest/no-identical-title': 'error',
'jest/prefer-to-have-length': 'warn',
'jest/valid-expect': 'error',
...vitest.configs.recommended.rules, // you can also use vitest.configs.all.rules to enable all rules
'vitest/max-nested-describe': ['error', { max: 3 }], // you can also modify rules' behavior using option like this
},
},
])

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

View file

@ -10,31 +10,27 @@ To take advantage of that make sure to run `git commit` from within this folder.
## 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>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
In the admin-ui package directory, you can run:
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.
### `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>
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>
Builds the app for production to the `build` folder.
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",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@mui/x-date-pickers": "^8.3.1",
"@simplewebauthn/browser": "^3.0.0",
"apollo-upload-client": "^18.0.0",
"bignumber.js": "9.0.0",
@ -25,9 +26,10 @@
"jszip": "^3.6.0",
"libphonenumber-js": "^1.11.15",
"match-sorter": "^4.2.0",
"material-react-table": "^3.2.1",
"pretty-ms": "^2.1.0",
"qrcode.react": "4.2.0",
"ramda": "^0.26.1",
"ramda": "^0.30.1",
"react": "18.3.1",
"react-copy-to-clipboard": "^5.0.2",
"react-dom": "18.3.1",
@ -49,12 +51,15 @@
"prettier": "3.4.1",
"tailwindcss": "^4.1.4",
"vite": "^6.0.1",
"vite-plugin-svgr": "^4.3.0"
"vite-plugin-svgr": "^4.3.0",
"vitest": "^3.1.4"
},
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"browserslist": {
"production": [

View file

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

View file

@ -1,4 +1,3 @@
import PropTypes from 'prop-types'
import React from 'react'
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 { cardState }

View file

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

View file

@ -213,7 +213,7 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
}
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) {
return elements

View file

@ -73,7 +73,7 @@ const ETable = ({
setSaving(true)
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)
if (!R.equals(data[index], it)) {

View file

@ -24,7 +24,7 @@ const Autocomplete = ({
autoFocus,
...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 getValue = () => {

View file

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

View file

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

View file

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

View file

@ -4,7 +4,8 @@ import React, { memo } from 'react'
const TableRow = memo(
({ className, children, header, error, success, size = 'sm', ...props }) => {
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-9 font-bold text-base ': !header && size === 'lg',
'bg-misty-rose': error,

View file

@ -163,7 +163,7 @@ const Analytics = () => {
const convertFiatToLocale = 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 multiplier = localeRate?.rate / itemRate?.rate
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" />}
{wizard && (
<Wizard
machine={R.find(R.propEq('deviceId', wizard))(machines)}
machine={R.find(R.propEq(wizard, 'deviceId'))(machines)}
onClose={() => setWizard(false)}
save={save}
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({
step: step + 1,

View file

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

View file

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

View file

@ -42,7 +42,7 @@ const getView = (data, code, compare) => it => {
if (!data) return ''
// 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'
}
@ -294,8 +294,8 @@ const getAlreadyUsed = (id, machine, values) => {
const getCrypto = R.prop('cryptoCurrencies')
const getMachineId = R.prop('machine')
const filteredOverrides = R.filter(R.propEq('machine', machine))(values)
const originalValue = R.find(R.propEq('id', id))(values)
const filteredOverrides = R.filter(R.propEq(machine, 'machine'))(values)
const originalValue = R.find(R.propEq(id, 'id'))(values)
const originalCryptos = getCrypto(originalValue)
const originalMachineId = getMachineId(originalValue)
@ -407,7 +407,7 @@ const overridesDefaults = {
const getOrder = ({ machine, cryptoCurrencies }) => {
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) return 1

View file

@ -144,7 +144,7 @@ const CustomerData = ({
deleteEditedData: () => deleteEditedData({ idCardData: null }),
save: values =>
editCustomer({
idCardData: R.merge(idData, formatDates(values)),
idCardData: R.mergeRight(idData, formatDates(values)),
}),
validationSchema: customerDataSchemas.idCardData,
checkAgainstSanctions: () =>
@ -167,7 +167,7 @@ const CustomerData = ({
save: values => {
editCustomer({
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 React, { useState } from 'react'
import { useLocation } from 'wouter'
import SearchBox from '../../components/SearchBox'
import SearchFilter from '../../components/SearchFilter'
import TitleSection from '../../components/layout/TitleSection'
import TxInIcon from '../../styling/icons/direction/cash-in.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 { getAuthorizedStatus } from './helper'
const GET_CUSTOMER_FILTERS = gql`
query filters {
customerFilters {
type
value
}
}
`
const GET_CUSTOMERS = gql`
query configAndCustomers(
$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 [, navigate] = useLocation()
const handleCustomerClicked = customer =>
navigate(`/compliance/customer/${customer.id}`)
const [filteredCustomers, setFilteredCustomers] = useState([])
const [variables, setVariables] = useState({})
const [filters, setFilters] = useState([])
const [customers, setCustomers] = useState([])
const [showCreationModal, setShowCreationModal] = useState(false)
const {
data: customersResponse,
loading: customerLoading,
refetch,
} = useQuery(GET_CUSTOMERS, {
variables,
onCompleted: data => setFilteredCustomers(R.path(['customers'])(data)),
})
const { data: filtersResponse, loading: loadingFilters } =
useQuery(GET_CUSTOMER_FILTERS)
const { data: customersResponse, loading: customerLoading } = useQuery(
GET_CUSTOMERS,
{
onCompleted: data => setCustomers(R.path(['customers'])(data)),
},
)
const [createNewCustomer] = useMutation(CREATE_CUSTOMER, {
onCompleted: () => setShowCreationModal(false),
refetchQueries: () => [
{
query: GET_CUSTOMERS,
variables,
},
],
})
@ -145,76 +123,12 @@ const Customers = () => {
const customersData = R.pipe(
R.map(setAuthorizedStatus),
R.sortWith([R.ascend(byAuthorized), R.descend(byLastActive)]),
)(filteredCustomers ?? [])
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)
)(customers ?? [])
return (
<>
<TitleSection
title="Customers"
appendix={
<div className="flex ml-4">
<SearchBox
loading={loadingFilters}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search customers'}
onChange={onFilterChange}
/>
</div>
}
appendixRight={
<div className="flex">
<Link color="primary" onClick={() => setShowCreationModal(true)}>
@ -227,21 +141,11 @@ const Customers = () => {
{ label: 'Cash-out', icon: <TxOutIcon /> },
]}
/>
{filters.length > 0 && (
<SearchFilter
entries={customersData.length}
filters={filters}
onFilterDelete={onFilterDelete}
deleteAllFilters={deleteAllFilters}
/>
)}
<CustomersList
data={customersData}
locale={locale}
country={locale?.country}
onClick={handleCustomerClicked}
loading={customerLoading}
triggers={triggers}
customRequests={customRequirementsData}
/>
<CreateCustomerModal
showModal={showCreationModal}

View file

@ -1,78 +1,141 @@
import Visibility from '@mui/icons-material/Visibility'
import { format } from 'date-fns/fp'
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 DataTable from '../../components/tables/DataTable'
import TxInIcon from '../../styling/icons/direction/cash-in.svg?react'
import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react'
import {
defaultMaterialTableOpts,
alignRight,
} from '../../utils/materialReactTableOpts'
import { getFormattedPhone, getName } from './helper'
const CustomersList = ({ data, locale, onClick, loading }) => {
const elements = [
{
header: 'Phone/email',
width: 199,
view: it => `${getFormattedPhone(it.phone, locale.country) || ''}
${it.email || ''}`,
},
{
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}
</>
)
const CustomersList = ({ data, country, onClick, loading }) => {
const columns = useMemo(
() => [
{
accessorKey: 'id',
header: 'ID',
size: 315,
},
{
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'] },
},
{
header: 'Status',
width: 191,
view: it => <MainStatus statuses={[it.authorizedStatus]} />,
},
]
state: { isLoading: loading },
getRowId: it => it.id,
enableRowActions: true,
renderRowActionMenuItems: ({ row }) => [
<MRT_ActionMenuItem //or just use a normal MUI MenuItem component
icon={<Visibility />}
key="view"
label="View"
onClick={() => onClick(row)}
table={table}
/>,
],
})
return (
<>
<DataTable
loading={loading}
emptyText="No customers so far"
elements={elements}
data={data}
onClick={onClick}
/>
<MaterialReactTable table={table} />
</>
)
}

View file

@ -45,43 +45,28 @@ const getAuthorizedStatus = (it, triggers, customRequests) => {
)
}
const pendingFieldStatus = 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, 'automatic')
const getFieldsByStatus = status =>
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, 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)
? it[`${ite}Path`]
: 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)
const pendingFieldStatus = getFieldsByStatus('automatic')
const rejectedFieldStatus = getFieldsByStatus('blocked')
if (it.authorizedOverride === CUSTOMER_BLOCKED)
return { label: 'Blocked', type: 'error' }
@ -235,7 +220,7 @@ const ManualDataEntry = ({ selectedValues, customInfoRequirementOptions }) => {
: requirementOptions
const requirementName = displayRequirements
? R.find(R.propEq('code', requirementSelected))(updatedRequirementOptions)
? R.find(R.propEq(requirementSelected, 'code'))(updatedRequirementOptions)
.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 Button from '@mui/material/Button'
import Grid from '@mui/material/Grid'
import classnames from 'classnames'
import * as R from 'ramda'
import React from 'react'
@ -56,11 +55,9 @@ const Alerts = ({ onReset, onExpand, size }) => {
</Label1>
)}
</div>
<Grid
className={classnames({ 'm-0': true, 'max-h-115': showAllItems })}
container
spacing={1}>
<Grid item xs={12}>
<div
className={classnames({ 'm-0 mt-2': true, 'max-h-115': showAllItems })}>
<div className="w-full flex-1">
{!alerts.length && (
<Label1 className="text-comet -ml-1 h-30">
No new alerts. Your system is running smoothly.
@ -71,10 +68,10 @@ const Alerts = ({ onReset, onExpand, size }) => {
alerts={alerts}
machines={machines}
/>
</Grid>
</Grid>
</div>
</div>
{!showAllItems && alertsLength > NUM_TO_RENDER && (
<Grid item xs={12}>
<div>
<Label1 className="text-center mb-0">
<Button
onClick={() => onExpand('alerts')}
@ -85,7 +82,7 @@ const Alerts = ({ onReset, onExpand, size }) => {
{`Show all (${alerts.length})`}
</Button>
</Label1>
</Grid>
</div>
)}
</>
)

View file

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

View file

@ -40,9 +40,9 @@ const Footer = () => {
const localeFiatCurrency = R.path(['locale_fiatCurrency'])(config) ?? ''
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 tickerIdx = R.findIndex(R.propEq('code', tickerCode))(accountsConfig)
const tickerIdx = R.findIndex(R.propEq(tickerCode, 'code'))(accountsConfig)
const tickerName = tickerIdx > -1 ? accountsConfig[tickerIdx].display : ''

View file

@ -1,7 +1,6 @@
import { useQuery, gql } from '@apollo/client'
import BigNumber from 'bignumber.js'
import classnames from 'classnames'
import { isAfter } from 'date-fns/fp'
import * as R from 'ramda'
import React, { useState } from 'react'
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 { DAY, WEEK, MONTH } from '../../../utils/time'
import { timezones } from '../../../utils/timezone-list'
import { toTimezone } from '../../../utils/timezones'
import PercentageChart from './Graphs/PercentageChart'
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 GET_DATA = gql`
query getData($excludeTestingCustomers: Boolean) {
transactions(excludeTestingCustomers: $excludeTestingCustomers) {
query getData($excludeTestingCustomers: Boolean, $from: DateTimeISO) {
transactions(
excludeTestingCustomers: $excludeTestingCustomers
from: $from
) {
fiatCode
fiat
fixedFee
@ -49,10 +50,17 @@ const GET_DATA = gql`
}
`
const twoMonthsAgo = new Date()
twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2)
const SystemPerformance = () => {
const [selectedRange, setSelectedRange] = useState('Day')
const { data, loading } = useQuery(GET_DATA, {
variables: { excludeTestingCustomers: true },
variables: {
excludeTestingCustomers: true,
from: twoMonthsAgo.toISOString(),
},
})
const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency
const timezone = fromNamespace('locale')(data?.config).timezone
@ -69,38 +77,41 @@ const SystemPerformance = () => {
if (t.error !== null) return false
if (t.txClass === 'cashOut' && !t.dispense) 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 (
t.error === null &&
isAfter(
toTimezone(t.created, timezone),
toTimezone(periodDomains[selectedRange][1], timezone),
) &&
isAfter(
toTimezone(periodDomains[selectedRange][0], timezone),
toTimezone(t.created, timezone),
)
createdTimestamp >= rangeStart - duration &&
createdTimestamp < rangeStart
)
}
return (
t.error === null &&
isAfter(
toTimezone(periodDomains[selectedRange][1], timezone),
toTimezone(t.created, timezone),
) &&
isAfter(
toTimezone(t.created, timezone),
toTimezone(periodDomains[selectedRange][0], timezone),
)
createdTimestamp >= rangeStart &&
createdTimestamp <= rangeEnd
)
}
const convertFiatToLocale = item => {
if (item.fiatCode === fiatLocale) return item
const itemRate = R.find(R.propEq('code', item.fiatCode))(data.fiatRates)
const localeRate = R.find(R.propEq('code', fiatLocale))(data.fiatRates)
if (item.fiatCode === fiatLocale)
return {
...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
return { ...item, fiat: parseFloat(item.fiat) * multiplier }
return {
...item,
fiat: parseFloat(item.fiat) * multiplier,
profit: parseFloat(item.profit) * multiplier,
}
}
const transactionsToShow = R.map(convertFiatToLocale)(
@ -140,7 +151,7 @@ const SystemPerformance = () => {
}
const getDirectionPercent = () => {
const [cashIn, cashOut] = R.partition(R.propEq('txClass', 'cashIn'))(
const [cashIn, cashOut] = R.partition(R.propEq('cashIn', 'txClass'))(
transactionsToShow,
)
const totalLength = cashIn.length + cashOut.length
@ -177,7 +188,10 @@ const SystemPerformance = () => {
handleSetRange={setSelectedRange}
/>
{!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) && (
<div className="flex flex-col gap-12">

View file

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

View file

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

View file

@ -52,8 +52,8 @@ const Commissions = ({ name: SCREEN_KEY, id: deviceId }) => {
const overrides = config.overrides
? R.concat(
R.filter(R.propEq('machine', 'ALL_MACHINES'), config.overrides),
R.filter(R.propEq('machine', deviceId), config.overrides),
R.filter(R.propEq('ALL_MACHINES', 'machine'), 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
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">
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" />}>
<Link to="/dashboard" className="no-underline">

View file

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

View file

@ -78,7 +78,7 @@ const CashboxHistory = ({ machines, currency, timezone }) => {
textAlign: 'left',
view: R.pipe(
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.prop('name'),
),

View file

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

View file

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

View file

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

View file

@ -54,7 +54,7 @@ const FiatBalanceOverrides = ({ config, section }) => {
)
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] : []
}
@ -127,7 +127,7 @@ const FiatBalanceOverrides = ({ config, section }) => {
)
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(
[

View file

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

View file

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

View file

@ -83,7 +83,7 @@ const Services = () => {
const getAccounts = ({ elements, 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 passwordFields = R.compose(
mapToCode,

View file

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

View file

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

View file

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

View file

@ -23,7 +23,7 @@ const buildAdvancedRequirementOptions = customInfoRequests => {
const displayRequirement = (code, customInfoRequests) => {
return R.prop(
'display',
R.find(R.propEq('code', code))(
R.find(R.propEq(code, 'code'))(
buildAdvancedRequirementOptions(customInfoRequests),
),
)
@ -47,7 +47,7 @@ const getOverridesSchema = (values, customInfoRequests) => {
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
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({
message: `Requirement '${displayRequirement(
requirement,

View file

@ -161,7 +161,7 @@ const Type = ({ ...props }) => {
'text-tomato': errors.triggerType && touched.triggerType,
}
const containsType = R.contains(values?.triggerType)
const containsType = R.includes(values?.triggerType)
const isThresholdCurrencyEnabled = containsType(['txAmount', 'txVolume'])
const isTransactionAmountEnabled = containsType(['txVelocity'])
const isThresholdDaysEnabled = containsType(['txVolume', 'txVelocity'])
@ -542,7 +542,7 @@ const requirements = (
const getView = (data, code, compare) => it => {
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 => {

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import { CURRENCY_MAX } from '../../utils/constants'
import { defaultToZero } from '../../utils/number'
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({
ticker: Yup.string('The ticker must be a string').required(
@ -36,6 +36,7 @@ const AdvancedWalletSchema = Yup.object().shape({
cryptoUnits: Yup.string().required(),
feeMultiplier: Yup.string().required(),
allowTransactionBatching: Yup.boolean(),
enableLastUsedAddress: Yup.boolean(),
})
const OverridesSchema = Yup.object().shape({
@ -57,7 +58,7 @@ const OverridesDefaults = {
}
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 = [
{ display: '+60%', code: '1.6' },
@ -127,6 +128,17 @@ const getAdvancedWalletElements = () => {
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 currencyDisplay = R.compose(
it => `${R.prop(['display'])(it)} ${it?.isBeta ? '(Beta)' : ''}`,
R.find(R.propEq('code', it)),
R.find(R.propEq(it, 'code')),
)(cryptoCurrencies)
return currencyDisplay
}
@ -213,7 +225,7 @@ const getElements = (cryptoCurrencies, accounts, onChange, wizard = false) => {
const getDisplayName = type => it =>
R.compose(
R.prop('display'),
R.find(R.propEq('code', it)),
R.find(R.propEq(it, 'code')),
)(filterOptions(type))
const getOptions = R.curry((option, it) =>

View file

@ -36,7 +36,7 @@ const SAVE_ACCOUNTS = gql`
`
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 { data } = useQuery(GET_CONFIG)

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,8 @@ const { p } = typographyStyles
let theme = createTheme({
typography: {
fontFamily: inputFontFamily,
root: { ...p },
body1: { ...p },
},
palette: {
primary: {
@ -56,6 +58,18 @@ theme = createTheme(theme, {
body1: { ...p },
},
},
MuiCircularProgress: {
styleOverrides: {
root: {
color: primaryColor,
},
},
},
MuiTableCell: {
styleOverrides: {
root: { ...p },
},
},
MuiIconButtonBase: {
defaultProps: {
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
process.env.SKIP_SERVER_LOGS = true
db.none(createMigration)
.then(() => migrate.run())
.then(() => {
console.log('DB Migration succeeded.')
process.exit(0)
function checkPostgresVersion () {
return db.one('SHOW server_version;')
.then(result => {
console.log(result)
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 => {
console.error('DB Migration failed: %s', err)

View file

@ -9,6 +9,7 @@ const logger = require('../logger')
const settingsLoader = require('../new-settings-loader')
const configManager = require('../new-config-manager')
const notifier = require('../notifier')
const constants = require('../constants')
const cashInAtomic = require('./cash-in-atomic')
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) {
const sql = `
SELECT EXISTS (
SELECT DISTINCT to_address FROM (
SELECT to_address FROM cash_in_txs WHERE id != $1
) AS x WHERE to_address = $2
)`
return db.one(sql, [tx.id, tx.toAddress]).then(({ exists }) => exists)
SELECT COUNT(*) > 1 as exists
FROM (SELECT DISTINCT customer_id
FROM cash_in_txs
WHERE to_address = $1
AND customer_id != $3
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) {

View file

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

View file

@ -8,16 +8,17 @@ const fs = require('fs')
const util = require('util')
const db = require('./db')
const anonymous = require('../lib/constants').anonymousCustomer
const complianceOverrides = require('./compliance_overrides')
const writeFile = util.promisify(fs.writeFile)
const notifierQueries = require('./notifier/queries')
const notifierUtils = require('./notifier/utils')
const NUM_RESULTS = 1000
const sms = require('./sms')
const settingsLoader = require('./new-settings-loader')
const logger = require('./logger')
const externalCompliance = require('./compliance-external')
const {
customers: { getCustomerList },
} = require('typesafe-db')
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) {
const sql = `SELECT id, phone, id_card_data
FROM customers
@ -512,88 +491,8 @@ function getSlimCustomerByIdBatch(ids) {
return db.any(sql, [ids]).then(customers => _.map(camelize, customers))
}
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
/**
* 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,
),
),
)
function getCustomersList() {
return getCustomerList({ withCustomInfoRequest: true })
}
/**
@ -1081,12 +980,10 @@ function notifyApprovedExternalCompliance(settings, customerId) {
function checkExternalCompliance(settings) {
return getOpenExternalCompliance().then(externals => {
console.log(externals)
const promises = _.map(external => {
return externalCompliance
.getStatus(settings, external.service, external.customer_id)
.then(status => {
console.log('status', status, external.customer_id, external.service)
if (status.status.answer === RETRY)
notifyRetryExternalCompliance(
settings,
@ -1112,12 +1009,16 @@ function addExternalCompliance(customerId, service, id) {
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 = {
add,
addWithEmail,
get,
getWithEmail,
batch,
getSlimCustomerByIdBatch,
getCustomersList,
getCustomerById,
@ -1139,4 +1040,5 @@ module.exports = {
updateLastAuthAttempt,
addExternalCompliance,
checkExternalCompliance,
getLastUsedAddress,
}

View file

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

View file

@ -30,7 +30,7 @@ function getBitPayFxRate(
fiatCodeProperty,
rateProperty,
) {
return getFiatRates().then(({ data: fxRates }) => {
return getFiatRates().then(fxRates => {
const defaultFiatRate = findCurrencyRates(
fxRates,
defaultFiatMarket,
@ -69,14 +69,15 @@ const getRate = (retries = 1, fiatCode, defaultFiatMarket) => {
defaultFiatMarket,
fiatCodeProperty,
rateProperty,
).catch(() => {
// Switch service
).catch(err => {
const erroredService = API_QUEUE.shift()
API_QUEUE.push(erroredService)
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) {
rec.operatorId = operatorId
switch (rec.action) {
@ -681,4 +718,5 @@ module.exports = {
refillMachineUnits,
updateDiagnostics,
updateFailedQRScans,
batchDiagnostics,
}

View file

@ -27,18 +27,5 @@ function transaction() {
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, customer }
module.exports = { transaction }

View file

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

View file

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

View file

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

View file

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

View file

@ -53,7 +53,12 @@ function getTx(req, res, next) {
return helpers
.fetchStatusTx(req.params.id, req.query.status)
.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))

View file

@ -8,6 +8,7 @@ const T = require('./time')
// FP operations on Postgres result in very big errors.
// E.g.: 1853.013808 * 1000 = 1866149.494
const REDEEMABLE_AGE = T.day / 1000
const MAX_THRESHOLD_DAYS = 365 * 50 // 50 years maximum
function process(tx, pi) {
const mtx = massage(tx)
@ -92,7 +93,9 @@ function customerHistory(customerId, thresholdDays) {
AND fiat > 0
) 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])
}

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-queue": "^6.6.2",
"p-retry": "^4.4.0",
"pg-native": "^3.0.0",
"pg-promise": "^10.10.2",
"pify": "^3.0.0",
"pretty-ms": "^2.1.0",
@ -89,6 +88,7 @@
"telnyx": "^1.25.5",
"tronweb": "^5.3.0",
"twilio": "^3.6.1",
"typesafe-db": "workspace:*",
"uuid": "8.3.2",
"web3": "1.7.1",
"winston": "^2.4.2",
@ -123,34 +123,16 @@
"lamassu-eth-recovery": "./bin/lamassu-eth-recovery",
"lamassu-trx-recovery": "./bin/lamassu-trx-recovery",
"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": {
"start": "node bin/lamassu-server",
"test": "mocha --recursive tests",
"jtest": "jest --detectOpenHandles",
"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\"",
"dev": "concurrently \"npm:server\" \"npm:admin-server\"",
"server": "node --watch bin/lamassu-server --mockScoring --logLevel silly",
"admin-server": "node --watch bin/lamassu-admin-server --dev --logLevel silly",
"stress-test": "cd tests/stress/ && node index.js 50 -v"
},
"nodemonConfig": {
"ignore": [
"new-lamassu-admin/*"
]
},
"devDependencies": {
"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"
]
"concurrently": "^5.3.0"
}
}

View file

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

View file

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