From e0940e9dcf8fa3e0a249e42e01b9b35fc2f997f6 Mon Sep 17 00:00:00 2001 From: oana-lolea Date: Mon, 7 Jul 2025 17:27:12 +0300 Subject: [PATCH 01/22] feat: created the backbone for the card service (#3508) * Created the backbone for the card service * Format fix * feat: add card service to docker compose --------- Co-authored-by: Nathan Lie --- localenv/admin-auth/dbinit.sql | 8 + localenv/cloud-nine-wallet/dbinit.sql | 8 + localenv/cloud-nine-wallet/docker-compose.yml | 32 ++++ localenv/happy-life-bank/docker-compose.yml | 32 ++++ packages/card-service/Dockerfile.dev | 35 ++++ packages/card-service/README.md | 1 + packages/card-service/jest.config.js | 17 ++ packages/card-service/knexfile.js | 47 +++++ packages/card-service/package.json | 30 +++ packages/card-service/src/app.ts | 116 ++++++++++++ packages/card-service/src/config/app.ts | 33 ++++ packages/card-service/src/index.ts | 177 ++++++++++++++++++ packages/card-service/tsconfig.json | 12 ++ pnpm-lock.yaml | 168 ++++++++++++----- 14 files changed, 665 insertions(+), 51 deletions(-) create mode 100644 packages/card-service/Dockerfile.dev create mode 100644 packages/card-service/README.md create mode 100644 packages/card-service/jest.config.js create mode 100644 packages/card-service/knexfile.js create mode 100644 packages/card-service/package.json create mode 100644 packages/card-service/src/app.ts create mode 100644 packages/card-service/src/config/app.ts create mode 100644 packages/card-service/src/index.ts create mode 100644 packages/card-service/tsconfig.json diff --git a/localenv/admin-auth/dbinit.sql b/localenv/admin-auth/dbinit.sql index 1052df19a3..0bb0acbe58 100644 --- a/localenv/admin-auth/dbinit.sql +++ b/localenv/admin-auth/dbinit.sql @@ -6,6 +6,10 @@ CREATE USER cloud_nine_wallet_auth WITH PASSWORD 'cloud_nine_wallet_auth'; CREATE DATABASE cloud_nine_wallet_auth; ALTER DATABASE cloud_nine_wallet_auth OWNER TO cloud_nine_wallet_auth; +CREATE USER cloud_nine_wallet_card WITH PASSWORD 'cloud_nine_wallet_card'; +CREATE DATABASE cloud_nine_wallet_card; +ALTER DATABASE cloud_nine_wallet_card OWNER TO cloud_nine_wallet_card; + CREATE USER happy_life_bank_backend WITH PASSWORD 'happy_life_bank_backend'; CREATE DATABASE happy_life_bank_backend; ALTER DATABASE happy_life_bank_backend OWNER TO happy_life_bank_backend; @@ -14,6 +18,10 @@ CREATE USER happy_life_bank_auth WITH PASSWORD 'happy_life_bank_auth'; CREATE DATABASE happy_life_bank_auth; ALTER DATABASE happy_life_bank_auth OWNER TO happy_life_bank_auth; +CREATE USER happy_life_bank_card WITH PASSWORD 'happy_life_bank_card'; +CREATE DATABASE happy_life_bank_card; +ALTER DATABASE happy_life_bank_card OWNER TO happy_life_bank_card; + CREATE USER happy_life_kratos WITH PASSWORD 'kratos_password'; CREATE DATABASE happy_life_kratos; ALTER DATABASE happy_life_kratos OWNER TO happy_life_kratos; diff --git a/localenv/cloud-nine-wallet/dbinit.sql b/localenv/cloud-nine-wallet/dbinit.sql index 0fe8c3e88b..b93a62b880 100644 --- a/localenv/cloud-nine-wallet/dbinit.sql +++ b/localenv/cloud-nine-wallet/dbinit.sql @@ -6,6 +6,10 @@ CREATE USER cloud_nine_wallet_auth WITH PASSWORD 'cloud_nine_wallet_auth'; CREATE DATABASE cloud_nine_wallet_auth; ALTER DATABASE cloud_nine_wallet_auth OWNER TO cloud_nine_wallet_auth; +CREATE USER cloud_nine_wallet_card_service WITH PASSWORD 'cloud_nine_wallet_card_service'; +CREATE DATABASE cloud_nine_wallet_card_service; +ALTER DATABASE cloud_nine_wallet_card_service OWNER TO cloud_nine_wallet_card_service; + CREATE USER happy_life_bank_backend WITH PASSWORD 'happy_life_bank_backend'; CREATE DATABASE happy_life_bank_backend; ALTER DATABASE happy_life_bank_backend OWNER TO happy_life_bank_backend; @@ -13,3 +17,7 @@ ALTER DATABASE happy_life_bank_backend OWNER TO happy_life_bank_backend; CREATE USER happy_life_bank_auth WITH PASSWORD 'happy_life_bank_auth'; CREATE DATABASE happy_life_bank_auth; ALTER DATABASE happy_life_bank_auth OWNER TO happy_life_bank_auth; + +CREATE USER happy_life_bank_card_service WITH PASSWORD 'happy_life_bank_card_service'; +CREATE DATABASE happy_life_bank_card_service; +ALTER DATABASE happy_life_bank_card_service OWNER TO happy_life_bank_card_service; \ No newline at end of file diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 271024d306..64b3830afb 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -1,5 +1,37 @@ name: c9 services: + cloud-nine-wallet-card-service: + hostname: cloud-nine-wallet-card-service + image: rafiki-card-service + build: + context: ../.. + dockerfile: ./packages/card-service/Dockerfile.dev + restart: always + networks: + - rafiki + ports: + - '3007:3007' + volumes: + - type: bind + source: ../../packages/card-service/src + target: /home/rafiki/packages/card-service/src + read_only: true + environment: + NODE_ENV: ${NODE_ENV:-development} + INSTANCE_NAME: CLOUD-NINE + TRUST_PROXY: ${TRUST_PROXY} + LOG_LEVEL: debug + CARD_SERVICE_PORT: 3007 + DATABASE_URL: postgresql://cloud_nine_wallet_card_service:cloud_nine_wallet_card_service@shared-database/cloud_nine_wallet_card_service + depends_on: + - shared-database + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:3007/healthz"] + start_period: 60s + start_interval: 5s + interval: 30s + retries: 1 + timeout: 3s cloud-nine-mock-ase: hostname: cloud-nine-wallet image: rafiki-mock-ase diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 88335d627a..5d17f1d074 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -1,5 +1,37 @@ name: hl services: + happy-life-bank-card-service: + hostname: happy-life-bank-card-service + image: rafiki-card-service + build: + context: ../.. + dockerfile: ./packages/card-service/Dockerfile.dev + restart: always + networks: + - rafiki + ports: + - '4007:3007' + volumes: + - type: bind + source: ../../packages/card-service/src + target: /home/rafiki/packages/card-service/src + read_only: true + environment: + NODE_ENV: ${NODE_ENV:-development} + INSTANCE_NAME: CLOUD-NINE + TRUST_PROXY: ${TRUST_PROXY} + LOG_LEVEL: debug + CARD_SERVICE_PORT: 3007 + DATABASE_URL: postgresql://happy_life_bank_card_service:happy_life_bank_card_service@shared-database/happy_life_bank_card_service + depends_on: + - shared-database + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:3007/healthz"] + start_period: 60s + start_interval: 5s + interval: 30s + retries: 1 + timeout: 3s happy-life-mock-ase: hostname: happy-life-bank image: rafiki-mock-ase diff --git a/packages/card-service/Dockerfile.dev b/packages/card-service/Dockerfile.dev new file mode 100644 index 0000000000..3051d5401c --- /dev/null +++ b/packages/card-service/Dockerfile.dev @@ -0,0 +1,35 @@ +FROM node:20-alpine3.20 + +RUN adduser -D rafiki +WORKDIR /home/rafiki + +# Install Corepack and pnpm as the Rafiki user +USER rafiki +RUN mkdir -p /home/rafiki/.local/bin +ENV PATH="/home/rafiki/.local/bin:$PATH" +RUN corepack enable --install-directory ~/.local/bin +RUN corepack prepare pnpm@8.7.4 --activate +COPY pnpm-lock.yaml package.json pnpm-workspace.yaml .npmrc tsconfig.json tsconfig.build.json ./ + +# Fetch the pnpm dependencies, but use a local cache. +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm fetch \ + | grep -v "cross-device link not permitted\|Falling back to copying packages from store" + +# Copy the source code and chown the relevant folders back to the Rafiki user +USER root +COPY . ./ +RUN chown -v -R rafiki:rafiki /home/rafiki/localenv +RUN chown -v -R rafiki:rafiki /home/rafiki/packages +RUN chown -v -R rafiki:rafiki /home/rafiki/test + +# As the Rafiki user, install the rest of the dependencies and build the source code +USER rafiki +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install \ + --recursive \ + --offline \ + --frozen-lockfile +RUN pnpm --filter card-service build:deps + +CMD pnpm --filter card-service dev \ No newline at end of file diff --git a/packages/card-service/README.md b/packages/card-service/README.md new file mode 100644 index 0000000000..d8227256ec --- /dev/null +++ b/packages/card-service/README.md @@ -0,0 +1 @@ +# Card Service diff --git a/packages/card-service/jest.config.js b/packages/card-service/jest.config.js new file mode 100644 index 0000000000..a2667d704f --- /dev/null +++ b/packages/card-service/jest.config.js @@ -0,0 +1,17 @@ +'use strict' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const baseConfig = require('../../jest.config.base.js') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const packageName = require('./package.json').name + +module.exports = { + ...baseConfig, + clearMocks: true, + roots: [`/packages/${packageName}`], + testRegex: `(packages/${packageName}/.*/__tests__/.*|\\.(test|spec))\\.tsx?$`, + moduleDirectories: [`node_modules`, `packages/${packageName}/node_modules`], + modulePaths: [`/packages/${packageName}/src/`], + id: packageName, + displayName: packageName, + rootDir: '../..' +} diff --git a/packages/card-service/knexfile.js b/packages/card-service/knexfile.js new file mode 100644 index 0000000000..6f8e2606ed --- /dev/null +++ b/packages/card-service/knexfile.js @@ -0,0 +1,47 @@ +// Update with your config settings. + +module.exports = { + development: { + client: 'postgresql', + connection: { + database: 'development', + user: 'postgres', + password: 'password' + }, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'knex_migrations' + } + }, + + testing: { + client: 'postgresql', + connection: { + database: 'testing', + user: 'postgres', + password: 'password' + }, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'knex_migrations' + } + }, + + production: { + client: 'postgresql', + connection: process.env.DATABASE_URL, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'knex_migrations' + } + } +} diff --git a/packages/card-service/package.json b/packages/card-service/package.json new file mode 100644 index 0000000000..aeadc35993 --- /dev/null +++ b/packages/card-service/package.json @@ -0,0 +1,30 @@ +{ + "name": "card-service", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "pnpm clean && tsc --build tsconfig.json", + "clean": "rm -fr dist/", + "test": "jest", + "dev": "ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only src/index.ts", + "knex": "knex" + }, + "dependencies": { + "@adonisjs/fold": "^8.2.0", + "@koa/cors": "^5.0.0", + "knex": "^3.1.0", + "koa": "^2.15.4", + "objection": "^3.1.5", + "pino": "^8.19.0" + }, + "devDependencies": { + "@types/koa": "2.15.0", + "@types/koa-bodyparser": "^4.3.12", + "@types/koa__cors": "^5.0.0", + "@types/koa__router": "^12.0.4", + "ts-node-dev": "^2.0.0" + } +} diff --git a/packages/card-service/src/app.ts b/packages/card-service/src/app.ts new file mode 100644 index 0000000000..b3a8399067 --- /dev/null +++ b/packages/card-service/src/app.ts @@ -0,0 +1,116 @@ +import { Logger } from 'pino' +import { IAppConfig } from './config/app' +import { IocContract } from '@adonisjs/fold' +import Koa, { DefaultState } from 'koa' +import Router from '@koa/router' +import bodyParser from 'koa-bodyparser' +import { Server } from 'http' +import cors from '@koa/cors' + +export interface AppServices { + logger: Promise + config: Promise +} + +export type AppContainer = IocContract + +export interface AppContextData { + logger: Logger + container: AppContainer + // Set by @koa/router. + params: { [key: string]: string } +} + +export type AppContext = Koa.ParameterizedContext + +export class App { + private cardServiceServer!: Server + private isShuttingDown = false + private logger!: Logger + private config!: IAppConfig + + public constructor(private container: IocContract) {} + + public async boot() { + this.config = await this.container.use('config') + this.logger = await this.container.use('logger') + } + + public async startCardServiceServer(port: number | string): Promise { + const koa = await this.createKoaServer() + const router = new Router() + router.use(bodyParser()) + + router.get('/healthz', (ctx: AppContext): void => { + ctx.status = 200 + }) + + // Add routes here... + + koa.use(cors()) + koa.use(router.routes()) + + this.cardServiceServer = koa.listen(port) + } + + public async shutdown(): Promise { + this.isShuttingDown = true + if (this.cardServiceServer) { + await this.stopServer(this.cardServiceServer) + } + } + + public getCardServicePort(): number { + return this.getPort(this.cardServiceServer) + } + + private getPort(server: Server): number { + const address = server?.address() + if (address && !(typeof address == 'string')) { + return address.port + } + return 0 + } + + private async createKoaServer(): Promise> { + const koa = new Koa({ + proxy: this.config.trustProxy + }) + + koa.context.container = this.container + koa.context.logger = this.logger + + koa.use( + async ( + ctx: { + status: number + set: (arg0: string, arg1: string) => void + body: string + }, + next: () => void | PromiseLike + ): Promise => { + if (this.isShuttingDown) { + ctx.status = 503 + ctx.set('Connection', 'close') + ctx.body = 'Server is in the process of restarting' + } else { + return next() + } + } + ) + + return koa + } + + private async stopServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err) + } + + resolve() + }) + }) + } +} diff --git a/packages/card-service/src/config/app.ts b/packages/card-service/src/config/app.ts new file mode 100644 index 0000000000..7f1abdf990 --- /dev/null +++ b/packages/card-service/src/config/app.ts @@ -0,0 +1,33 @@ +function envString(name: string, defaultValue?: string): string { + const envValue = process.env[name] + + if (envValue) return envValue + if (defaultValue) return defaultValue + + throw new Error(`Environment variable ${name} must be set.`) +} + +function envInt(name: string, value: number): number { + const envValue = process.env[name] + return envValue == null ? value : parseInt(envValue) +} + +function envBool(name: string, value: boolean): boolean { + const envValue = process.env[name] + return envValue == null ? value : envValue === 'true' +} + +export const Config = { + logLevel: envString('LOG_LEVEL', 'info'), + databaseUrl: envString( + 'DATABASE_URL', + 'postgresql://postgres:password@localhost:6543/development' + ), + dbSchema: undefined as string | undefined, + enableManualMigrations: envBool('ENABLE_MANUAL_MIGRATIONS', false), + trustProxy: envBool('TRUST_PROXY', false), + env: envString('NODE_ENV', 'development'), + cardServicePort: envInt('CARD_SERVICE_PORT', 3007) +} + +export type IAppConfig = typeof Config diff --git a/packages/card-service/src/index.ts b/packages/card-service/src/index.ts new file mode 100644 index 0000000000..9d5dba1c0b --- /dev/null +++ b/packages/card-service/src/index.ts @@ -0,0 +1,177 @@ +import { App, AppServices } from './app' +import { Config } from './config/app' +import { Ioc, IocContract } from '@adonisjs/fold' +import createLogger from 'pino' +import { knex } from 'knex' +import { Model } from 'objection' + +export function initIocContainer( + config: typeof Config +): IocContract { + const container: IocContract = new Ioc() + container.singleton('config', async () => config) + container.singleton('logger', async (deps: IocContract) => { + const config = await deps.use('config') + const logger = createLogger() + logger.level = config.logLevel + return logger + }) + container.singleton('knex', async (deps: IocContract) => { + const logger = await deps.use('logger') + const config = await deps.use('config') + logger.info({ msg: 'creating knex' }) + const db = knex({ + client: 'postgresql', + connection: config.databaseUrl, + pool: { + min: 2, + max: 10 + }, + migrations: { + directory: './', + tableName: 'knex_migrations' + }, + searchPath: config.dbSchema, + log: { + warn(message) { + logger.warn(message) + }, + error(message) { + logger.error(message) + }, + deprecate(message) { + logger.warn(message) + }, + debug(message) { + logger.debug(message) + } + } + }) + db.client.driver.types.setTypeParser( + db.client.driver.types.builtins.INT8, + 'text', + BigInt + ) + if (config.dbSchema) { + await db.raw(`CREATE SCHEMA IF NOT EXISTS "${config.dbSchema}"`) + } + return db + }) + + return container +} + +export const start = async ( + container: IocContract, + app: App +): Promise => { + let shuttingDown = false + const logger = await container.use('logger') + process.on('SIGINT', async (): Promise => { + logger.info('received SIGINT attempting graceful shutdown') + try { + if (shuttingDown) { + logger.warn( + 'received second SIGINT during graceful shutdown, exiting forcefully.' + ) + process.exit(1) + } + + shuttingDown = true + + // Graceful shutdown + await gracefulShutdown(container, app) + logger.info('completed graceful shutdown.') + process.exit(0) + } catch (err) { + const errInfo = err instanceof Error && err.stack ? err.stack : err + logger.error({ err: errInfo }, 'error while shutting down') + process.exit(1) + } + }) + + process.on('SIGTERM', async (): Promise => { + logger.info('received SIGTERM attempting graceful shutdown') + + try { + if (shuttingDown) { + logger.warn( + 'received second SIGTERM during graceful shutdown, exiting forcefully.' + ) + process.exit(1) + } + + shuttingDown = true + // Graceful shutdown + await gracefulShutdown(container, app) + logger.info('completed graceful shutdown.') + process.exit(0) + } catch (err) { + const errInfo = err instanceof Error && err.stack ? err.stack : err + logger.error({ err: errInfo }, 'error while shutting down') + process.exit(1) + } + }) + + const config = await container.use('config') + + // Do migrations + const knex = await container.use('knex') + + if (!config.enableManualMigrations) { + // Needs a wrapped inline function + await callWithRetry(async () => { + await knex.migrate.latest({ + directory: __dirname + '/../migrations' + }) + }) + } + + Model.knex(knex) + + await app.boot() + + await app.startCardServiceServer(config.cardServicePort) + logger.info(`Card service listening on ${app.getCardServicePort()}`) +} + +if (require.main === module) { + const container = initIocContainer(Config) + const app = new App(container) + + start(container, app).catch(async (e): Promise => { + const errInfo = e && typeof e === 'object' && e.stack ? e.stack : e + const logger = await container.use('logger') + logger.error({ err: errInfo }) + }) +} + +export const gracefulShutdown = async ( + container: IocContract, + app: App +): Promise => { + const logger = await container.use('logger') + logger.info('shutting down.') + await app.shutdown() + const knex = await container.use('knex') + await knex.destroy() +} + +// Used for running migrations in a try loop with exponential backoff +const callWithRetry: CallableFunction = async ( + fn: CallableFunction, + depth = 0 +) => { + const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)) + + try { + return await fn() + } catch (e) { + if (depth > 7) { + throw e + } + await wait(2 ** depth * 30) + + return callWithRetry(fn, depth + 1) + } +} diff --git a/packages/card-service/tsconfig.json b/packages/card-service/tsconfig.json new file mode 100644 index 0000000000..0ea2a8b22f --- /dev/null +++ b/packages/card-service/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "lib": ["ESNext"], + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "ts-node": { + "swc": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 753b2246f4..7f59dd4c5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,6 +548,43 @@ importers: specifier: ^2.0.0 version: 2.0.0(@swc/core@1.11.29)(@types/node@20.14.15)(typescript@5.8.3) + packages/card-service: + dependencies: + '@adonisjs/fold': + specifier: ^8.2.0 + version: 8.2.0 + '@koa/cors': + specifier: ^5.0.0 + version: 5.0.0 + knex: + specifier: ^3.1.0 + version: 3.1.0 + koa: + specifier: ^2.15.4 + version: 2.16.0 + objection: + specifier: ^3.1.5 + version: 3.1.5(knex@3.1.0) + pino: + specifier: ^8.19.0 + version: 8.19.0 + devDependencies: + '@types/koa': + specifier: 2.15.0 + version: 2.15.0 + '@types/koa-bodyparser': + specifier: ^4.3.12 + version: 4.3.12 + '@types/koa__cors': + specifier: ^5.0.0 + version: 5.0.0 + '@types/koa__router': + specifier: ^12.0.4 + version: 12.0.4 + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@swc/core@1.11.29)(@types/node@20.14.15)(typescript@5.8.3) + packages/documentation: dependencies: '@astrojs/markdown-remark': @@ -1363,7 +1400,7 @@ packages: '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1590,7 +1627,7 @@ packages: '@babel/core': 7.26.9 '@babel/helper-compilation-targets': 7.26.5 '@babel/helper-plugin-utils': 7.26.5 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -3042,7 +3079,7 @@ packages: '@babel/parser': 7.26.7 '@babel/template': 7.25.9 '@babel/types': 7.26.7 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3056,7 +3093,7 @@ packages: '@babel/parser': 7.27.0 '@babel/template': 7.25.9 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3070,7 +3107,7 @@ packages: '@babel/parser': 7.26.9 '@babel/template': 7.26.9 '@babel/types': 7.26.9 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3957,7 +3994,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 espree: 9.6.1 globals: 13.19.0 ignore: 5.2.4 @@ -5111,7 +5148,7 @@ packages: deprecated: Use @eslint/config-array instead dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -5143,7 +5180,7 @@ packages: '@antfu/install-pkg': 0.4.1 '@antfu/utils': 0.7.10 '@iconify/types': 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 kolorist: 1.8.0 local-pkg: 0.5.0 mlly: 1.7.3 @@ -8488,7 +8525,7 @@ packages: '@typescript-eslint/scope-manager': 5.60.1 '@typescript-eslint/type-utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) '@typescript-eslint/utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 eslint: 8.57.1 grapheme-splitter: 1.0.4 ignore: 5.2.4 @@ -8542,7 +8579,7 @@ packages: '@typescript-eslint/scope-manager': 5.60.1 '@typescript-eslint/types': 5.60.1 '@typescript-eslint/typescript-estree': 5.60.1(typescript@5.8.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 eslint: 8.57.1 typescript: 5.8.3 transitivePeerDependencies: @@ -8606,7 +8643,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.60.1(typescript@5.8.3) '@typescript-eslint/utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 eslint: 8.57.1 tsutils: 3.21.0(typescript@5.8.3) typescript: 5.8.3 @@ -8626,7 +8663,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 7.5.0(typescript@5.4.3) '@typescript-eslint/utils': 7.5.0(eslint@8.57.1)(typescript@5.4.3) - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 eslint: 8.57.1 ts-api-utils: 1.0.1(typescript@5.4.3) typescript: 5.4.3 @@ -8660,7 +8697,7 @@ packages: dependencies: '@typescript-eslint/types': 5.60.1 '@typescript-eslint/visitor-keys': 5.60.1 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 @@ -8681,7 +8718,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 @@ -8702,7 +8739,7 @@ packages: dependencies: '@typescript-eslint/types': 7.5.0 '@typescript-eslint/visitor-keys': 7.5.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -9178,7 +9215,7 @@ packages: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 transitivePeerDependencies: - supports-color dev: true @@ -9195,17 +9232,6 @@ packages: resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==} dev: true - /ajv-formats@2.1.1(ajv@8.12.0): - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - dependencies: - ajv: 8.12.0 - dev: true - /ajv-formats@2.1.1(ajv@8.17.1): resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -9249,6 +9275,7 @@ packages: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 uri-js: 4.4.1 + dev: false /ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -9303,14 +9330,6 @@ packages: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} dev: true - /anymatch@3.1.2: - resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} - engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: true - /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -11662,7 +11681,7 @@ packages: resolution: {integrity: sha512-h0Ow21gclbYsZ3mkHDfsYNDqtRhXS8fXr51bU0qr1dxgTMJj0XufbzX+jhNOvA8KuEEzn6JbvLVhXyv+fny9Uw==} engines: {node: '>= 8.0'} dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 readable-stream: 3.6.0 split-ca: 1.0.1 ssh2: 1.11.0 @@ -12204,6 +12223,7 @@ packages: /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} + dev: false /escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} @@ -12258,7 +12278,7 @@ packages: eslint: '*' eslint-plugin-import: '*' dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 enhanced-resolve: 5.13.0 eslint: 8.57.1 eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.60.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1) @@ -13989,7 +14009,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 transitivePeerDependencies: - supports-color dev: true @@ -14004,7 +14024,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 transitivePeerDependencies: - supports-color dev: true @@ -14791,7 +14811,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 istanbul-lib-coverage: 3.2.0 source-map: 0.6.1 transitivePeerDependencies: @@ -14991,7 +15011,7 @@ packages: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.12.7 + '@types/node': 20.14.15 jest-mock: 29.7.0 jest-util: 29.7.0 dev: true @@ -15013,7 +15033,7 @@ packages: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.5 '@types/node': 20.14.15 - anymatch: 3.1.2 + anymatch: 3.1.3 fb-watchman: 2.0.1 graceful-fs: 4.2.11 jest-regex-util: 29.6.3 @@ -15443,6 +15463,52 @@ packages: engines: {node: '>= 8'} dev: false + /knex@3.1.0: + resolution: {integrity: sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + better-sqlite3: '*' + mysql: '*' + mysql2: '*' + pg: '*' + pg-native: '*' + sqlite3: '*' + tedious: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + mysql: + optional: true + mysql2: + optional: true + pg: + optional: true + pg-native: + optional: true + sqlite3: + optional: true + tedious: + optional: true + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4 + escalade: 3.2.0 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.17.21 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.0.2 + tildify: 2.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /knex@3.1.0(pg@8.11.3): resolution: {integrity: sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==} engines: {node: '>=16'} @@ -15561,7 +15627,7 @@ packages: content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 delegates: 1.0.0 depd: 2.0.0 destroy: 1.2.0 @@ -16951,7 +17017,7 @@ packages: resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} dependencies: '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.1.0 micromark-factory-space: 1.1.0 @@ -16975,7 +17041,7 @@ packages: resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} dependencies: '@types/debug': 4.1.12 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.1 @@ -17701,15 +17767,15 @@ packages: /openapi-response-validator@9.3.1: resolution: {integrity: sha512-2AOzHAbrwdj5DNL3u+BadhfmL3mlc3mmCv6cSAsEjoMncpOOVd95JyMf0j0XUyJigJ8/ILxnhETfg35vt1pGSQ==} dependencies: - ajv: 8.12.0 + ajv: 8.17.1 openapi-types: 9.3.1 dev: true /openapi-schema-validator@9.3.1: resolution: {integrity: sha512-5wpFKMoEbUcjiqo16jIen3Cb2+oApSnYZpWn8WQdRO2q/dNQZZl8Pz6ESwCriiyU5AK4i5ZI6+7O3bHQr6+6+g==} dependencies: - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) lodash.merge: 4.6.2 openapi-types: 9.3.1 dev: true @@ -21711,7 +21777,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 mlly: 1.7.3 pathe: 1.1.2 picocolors: 1.1.1 @@ -21735,7 +21801,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 mlly: 1.7.3 pathe: 1.1.2 picocolors: 1.1.1 @@ -22475,7 +22541,7 @@ packages: engines: {node: '>=12'} dependencies: cliui: 7.0.4 - escalade: 3.1.1 + escalade: 3.2.0 get-caller-file: 2.0.5 require-directory: 2.1.1 string-width: 4.2.3 From b66c9448ba56a2f1dd0c71c6e66c9c3a517f035b Mon Sep 17 00:00:00 2001 From: oana-lolea Date: Tue, 8 Jul 2025 12:25:33 +0300 Subject: [PATCH 02/22] feat: initialize POS service (#3509) * feat(wip): pos service * Completed POS service init * Added docker files and missing scripts * Removed editor code after handling conflicts * Updated docker-compose for cloud 9 localenv and removed unnecessary telemetry command from dockerfile * Updated docker compose files for c9 and hlb * Updated ports and env var names from config * fix: install ts-node-dev, add main script to index.ts --------- Co-authored-by: Nathan Lie --- localenv/cloud-nine-wallet/dbinit.sql | 8 + localenv/cloud-nine-wallet/docker-compose.yml | 32 +++ localenv/happy-life-bank/docker-compose.yml | 38 ++- packages/point-of-sale/Dockerfile.dev | 35 +++ packages/point-of-sale/Dockerfile.prod | 68 ++++++ packages/point-of-sale/knexfile.js | 43 ++++ packages/point-of-sale/package.json | 38 +++ packages/point-of-sale/src/app.ts | 113 +++++++++ packages/point-of-sale/src/config/app.ts | 38 +++ packages/point-of-sale/src/index.ts | 175 ++++++++++++++ packages/point-of-sale/tsconfig.json | 12 + pnpm-lock.yaml | 218 +++++++++++------- 12 files changed, 732 insertions(+), 86 deletions(-) create mode 100644 packages/point-of-sale/Dockerfile.dev create mode 100644 packages/point-of-sale/Dockerfile.prod create mode 100644 packages/point-of-sale/knexfile.js create mode 100644 packages/point-of-sale/package.json create mode 100644 packages/point-of-sale/src/app.ts create mode 100644 packages/point-of-sale/src/config/app.ts create mode 100644 packages/point-of-sale/src/index.ts create mode 100644 packages/point-of-sale/tsconfig.json diff --git a/localenv/cloud-nine-wallet/dbinit.sql b/localenv/cloud-nine-wallet/dbinit.sql index b93a62b880..ba680bd520 100644 --- a/localenv/cloud-nine-wallet/dbinit.sql +++ b/localenv/cloud-nine-wallet/dbinit.sql @@ -18,6 +18,14 @@ CREATE USER happy_life_bank_auth WITH PASSWORD 'happy_life_bank_auth'; CREATE DATABASE happy_life_bank_auth; ALTER DATABASE happy_life_bank_auth OWNER TO happy_life_bank_auth; +CREATE USER cloud_nine_wallet_pos WITH PASSWORD 'cloud_nine_wallet_pos'; +CREATE DATABASE cloud_nine_wallet_pos; +ALTER DATABASE cloud_nine_wallet_pos OWNER TO cloud_nine_wallet_pos; + +CREATE USER happy_life_bank_pos WITH PASSWORD 'happy_life_bank_pos'; +CREATE DATABASE happy_life_bank_pos; +ALTER DATABASE happy_life_bank_pos OWNER TO happy_life_bank_pos; + CREATE USER happy_life_bank_card_service WITH PASSWORD 'happy_life_bank_card_service'; CREATE DATABASE happy_life_bank_card_service; ALTER DATABASE happy_life_bank_card_service OWNER TO happy_life_bank_card_service; \ No newline at end of file diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 64b3830afb..c77a684576 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -32,6 +32,38 @@ services: interval: 30s retries: 1 timeout: 3s + cloud-nine-wallet-point-of-sale: + hostname: cloud-nine-wallet-point-of-sale + image: rafiki-point-of-sale + build: + context: ../.. + dockerfile: ./packages/point-of-sale/Dockerfile.dev + restart: always + networks: + - rafiki + ports: + - '3008:3008' + volumes: + - type: bind + source: ../../packages/point-of-sale/src + target: /home/rafiki/packages/point-of-sale/src + read_only: true + environment: + NODE_ENV: ${NODE_ENV:-development} + INSTANCE_NAME: CLOUD-NINE + TRUST_PROXY: ${TRUST_PROXY} + LOG_LEVEL: debug + PORT: 3008 + DATABASE_URL: postgresql://cloud_nine_wallet_point_of_sale:cloud_nine_wallet_point_of_sale@shared-database/cloud_nine_wallet_point_of_sale + depends_on: + - shared-database + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:3008/healthz"] + start_period: 60s + start_interval: 5s + interval: 30s + retries: 1 + timeout: 3s cloud-nine-mock-ase: hostname: cloud-nine-wallet image: rafiki-mock-ase diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 5d17f1d074..c8c3ad885d 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -10,7 +10,7 @@ services: networks: - rafiki ports: - - '4007:3007' + - '4007:4007' volumes: - type: bind source: ../../packages/card-service/src @@ -18,10 +18,10 @@ services: read_only: true environment: NODE_ENV: ${NODE_ENV:-development} - INSTANCE_NAME: CLOUD-NINE + INSTANCE_NAME: HAPPY-LIFE TRUST_PROXY: ${TRUST_PROXY} LOG_LEVEL: debug - CARD_SERVICE_PORT: 3007 + CARD_SERVICE_PORT: 4007 DATABASE_URL: postgresql://happy_life_bank_card_service:happy_life_bank_card_service@shared-database/happy_life_bank_card_service depends_on: - shared-database @@ -32,6 +32,38 @@ services: interval: 30s retries: 1 timeout: 3s + happy-life-bank-point-of-sale: + hostname: happy-life-bank-point-of-sale + image: rafiki-point-of-sale + build: + context: ../.. + dockerfile: ./packages/point-of-sale/Dockerfile.dev + restart: always + networks: + - rafiki + ports: + - '4008:4008' + volumes: + - type: bind + source: ../../packages/point-of-sale/src + target: /home/rafiki/packages/point-of-sale/src + read_only: true + environment: + NODE_ENV: ${NODE_ENV:-development} + INSTANCE_NAME: HAPPY-LIFE + TRUST_PROXY: ${TRUST_PROXY} + LOG_LEVEL: debug + PORT: 4008 + DATABASE_URL: postgresql://happy_life_bank_point_of_sale:happy_life_bank_point_of_sale@shared-database/happy_life_bank_point_of_sale + depends_on: + - shared-database + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:4008/healthz"] + start_period: 60s + start_interval: 5s + interval: 30s + retries: 1 + timeout: 3s happy-life-mock-ase: hostname: happy-life-bank image: rafiki-mock-ase diff --git a/packages/point-of-sale/Dockerfile.dev b/packages/point-of-sale/Dockerfile.dev new file mode 100644 index 0000000000..736d702abd --- /dev/null +++ b/packages/point-of-sale/Dockerfile.dev @@ -0,0 +1,35 @@ +FROM node:20-alpine3.20 + +RUN adduser -D rafiki +WORKDIR /home/rafiki + +# Install Corepack and pnpm as the Rafiki user +USER rafiki +RUN mkdir -p /home/rafiki/.local/bin +ENV PATH="/home/rafiki/.local/bin:$PATH" +RUN corepack enable --install-directory ~/.local/bin +RUN corepack prepare pnpm@8.7.4 --activate +COPY pnpm-lock.yaml package.json pnpm-workspace.yaml .npmrc tsconfig.json tsconfig.build.json ./ + +# Fetch the pnpm dependencies, but use a local cache. +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm fetch \ + | grep -v "cross-device link not permitted\|Falling back to copying packages from store" + +# Copy the source code and chown the relevant folders back to the Rafiki user +USER root +COPY . ./ +RUN chown -v -R rafiki:rafiki /home/rafiki/localenv +RUN chown -v -R rafiki:rafiki /home/rafiki/packages +RUN chown -v -R rafiki:rafiki /home/rafiki/test + +# As the Rafiki user, install the rest of the dependencies and build the source code +USER rafiki +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install \ + --recursive \ + --offline \ + --frozen-lockfile +RUN pnpm --filter point-of-sale build:deps + +CMD pnpm --filter point-of-sale dev \ No newline at end of file diff --git a/packages/point-of-sale/Dockerfile.prod b/packages/point-of-sale/Dockerfile.prod new file mode 100644 index 0000000000..b15030d030 --- /dev/null +++ b/packages/point-of-sale/Dockerfile.prod @@ -0,0 +1,68 @@ +FROM node:20-alpine3.20 AS base + +WORKDIR /home/rafiki + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +RUN corepack enable +RUN corepack prepare pnpm@8.7.4 --activate + +COPY pnpm-lock.yaml ./ + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm fetch \ + | grep -v "cross-device link not permitted\|Falling back to copying packages from store" + +FROM base AS prod-deps + +COPY package.json pnpm-workspace.yaml .npmrc ./ +COPY packages/point-of-sale/knexfile.js ./packages/point-of-sale/knexfile.js +COPY packages/point-of-sale/package.json ./packages/point-of-sale/package.json +COPY packages/token-introspection/package.json ./packages/token-introspection/package.json + +RUN pnpm clean +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install \ + --recursive \ + --prefer-offline \ + --frozen-lockfile \ + --prod \ + | grep -v "cross-device link not permitted\|Falling back to copying packages from store" + +FROM base AS builder + +COPY package.json pnpm-workspace.yaml .npmrc tsconfig.json tsconfig.build.json ./ +COPY packages/point-of-sale ./packages/point-of-sale +COPY packages/token-introspection ./packages/token-introspection + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install \ + --recursive \ + --offline \ + --frozen-lockfile +RUN pnpm --filter point-of-sale build + +FROM node:20-alpine3.20 AS runner + +# Since this is from a fresh image, we need to first create the Rafiki user +RUN adduser -D rafiki +WORKDIR /home/rafiki + +COPY --from=prod-deps /home/rafiki/node_modules ./node_modules +COPY --from=prod-deps /home/rafiki/packages/point-of-sale/node_modules ./packages/point-of-sale/node_modules +COPY --from=prod-deps /home/rafiki/packages/point-of-sale/package.json ./packages/point-of-sale/package.json +COPY --from=prod-deps /home/rafiki/packages/token-introspection/node_modules ./packages/token-introspection/node_modules +COPY --from=prod-deps /home/rafiki/packages/token-introspection/package.json ./packages/token-introspection/package.json +COPY --from=prod-deps /home/rafiki/packages/point-of-sale/knexfile.js ./packages/point-of-sale/knexfile.js + +COPY --from=builder /home/rafiki/packages/point-of-sale/migrations/ ./packages/point-of-sale/migrations +COPY --from=builder /home/rafiki/packages/point-of-sale/dist ./packages/point-of-sale/dist +COPY --from=builder /home/rafiki/packages/token-introspection/dist ./packages/token-introspection/dist +COPY --from=builder /home/rafiki/packages/point-of-sale/knexfile.js ./packages/point-of-sale/knexfile.js + +USER root + +# For additional paranoia, we make it so that the Rafiki user has no write access to the packages +RUN chown -R :rafiki /home/rafiki/packages +RUN chmod -R 750 /home/rafiki/packages diff --git a/packages/point-of-sale/knexfile.js b/packages/point-of-sale/knexfile.js new file mode 100644 index 0000000000..32ab26557f --- /dev/null +++ b/packages/point-of-sale/knexfile.js @@ -0,0 +1,43 @@ +// Update with your config settings. + +/** + * @type { Object. } + */ +module.exports = { + development: { + client: 'postgresql', + connection: { + database: 'pos', + user: 'postgres', + password: 'password' + } + }, + + testing: { + client: 'postgresql', + connection: { + database: 'pos_testing', + user: 'postgres', + password: 'password' + }, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'pos_knex_migrations' + } + }, + + production: { + client: 'postgresql', + connection: process.env.POS_DATABASE_URL, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'pos_knex_migrations' + } + } +} diff --git a/packages/point-of-sale/package.json b/packages/point-of-sale/package.json new file mode 100644 index 0000000000..df3c1da79c --- /dev/null +++ b/packages/point-of-sale/package.json @@ -0,0 +1,38 @@ +{ + "name": "point-of-sale", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "knex": "knex", + "dev": "ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only src/index.ts", + "build": "pnpm build:deps && pnpm clean && tsc --build tsconfig.json", + "build:deps": "pnpm --filter token-introspection build", + "clean": "rm -fr dist/" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@adonisjs/fold": "^8.2.0", + "@apollo/server": "^4.11.2", + "@koa/cors": "^5.0.0", + "@koa/router": "^12.0.2", + "dotenv": "^16.4.7", + "knex": "^3.1.0", + "koa": "^3.0.0", + "koa-bodyparser": "^4.4.1", + "objection": "^3.1.5", + "objection-db-errors": "^1.1.2", + "pg": "^8.11.3", + "pino": "^8.19.0", + "ts-node-dev": "^2.0.0" + }, + "devDependencies": { + "@types/koa": "2.15.0", + "@types/koa-bodyparser": "^4.3.12", + "@types/koa__cors": "^5.0.0", + "@types/koa__router": "^12.0.4" + } +} diff --git a/packages/point-of-sale/src/app.ts b/packages/point-of-sale/src/app.ts new file mode 100644 index 0000000000..2a661fac78 --- /dev/null +++ b/packages/point-of-sale/src/app.ts @@ -0,0 +1,113 @@ +import { Knex } from 'knex' +import { Logger } from 'pino' +import { IAppConfig } from './config/app' +import { Server } from 'http' +import { IocContract } from '@adonisjs/fold' +import Koa, { DefaultState } from 'koa' +import Router from '@koa/router' +import bodyParser from 'koa-bodyparser' +import cors from '@koa/cors' + +export interface AppServices { + logger: Promise + knex: Promise + config: Promise +} + +export type AppContainer = IocContract + +export interface AppContextData { + logger: Logger + container: AppContainer + // Set by @koa/router. + params: { [key: string]: string } +} + +export type AppContext = Koa.ParameterizedContext + +export class App { + private posServer!: Server + public isShuttingDown = false + private logger!: Logger + private config!: IAppConfig + + public constructor(private container: IocContract) {} + + public async boot(): Promise { + this.config = await this.container.use('config') + this.logger = await this.container.use('logger') + } + + public async startPosServer(port: number): Promise { + const koa = await this.createKoaServer() + + const router = new Router() + router.use(bodyParser()) + router.get('/healthz', (ctx: AppContext): void => { + ctx.status = 200 + }) + + koa.use(cors()) + koa.use(router.routes()) + + this.posServer = koa.listen(port) + } + + public async shutdown(): Promise { + this.isShuttingDown = true + + if (this.posServer) { + await this.stopServer(this.posServer) + } + } + + private async stopServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err) + } + + resolve() + }) + }) + } + + public getPort() { + const address = this.posServer?.address() + if (address && !(typeof address == 'string')) { + return address.port + } + return 0 + } + + private async createKoaServer(): Promise> { + const koa = new Koa({ + proxy: this.config.trustProxy + }) + + koa.context.container = this.container + koa.context.logger = await this.container.use('logger') + + koa.use( + async ( + ctx: { + status: number + set: (arg0: string, arg1: string) => void + body: string + }, + next: () => void | PromiseLike + ): Promise => { + if (this.isShuttingDown) { + ctx.status = 503 + ctx.set('Connection', 'close') + ctx.body = 'Server is in the process of restarting' + } else { + return next() + } + } + ) + + return koa + } +} diff --git a/packages/point-of-sale/src/config/app.ts b/packages/point-of-sale/src/config/app.ts new file mode 100644 index 0000000000..2691d7397b --- /dev/null +++ b/packages/point-of-sale/src/config/app.ts @@ -0,0 +1,38 @@ +import dotenv from 'dotenv' + +function envString(name: string, defaultValue?: string): string { + const envValue = process.env[name] + + if (envValue) return envValue + if (defaultValue) return defaultValue + + throw new Error(`Environment variable ${name} must be set.`) +} + +function envInt(name: string, value: number): number { + const envValue = process.env[name] + return envValue == null ? value : parseInt(envValue) +} + +function envBool(name: string, value: boolean): boolean { + const envValue = process.env[name] + return envValue == null ? value : envValue === 'true' +} + +export type IAppConfig = typeof Config + +dotenv.config({ + path: process.env.ENV_FILE || '.env' +}) + +export const Config = { + logLevel: envString('LOG_LEVEL', 'info'), + databaseUrl: envString( + 'DATABASE_URL', + 'postgresql://postgres:password@localhost:5432/development' + ), + env: envString('NODE_ENV', 'development'), + port: envInt('PORT', 3008), + trustProxy: envBool('TRUST_PROXY', false), + enableManualMigrations: envBool('ENABLE_MANUAl_MIGRATIONS', false) +} diff --git a/packages/point-of-sale/src/index.ts b/packages/point-of-sale/src/index.ts new file mode 100644 index 0000000000..d9d359db53 --- /dev/null +++ b/packages/point-of-sale/src/index.ts @@ -0,0 +1,175 @@ +import { Ioc, IocContract } from '@adonisjs/fold' +import { knex } from 'knex' +import { Model } from 'objection' +import { Config } from './config/app' +import { App, AppServices } from './app' +import createLogger from 'pino' + +export function initIocContainer( + config: typeof Config +): IocContract { + const container: IocContract = new Ioc() + container.singleton('config', async () => config) + container.singleton('logger', async (deps: IocContract) => { + const config = await deps.use('config') + const logger = createLogger() + logger.level = config.logLevel + return logger + }) + container.singleton('knex', async (deps: IocContract) => { + const logger = await deps.use('logger') + const config = await deps.use('config') + logger.info({ msg: 'creating knex' }) + const db = knex({ + client: 'postgresql', + connection: config.databaseUrl, + pool: { + min: 2, + max: 10 + }, + migrations: { + directory: './', + tableName: 'pos_knex_migrations' + }, + log: { + warn(message) { + logger.warn(message) + }, + error(message) { + logger.error(message) + }, + deprecate(message) { + logger.warn(message) + }, + debug(message) { + logger.debug(message) + } + } + }) + + // node pg defaults to returning bigint as string. This ensures it parses to bigint + db.client.driver.types.setTypeParser( + db.client.driver.types.builtins.INT8, + 'text', + BigInt + ) + return db + }) + return container +} + +export const gracefulShutdown = async ( + container: IocContract, + app: App +): Promise => { + const logger = await container.use('logger') + logger.info('shutting down.') + await app.shutdown() + const knex = await container.use('knex') + await knex.destroy() +} + +export const start = async ( + container: IocContract, + app: App +): Promise => { + let shuttingDown = false + const logger = await container.use('logger') + process.on('SIGINT', async (): Promise => { + logger.info('received SIGINT attempting graceful shutdown') + try { + if (shuttingDown) { + logger.warn( + 'received second SIGINT during graceful shutdown, exiting forcefully.' + ) + process.exit(1) + } + + shuttingDown = true + + // Graceful shutdown + await gracefulShutdown(container, app) + logger.info('completed graceful shutdown.') + process.exit(0) + } catch (err) { + const errInfo = err instanceof Error && err.stack ? err.stack : err + logger.error({ err: errInfo }, 'error while shutting down') + process.exit(1) + } + }) + + process.on('SIGTERM', async (): Promise => { + logger.info('received SIGTERM attempting graceful shutdown') + + try { + if (shuttingDown) { + logger.warn( + 'received second SIGTERM during graceful shutdown, exiting forcefully.' + ) + process.exit(1) + } + + shuttingDown = true + + // Graceful shutdown + await gracefulShutdown(container, app) + logger.info('completed graceful shutdown.') + process.exit(0) + } catch (err) { + const errInfo = err instanceof Error && err.stack ? err.stack : err + logger.error({ err: errInfo }, 'error while shutting down') + process.exit(1) + } + }) + + const config = await container.use('config') + + // Do migrations + const knex = await container.use('knex') + + if (!config.enableManualMigrations) { + // Needs a wrapped inline function + await callWithRetry(async () => { + await knex.migrate.latest({ + directory: __dirname + '/../migrations' + }) + }) + } + + Model.knex(knex) + + await app.boot() + + await app.startPosServer(config.port) + logger.info(`POS Service listening on ${app.getPort()}`) +} + +if (require.main === module) { + const container = initIocContainer(Config) + const app = new App(container) + + start(container, app).catch(async (e): Promise => { + const errInfo = e && typeof e === 'object' && e.stack ? e.stack : e + const logger = await container.use('logger') + logger.error({ err: errInfo }) + }) +} + +// Used for running migrations in a try loop with exponential backoff +const callWithRetry: CallableFunction = async ( + fn: CallableFunction, + depth = 0 +) => { + const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)) + + try { + return await fn() + } catch (e) { + if (depth > 7) { + throw e + } + await wait(2 ** depth * 30) + + return callWithRetry(fn, depth + 1) + } +} diff --git a/packages/point-of-sale/tsconfig.json b/packages/point-of-sale/tsconfig.json new file mode 100644 index 0000000000..0ea2a8b22f --- /dev/null +++ b/packages/point-of-sale/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "lib": ["ESNext"], + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "ts-node": { + "swc": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f59dd4c5d..7e4c5026a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -558,7 +558,7 @@ importers: version: 5.0.0 knex: specifier: ^3.1.0 - version: 3.1.0 + version: 3.1.0(pg@8.11.3) koa: specifier: ^2.15.4 version: 2.16.0 @@ -761,6 +761,61 @@ importers: specifier: ^9.0.8 version: 9.0.8 + packages/point-of-sale: + dependencies: + '@adonisjs/fold': + specifier: ^8.2.0 + version: 8.2.0 + '@apollo/server': + specifier: ^4.11.2 + version: 4.11.2(graphql@16.11.0) + '@koa/cors': + specifier: ^5.0.0 + version: 5.0.0 + '@koa/router': + specifier: ^12.0.2 + version: 12.0.2 + dotenv: + specifier: ^16.4.7 + version: 16.4.7 + knex: + specifier: ^3.1.0 + version: 3.1.0(pg@8.11.3) + koa: + specifier: ^3.0.0 + version: 3.0.0 + koa-bodyparser: + specifier: ^4.4.1 + version: 4.4.1 + objection: + specifier: ^3.1.5 + version: 3.1.5(knex@3.1.0) + objection-db-errors: + specifier: ^1.1.2 + version: 1.1.2(objection@3.1.5) + pg: + specifier: ^8.11.3 + version: 8.11.3 + pino: + specifier: ^8.19.0 + version: 8.19.0 + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@swc/core@1.11.29)(@types/node@20.14.15)(typescript@5.8.3) + devDependencies: + '@types/koa': + specifier: 2.15.0 + version: 2.15.0 + '@types/koa-bodyparser': + specifier: ^4.3.12 + version: 4.3.12 + '@types/koa__cors': + specifier: ^5.0.0 + version: 5.0.0 + '@types/koa__router': + specifier: ^12.0.4 + version: 12.0.4 + packages/token-introspection: dependencies: '@interledger/open-payments': @@ -1422,7 +1477,7 @@ packages: '@babel/traverse': 7.26.7 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -6012,7 +6067,7 @@ packages: resolution: {integrity: sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA==} engines: {node: '>= 12'} dependencies: - debug: 4.3.7 + debug: 4.4.1 http-errors: 2.0.0 koa-compose: 4.1.0 methods: 1.1.2 @@ -9194,7 +9249,6 @@ packages: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: acorn: 8.14.1 - dev: false /acorn-walk@8.3.2: resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} @@ -9209,7 +9263,6 @@ packages: resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} engines: {node: '>=0.4.0'} hasBin: true - dev: false /agent-base@7.1.0: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} @@ -9330,6 +9383,14 @@ packages: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} dev: true + /anymatch@3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -10370,7 +10431,6 @@ packages: dependencies: call-bind-apply-helpers: 1.0.1 get-intrinsic: 1.2.6 - dev: true /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} @@ -11450,6 +11510,7 @@ packages: optional: true dependencies: ms: 2.1.3 + dev: true /debug@4.4.0(supports-color@7.2.0): resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} @@ -12220,11 +12281,6 @@ packages: '@esbuild/win32-ia32': 0.25.2 '@esbuild/win32-x64': 0.25.2 - /escalade@3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} - engines: {node: '>=6'} - dev: false - /escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -14173,6 +14229,7 @@ packages: /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. dependencies: once: 1.4.0 wrappy: 1.0.2 @@ -14214,7 +14271,7 @@ packages: dependencies: get-intrinsic: 1.2.4 has: 1.0.3 - side-channel: 1.0.5 + side-channel: 1.1.0 dev: true /internal-slot@1.0.7: @@ -14223,7 +14280,7 @@ packages: dependencies: es-errors: 1.3.0 hasown: 2.0.1 - side-channel: 1.0.5 + side-channel: 1.1.0 dev: true /internal-slot@1.1.0: @@ -15011,7 +15068,7 @@ packages: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.15 + '@types/node': 20.12.7 jest-mock: 29.7.0 jest-util: 29.7.0 dev: true @@ -15033,7 +15090,7 @@ packages: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.5 '@types/node': 20.14.15 - anymatch: 3.1.3 + anymatch: 3.1.2 fb-watchman: 2.0.1 graceful-fs: 4.2.11 jest-regex-util: 29.6.3 @@ -15463,52 +15520,6 @@ packages: engines: {node: '>= 8'} dev: false - /knex@3.1.0: - resolution: {integrity: sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==} - engines: {node: '>=16'} - hasBin: true - peerDependencies: - better-sqlite3: '*' - mysql: '*' - mysql2: '*' - pg: '*' - pg-native: '*' - sqlite3: '*' - tedious: '*' - peerDependenciesMeta: - better-sqlite3: - optional: true - mysql: - optional: true - mysql2: - optional: true - pg: - optional: true - pg-native: - optional: true - sqlite3: - optional: true - tedious: - optional: true - dependencies: - colorette: 2.0.19 - commander: 10.0.1 - debug: 4.3.4 - escalade: 3.2.0 - esm: 3.2.25 - get-package-type: 0.1.0 - getopts: 2.3.0 - interpret: 2.2.0 - lodash: 4.17.21 - pg-connection-string: 2.6.2 - rechoir: 0.8.0 - resolve-from: 5.0.0 - tarn: 3.0.2 - tildify: 2.0.0 - transitivePeerDependencies: - - supports-color - dev: false - /knex@3.1.0(pg@8.11.3): resolution: {integrity: sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==} engines: {node: '>=16'} @@ -15540,7 +15551,7 @@ packages: colorette: 2.0.19 commander: 10.0.1 debug: 4.3.4 - escalade: 3.1.1 + escalade: 3.2.0 esm: 3.2.25 get-package-type: 0.1.0 getopts: 2.3.0 @@ -15648,6 +15659,33 @@ packages: transitivePeerDependencies: - supports-color + /koa@3.0.0: + resolution: {integrity: sha512-Usyqf1o+XN618R3Jzq4S4YWbKsRtPcGpgyHXD4APdGYQQyqQ59X+Oyc7fXHS2429stzLsBiDjj6zqqYe8kknfw==} + engines: {node: '>= 18'} + dependencies: + accepts: 1.3.8 + cache-content-type: 1.0.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookies: 0.9.1 + debug: 4.4.1 + delegates: 1.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 2.0.0 + koa-compose: 4.1.0 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: false + /kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} dev: false @@ -16368,6 +16406,11 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + /media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + dev: false + /memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} @@ -16672,8 +16715,8 @@ packages: /micromark-extension-mdxjs@1.0.0: resolution: {integrity: sha512-TZZRZgeHvtgm+IhtgC2+uDMR7h8eTKF0QUX9YsgoL9+bADBpBY6SiLvWqnBlLbCEevITmTqmEuY3FoxMKVs1rQ==} dependencies: - acorn: 8.14.0 - acorn-jsx: 5.3.2(acorn@8.14.0) + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) micromark-extension-mdx-expression: 1.0.3 micromark-extension-mdx-jsx: 1.0.3 micromark-extension-mdx-md: 1.0.0 @@ -17080,12 +17123,24 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + /mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + dev: false + /mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} dependencies: mime-db: 1.52.0 + /mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.54.0 + dev: false + /mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -17560,7 +17615,6 @@ packages: /object-inspect@1.13.3: resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==} engines: {node: '>= 0.4'} - dev: true /object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} @@ -18721,7 +18775,7 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} dependencies: - side-channel: 1.0.6 + side-channel: 1.1.0 /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -19456,6 +19510,7 @@ packages: /rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 @@ -19931,7 +19986,6 @@ packages: dependencies: es-errors: 1.3.0 object-inspect: 1.13.3 - dev: true /side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} @@ -19941,7 +19995,6 @@ packages: es-errors: 1.3.0 get-intrinsic: 1.2.6 object-inspect: 1.13.3 - dev: true /side-channel-weakmap@1.0.2: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} @@ -19952,7 +20005,6 @@ packages: get-intrinsic: 1.2.6 object-inspect: 1.13.3 side-channel-map: 1.0.1 - dev: true /side-channel@1.0.5: resolution: {integrity: sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==} @@ -19962,15 +20014,7 @@ packages: es-errors: 1.3.0 get-intrinsic: 1.2.4 object-inspect: 1.13.1 - - /side-channel@1.0.6: - resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} - engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - get-intrinsic: 1.2.4 - object-inspect: 1.13.1 + dev: false /side-channel@1.1.0: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} @@ -19981,7 +20025,6 @@ packages: side-channel-list: 1.0.0 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - dev: true /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -20704,7 +20747,7 @@ packages: hasBin: true dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 + acorn: 8.14.1 commander: 2.20.3 source-map-support: 0.5.21 dev: true @@ -20965,7 +21008,7 @@ packages: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 20.14.15 - acorn: 8.14.0 + acorn: 8.14.1 acorn-walk: 8.3.2 arg: 4.1.3 create-require: 1.1.1 @@ -21096,6 +21139,15 @@ packages: media-typer: 0.3.0 mime-types: 2.1.35 + /type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + dev: false + /typed-array-buffer@1.0.0: resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==} engines: {node: '>= 0.4'} @@ -21825,7 +21877,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 es-module-lexer: 1.6.0 pathe: 1.1.2 vite: 6.2.5(@types/node@18.11.9)(yaml@2.7.0) @@ -21850,7 +21902,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1 es-module-lexer: 1.6.0 pathe: 1.1.2 vite: 6.2.5(@types/node@20.12.7)(yaml@2.7.0) From 0f94e3aac907d8bbecfd10ff37856e4d621007c5 Mon Sep 17 00:00:00 2001 From: Arpi Lengyel <165793743+lengyel-arpad85@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:02:37 +0300 Subject: [PATCH 03/22] feat(card-service): card payments table (#3514) * migration & model for card payments --- packages/card-service/knexfile.js | 6 +- .../20250708070327_card_payments_table.js | 34 +++++ packages/card-service/package.json | 5 +- .../card-service/src/card-payment/model.ts | 16 ++ .../card-service/src/shared/baseModel.test.ts | 128 ++++++++++++++++ packages/card-service/src/shared/baseModel.ts | 144 ++++++++++++++++++ .../card-service/src/shared/baseService.ts | 8 + packages/card-service/src/shared/filters.ts | 3 + .../card-service/src/shared/pagination.ts | 53 +++++++ pnpm-lock.yaml | 9 ++ 10 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 packages/card-service/migrations/20250708070327_card_payments_table.js create mode 100644 packages/card-service/src/card-payment/model.ts create mode 100644 packages/card-service/src/shared/baseModel.test.ts create mode 100644 packages/card-service/src/shared/baseModel.ts create mode 100644 packages/card-service/src/shared/baseService.ts create mode 100644 packages/card-service/src/shared/filters.ts create mode 100644 packages/card-service/src/shared/pagination.ts diff --git a/packages/card-service/knexfile.js b/packages/card-service/knexfile.js index 6f8e2606ed..cff04064bc 100644 --- a/packages/card-service/knexfile.js +++ b/packages/card-service/knexfile.js @@ -13,7 +13,7 @@ module.exports = { max: 10 }, migrations: { - tableName: 'knex_migrations' + tableName: 'card_service_knex_migrations' } }, @@ -29,7 +29,7 @@ module.exports = { max: 10 }, migrations: { - tableName: 'knex_migrations' + tableName: 'card_service_knex_migrations' } }, @@ -41,7 +41,7 @@ module.exports = { max: 10 }, migrations: { - tableName: 'knex_migrations' + tableName: 'card_service_knex_migrations' } } } diff --git a/packages/card-service/migrations/20250708070327_card_payments_table.js b/packages/card-service/migrations/20250708070327_card_payments_table.js new file mode 100644 index 0000000000..a1adf8d8c0 --- /dev/null +++ b/packages/card-service/migrations/20250708070327_card_payments_table.js @@ -0,0 +1,34 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('cardPayments', function (table) { + table.uuid('id').notNullable().primary() + table.uuid('requestId').notNullable() + + table.timestamp('requestedAt').defaultTo(knex.fn.now()) + table.timestamp('finalizedAt').defaultTo(knex.fn.now()) + + table.string('cardWalletAddress').notNullable() + table.string('incomingPaymentUrl').notNullable() + + table.integer('statusCode').nullable() + + table.uuid('outgoingPaymentId') + table.uuid('terminalId') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + + table.index('cardWalletAddress') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('cardPayments') +} diff --git a/packages/card-service/package.json b/packages/card-service/package.json index aeadc35993..39fafcbf8a 100644 --- a/packages/card-service/package.json +++ b/packages/card-service/package.json @@ -18,13 +18,16 @@ "knex": "^3.1.0", "koa": "^2.15.4", "objection": "^3.1.5", - "pino": "^8.19.0" + "objection-db-errors": "^1.1.2", + "pino": "^8.19.0", + "uuid": "^9.0.1" }, "devDependencies": { "@types/koa": "2.15.0", "@types/koa-bodyparser": "^4.3.12", "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.4", + "@types/uuid": "^9.0.8", "ts-node-dev": "^2.0.0" } } diff --git a/packages/card-service/src/card-payment/model.ts b/packages/card-service/src/card-payment/model.ts new file mode 100644 index 0000000000..704aeb49d3 --- /dev/null +++ b/packages/card-service/src/card-payment/model.ts @@ -0,0 +1,16 @@ +import { BaseModel } from '../shared/baseModel' + +export class CardPayment extends BaseModel { + public static get tableName(): string { + return 'card_payments' + } + + public requestId!: string + public requestedAt!: Date | null + public finalizedAt!: Date | null + public cardWalletAddress!: string + public incomingPaymentUrl?: string + public statusCode?: number + public outgoingPaymentId?: string + public terminalId!: string +} diff --git a/packages/card-service/src/shared/baseModel.test.ts b/packages/card-service/src/shared/baseModel.test.ts new file mode 100644 index 0000000000..cfb256ec25 --- /dev/null +++ b/packages/card-service/src/shared/baseModel.test.ts @@ -0,0 +1,128 @@ +import { BaseModel, Pagination, SortOrder } from './baseModel' +import { getPageInfo } from './pagination' + +interface PageTestsOptions { + createModel: () => Promise + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise +} + +export const getPageTests = ({ + createModel, + getPage +}: PageTestsOptions): void => { + describe('Common BaseModel pagination', (): void => { + let modelsCreated: Type[] + + beforeEach(async (): Promise => { + modelsCreated = [] + for (let i = 0; i < 22; i++) { + modelsCreated.push(await createModel()) + } + modelsCreated.reverse() // default sort order is DESC + }) + + test.each` + pagination | expected | description + ${undefined} | ${{ length: 20, first: 0, last: 19 }} | ${'Defaults to fetching first 20 items'} + ${{ first: 10 }} | ${{ length: 10, first: 0, last: 9 }} | ${'Can change forward pagination limit'} + ${{ after: 0 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Can paginate forwards from a cursor'} + ${{ first: 10, after: 9 }} | ${{ length: 10, first: 10, last: 19 }} | ${'Can paginate forwards from a cursor with a limit'} + ${{ before: 20 }} | ${{ length: 20, first: 0, last: 19 }} | ${'Can paginate backwards from a cursor'} + ${{ last: 5, before: 10 }} | ${{ length: 5, first: 5, last: 9 }} | ${'Can paginate backwards from a cursor with a limit'} + ${{ after: 0, before: 19 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Providing before and after results in forward pagination'} + `('$description', async ({ pagination, expected }): Promise => { + if (pagination?.after !== undefined) { + pagination.after = modelsCreated[pagination.after].id + } + if (pagination?.before !== undefined) { + pagination.before = modelsCreated[pagination.before].id + } + const models = await getPage(pagination) + expect(models).toHaveLength(expected.length) + expect(models[0].id).toEqual(modelsCreated[expected.first].id) + expect(models[expected.length - 1].id).toEqual( + modelsCreated[expected.last].id + ) + }) + + test.each` + pagination | expectedError | description + ${{ last: 10 }} | ${"Can't paginate backwards from the start."} | ${"Can't change backward pagination limit on it's own."} + ${{ first: -1 }} | ${'Pagination index error'} | ${"Can't request less than 0"} + ${{ first: 101 }} | ${'Pagination index error'} | ${"Can't request more than 100"} + `('$description', async ({ pagination, expectedError }): Promise => { + await expect(getPage(pagination)).rejects.toThrow(expectedError) + }) + + test.each` + order | description + ${SortOrder.Asc} | ${'Backwards/Forwards pagination results in same order for ASC.'} + ${SortOrder.Desc} | ${'Backwards/Forwards pagination results in same order for DESC.'} + `('$description', async ({ order }): Promise => { + if (order === SortOrder.Asc) { + // model was in DESC order so needs to be reverted back to ASC + modelsCreated.reverse() + } + const paginationForwards = { + first: 10 + } + const modelsForwards = await getPage(paginationForwards, order) + const paginationBackwards = { + last: 10, + before: modelsCreated[10].id + } + const modelsBackwards = await getPage(paginationBackwards, order) + expect(modelsForwards).toHaveLength(10) + expect(modelsBackwards).toHaveLength(10) + expect(modelsForwards).toEqual(modelsBackwards) + }) + + test.each` + pagination | cursor | start | end | hasNextPage | hasPreviousPage | sortOrder + ${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Desc} + ${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Desc} + ${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Desc} + ${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Desc} + ${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Desc} + ${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Asc} + ${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Asc} + ${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Asc} + ${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Asc} + ${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Asc} + `( + 'pagination $pagination with cursor $cursor in $sortOrder order', + async ({ + pagination, + cursor, + start, + end, + hasNextPage, + hasPreviousPage, + sortOrder + }): Promise => { + if (sortOrder === SortOrder.Asc) { + modelsCreated.reverse() + } + if (cursor) { + if (pagination.last) pagination.before = modelsCreated[cursor].id + else pagination.after = modelsCreated[cursor].id + } + + const page = await getPage(pagination, sortOrder) + const pageInfo = await getPageInfo({ + getPage: (pagination, sortOrder) => getPage(pagination, sortOrder), + page, + sortOrder + }) + expect(pageInfo).toEqual({ + startCursor: modelsCreated[start].id, + endCursor: modelsCreated[end].id, + hasNextPage, + hasPreviousPage + }) + } + ) + }) +} + +test.todo('test suite must contain at least one test') diff --git a/packages/card-service/src/shared/baseModel.ts b/packages/card-service/src/shared/baseModel.ts new file mode 100644 index 0000000000..1b361238d1 --- /dev/null +++ b/packages/card-service/src/shared/baseModel.ts @@ -0,0 +1,144 @@ +import { + Model, + ModelOptions, + Page, + Pojo, + QueryBuilder, + QueryContext +} from 'objection' +import { DbErrors } from 'objection-db-errors' +import { v4 as uuid } from 'uuid' + +export interface Pagination { + after?: string // Forward pagination: cursor. + before?: string // Backward pagination: cursor. + first?: number // Forward pagination: limit. + last?: number // Backward pagination: limit. +} + +export interface PageInfo { + startCursor?: string + endCursor?: string + hasNextPage: boolean + hasPreviousPage: boolean +} + +export enum SortOrder { + Asc = 'ASC', + Desc = 'DESC' +} + +class PaginationQueryBuilder extends QueryBuilder< + M, + R +> { + ArrayQueryBuilderType!: PaginationQueryBuilder + SingleQueryBuilderType!: PaginationQueryBuilder + MaybeSingleQueryBuilderType!: PaginationQueryBuilder + NumberQueryBuilderType!: PaginationQueryBuilder + PageQueryBuilderType!: PaginationQueryBuilder> + + /** TODO: Base64 encode/decode the cursors + * Buffer.from("Hello World").toString('base64') + * Buffer.from("SGVsbG8gV29ybGQ=", 'base64').toString('ascii') + */ + + /** getPage + * The pagination algorithm is based on the Relay connection specification. + * Please read the spec before changing things: + * https://relay.dev/graphql/connections.htm + * @param pagination Pagination - cursors and limits. + * @param sortOrder SortOrder - Asc/Desc sort order. + * @returns Model[] An array of Models that form a page. + */ + getPage( + pagination?: Pagination, + sortOrder: SortOrder = SortOrder.Desc + ): this { + const tableName = this.modelClass().tableName + if ( + typeof pagination?.before === 'undefined' && + typeof pagination?.last === 'number' + ) + throw new Error("Can't paginate backwards from the start.") + + const first = pagination?.first || 20 + if (first < 0 || first > 100) throw new Error('Pagination index error') + const last = pagination?.last || 20 + if (last < 0 || last > 100) throw new Error('Pagination index error') + /** + * Forward pagination + */ + if (typeof pagination?.after === 'string') { + const comparisonOperator = sortOrder === SortOrder.Asc ? '>' : '<' + return this.whereRaw( + `("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, + [this.modelClass().tableName, pagination.after] + ) + .orderBy([ + { column: 'createdAt', order: sortOrder }, + { column: 'id', order: sortOrder } + ]) + .limit(first) + } + /** + * Backward pagination + */ + if (typeof pagination?.before === 'string') { + const comparisonOperator = sortOrder === SortOrder.Asc ? '<' : '>' + const order = sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc + return this.whereRaw( + `("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, + [this.modelClass().tableName, pagination.before] + ) + .orderBy([ + { column: 'createdAt', order }, + { column: 'id', order } + ]) + .limit(last) + .runAfter((models) => { + if (Array.isArray(models)) { + return models.reverse() + } + }) + } + + return this.orderBy([ + { column: 'createdAt', order: sortOrder }, + { column: 'id', order: sortOrder } + ]).limit(first) + } +} + +export class PaginationModel extends DbErrors(Model) { + QueryBuilderType!: PaginationQueryBuilder + static QueryBuilder = PaginationQueryBuilder +} + +export abstract class BaseModel extends PaginationModel { + public static get modelPaths(): string[] { + return [__dirname] + } + + public id!: string + public createdAt!: Date + public updatedAt!: Date + + public $beforeInsert(context: QueryContext): void { + super.$beforeInsert(context) + this.id = this.id || uuid() + } + + public $beforeUpdate(_opts: ModelOptions, _queryContext: QueryContext): void { + this.updatedAt = new Date() + } + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + createdAt: json.createdAt.toISOString(), + updatedAt: json.updatedAt.toISOString() + } + } +} diff --git a/packages/card-service/src/shared/baseService.ts b/packages/card-service/src/shared/baseService.ts new file mode 100644 index 0000000000..3254a736e2 --- /dev/null +++ b/packages/card-service/src/shared/baseService.ts @@ -0,0 +1,8 @@ +import { TransactionOrKnex } from 'objection' + +import { Logger } from 'pino' + +export interface BaseService { + logger: Logger + knex?: TransactionOrKnex +} diff --git a/packages/card-service/src/shared/filters.ts b/packages/card-service/src/shared/filters.ts new file mode 100644 index 0000000000..dcfeb6c0bc --- /dev/null +++ b/packages/card-service/src/shared/filters.ts @@ -0,0 +1,3 @@ +export interface FilterString { + in?: string[] +} diff --git a/packages/card-service/src/shared/pagination.ts b/packages/card-service/src/shared/pagination.ts new file mode 100644 index 0000000000..06a4bfc4e2 --- /dev/null +++ b/packages/card-service/src/shared/pagination.ts @@ -0,0 +1,53 @@ +import { BaseModel, PageInfo, Pagination, SortOrder } from './baseModel' + +type GetPageInfoArgs = { + getPage: (pagination: Pagination, sortOrder?: SortOrder) => Promise + page: T[] + sortOrder?: SortOrder +} + +export async function getPageInfo({ + getPage, + page, + sortOrder +}: GetPageInfoArgs): Promise { + if (page.length == 0) + return { + hasPreviousPage: false, + hasNextPage: false + } + const firstId = page[0].id + const lastId = page[page.length - 1].id + + let hasNextPage, hasPreviousPage + + try { + hasNextPage = await getPage( + { + after: lastId, + first: 1 + }, + sortOrder + ) + } catch (e) { + hasNextPage = [] + } + try { + hasPreviousPage = await getPage( + { + before: firstId, + last: 1 + }, + sortOrder + ) + } catch (e) { + hasPreviousPage = [] + } + + return { + endCursor: lastId, + hasNextPage: hasNextPage.length == 1, + hasPreviousPage: hasPreviousPage.length == 1, + startCursor: firstId + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e4c5026a5..82ab71780f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,9 +565,15 @@ importers: objection: specifier: ^3.1.5 version: 3.1.5(knex@3.1.0) + objection-db-errors: + specifier: ^1.1.2 + version: 1.1.2(objection@3.1.5) pino: specifier: ^8.19.0 version: 8.19.0 + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@types/koa': specifier: 2.15.0 @@ -581,6 +587,9 @@ importers: '@types/koa__router': specifier: ^12.0.4 version: 12.0.4 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 ts-node-dev: specifier: ^2.0.0 version: 2.0.0(@swc/core@1.11.29)(@types/node@20.14.15)(typescript@5.8.3) From e2ee2c6d2f82db34dc39e76885e7e9cb66e1876e Mon Sep 17 00:00:00 2001 From: Arpi Lengyel <165793743+lengyel-arpad85@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:29:13 +0300 Subject: [PATCH 04/22] feat(pos): create merchants table (#3519) * merchants table migrations and model --- .../20250708093546_create_merchants_table.js | 23 +++ packages/point-of-sale/package.json | 6 +- packages/point-of-sale/src/merchant/model.ts | 10 ++ .../src/shared/baseModel.test.ts | 128 ++++++++++++++++ .../point-of-sale/src/shared/baseModel.ts | 144 ++++++++++++++++++ .../point-of-sale/src/shared/baseService.ts | 8 + packages/point-of-sale/src/shared/filters.ts | 3 + .../point-of-sale/src/shared/pagination.ts | 53 +++++++ pnpm-lock.yaml | 6 + 9 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 packages/point-of-sale/migrations/20250708093546_create_merchants_table.js create mode 100644 packages/point-of-sale/src/merchant/model.ts create mode 100644 packages/point-of-sale/src/shared/baseModel.test.ts create mode 100644 packages/point-of-sale/src/shared/baseModel.ts create mode 100644 packages/point-of-sale/src/shared/baseService.ts create mode 100644 packages/point-of-sale/src/shared/filters.ts create mode 100644 packages/point-of-sale/src/shared/pagination.ts diff --git a/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js b/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js new file mode 100644 index 0000000000..97c7497c30 --- /dev/null +++ b/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('merchants', function (table) { + table.uuid('id').notNullable().primary() + + table.string('name').notNullable().unique() + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('merchants') +} diff --git a/packages/point-of-sale/package.json b/packages/point-of-sale/package.json index df3c1da79c..75ba472fde 100644 --- a/packages/point-of-sale/package.json +++ b/packages/point-of-sale/package.json @@ -27,12 +27,14 @@ "objection-db-errors": "^1.1.2", "pg": "^8.11.3", "pino": "^8.19.0", - "ts-node-dev": "^2.0.0" + "ts-node-dev": "^2.0.0", + "uuid": "^9.0.1" }, "devDependencies": { "@types/koa": "2.15.0", "@types/koa-bodyparser": "^4.3.12", "@types/koa__cors": "^5.0.0", - "@types/koa__router": "^12.0.4" + "@types/koa__router": "^12.0.4", + "@types/uuid": "^9.0.8" } } diff --git a/packages/point-of-sale/src/merchant/model.ts b/packages/point-of-sale/src/merchant/model.ts new file mode 100644 index 0000000000..d19a6bb651 --- /dev/null +++ b/packages/point-of-sale/src/merchant/model.ts @@ -0,0 +1,10 @@ +import { BaseModel } from '../shared/baseModel' + +export class Merchant extends BaseModel { + public static get tableName(): string { + return 'merchants' + } + + public name!: string + public deletedAt!: Date | null +} diff --git a/packages/point-of-sale/src/shared/baseModel.test.ts b/packages/point-of-sale/src/shared/baseModel.test.ts new file mode 100644 index 0000000000..cfb256ec25 --- /dev/null +++ b/packages/point-of-sale/src/shared/baseModel.test.ts @@ -0,0 +1,128 @@ +import { BaseModel, Pagination, SortOrder } from './baseModel' +import { getPageInfo } from './pagination' + +interface PageTestsOptions { + createModel: () => Promise + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise +} + +export const getPageTests = ({ + createModel, + getPage +}: PageTestsOptions): void => { + describe('Common BaseModel pagination', (): void => { + let modelsCreated: Type[] + + beforeEach(async (): Promise => { + modelsCreated = [] + for (let i = 0; i < 22; i++) { + modelsCreated.push(await createModel()) + } + modelsCreated.reverse() // default sort order is DESC + }) + + test.each` + pagination | expected | description + ${undefined} | ${{ length: 20, first: 0, last: 19 }} | ${'Defaults to fetching first 20 items'} + ${{ first: 10 }} | ${{ length: 10, first: 0, last: 9 }} | ${'Can change forward pagination limit'} + ${{ after: 0 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Can paginate forwards from a cursor'} + ${{ first: 10, after: 9 }} | ${{ length: 10, first: 10, last: 19 }} | ${'Can paginate forwards from a cursor with a limit'} + ${{ before: 20 }} | ${{ length: 20, first: 0, last: 19 }} | ${'Can paginate backwards from a cursor'} + ${{ last: 5, before: 10 }} | ${{ length: 5, first: 5, last: 9 }} | ${'Can paginate backwards from a cursor with a limit'} + ${{ after: 0, before: 19 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Providing before and after results in forward pagination'} + `('$description', async ({ pagination, expected }): Promise => { + if (pagination?.after !== undefined) { + pagination.after = modelsCreated[pagination.after].id + } + if (pagination?.before !== undefined) { + pagination.before = modelsCreated[pagination.before].id + } + const models = await getPage(pagination) + expect(models).toHaveLength(expected.length) + expect(models[0].id).toEqual(modelsCreated[expected.first].id) + expect(models[expected.length - 1].id).toEqual( + modelsCreated[expected.last].id + ) + }) + + test.each` + pagination | expectedError | description + ${{ last: 10 }} | ${"Can't paginate backwards from the start."} | ${"Can't change backward pagination limit on it's own."} + ${{ first: -1 }} | ${'Pagination index error'} | ${"Can't request less than 0"} + ${{ first: 101 }} | ${'Pagination index error'} | ${"Can't request more than 100"} + `('$description', async ({ pagination, expectedError }): Promise => { + await expect(getPage(pagination)).rejects.toThrow(expectedError) + }) + + test.each` + order | description + ${SortOrder.Asc} | ${'Backwards/Forwards pagination results in same order for ASC.'} + ${SortOrder.Desc} | ${'Backwards/Forwards pagination results in same order for DESC.'} + `('$description', async ({ order }): Promise => { + if (order === SortOrder.Asc) { + // model was in DESC order so needs to be reverted back to ASC + modelsCreated.reverse() + } + const paginationForwards = { + first: 10 + } + const modelsForwards = await getPage(paginationForwards, order) + const paginationBackwards = { + last: 10, + before: modelsCreated[10].id + } + const modelsBackwards = await getPage(paginationBackwards, order) + expect(modelsForwards).toHaveLength(10) + expect(modelsBackwards).toHaveLength(10) + expect(modelsForwards).toEqual(modelsBackwards) + }) + + test.each` + pagination | cursor | start | end | hasNextPage | hasPreviousPage | sortOrder + ${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Desc} + ${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Desc} + ${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Desc} + ${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Desc} + ${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Desc} + ${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Asc} + ${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Asc} + ${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Asc} + ${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Asc} + ${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Asc} + `( + 'pagination $pagination with cursor $cursor in $sortOrder order', + async ({ + pagination, + cursor, + start, + end, + hasNextPage, + hasPreviousPage, + sortOrder + }): Promise => { + if (sortOrder === SortOrder.Asc) { + modelsCreated.reverse() + } + if (cursor) { + if (pagination.last) pagination.before = modelsCreated[cursor].id + else pagination.after = modelsCreated[cursor].id + } + + const page = await getPage(pagination, sortOrder) + const pageInfo = await getPageInfo({ + getPage: (pagination, sortOrder) => getPage(pagination, sortOrder), + page, + sortOrder + }) + expect(pageInfo).toEqual({ + startCursor: modelsCreated[start].id, + endCursor: modelsCreated[end].id, + hasNextPage, + hasPreviousPage + }) + } + ) + }) +} + +test.todo('test suite must contain at least one test') diff --git a/packages/point-of-sale/src/shared/baseModel.ts b/packages/point-of-sale/src/shared/baseModel.ts new file mode 100644 index 0000000000..1b361238d1 --- /dev/null +++ b/packages/point-of-sale/src/shared/baseModel.ts @@ -0,0 +1,144 @@ +import { + Model, + ModelOptions, + Page, + Pojo, + QueryBuilder, + QueryContext +} from 'objection' +import { DbErrors } from 'objection-db-errors' +import { v4 as uuid } from 'uuid' + +export interface Pagination { + after?: string // Forward pagination: cursor. + before?: string // Backward pagination: cursor. + first?: number // Forward pagination: limit. + last?: number // Backward pagination: limit. +} + +export interface PageInfo { + startCursor?: string + endCursor?: string + hasNextPage: boolean + hasPreviousPage: boolean +} + +export enum SortOrder { + Asc = 'ASC', + Desc = 'DESC' +} + +class PaginationQueryBuilder extends QueryBuilder< + M, + R +> { + ArrayQueryBuilderType!: PaginationQueryBuilder + SingleQueryBuilderType!: PaginationQueryBuilder + MaybeSingleQueryBuilderType!: PaginationQueryBuilder + NumberQueryBuilderType!: PaginationQueryBuilder + PageQueryBuilderType!: PaginationQueryBuilder> + + /** TODO: Base64 encode/decode the cursors + * Buffer.from("Hello World").toString('base64') + * Buffer.from("SGVsbG8gV29ybGQ=", 'base64').toString('ascii') + */ + + /** getPage + * The pagination algorithm is based on the Relay connection specification. + * Please read the spec before changing things: + * https://relay.dev/graphql/connections.htm + * @param pagination Pagination - cursors and limits. + * @param sortOrder SortOrder - Asc/Desc sort order. + * @returns Model[] An array of Models that form a page. + */ + getPage( + pagination?: Pagination, + sortOrder: SortOrder = SortOrder.Desc + ): this { + const tableName = this.modelClass().tableName + if ( + typeof pagination?.before === 'undefined' && + typeof pagination?.last === 'number' + ) + throw new Error("Can't paginate backwards from the start.") + + const first = pagination?.first || 20 + if (first < 0 || first > 100) throw new Error('Pagination index error') + const last = pagination?.last || 20 + if (last < 0 || last > 100) throw new Error('Pagination index error') + /** + * Forward pagination + */ + if (typeof pagination?.after === 'string') { + const comparisonOperator = sortOrder === SortOrder.Asc ? '>' : '<' + return this.whereRaw( + `("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, + [this.modelClass().tableName, pagination.after] + ) + .orderBy([ + { column: 'createdAt', order: sortOrder }, + { column: 'id', order: sortOrder } + ]) + .limit(first) + } + /** + * Backward pagination + */ + if (typeof pagination?.before === 'string') { + const comparisonOperator = sortOrder === SortOrder.Asc ? '<' : '>' + const order = sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc + return this.whereRaw( + `("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`, + [this.modelClass().tableName, pagination.before] + ) + .orderBy([ + { column: 'createdAt', order }, + { column: 'id', order } + ]) + .limit(last) + .runAfter((models) => { + if (Array.isArray(models)) { + return models.reverse() + } + }) + } + + return this.orderBy([ + { column: 'createdAt', order: sortOrder }, + { column: 'id', order: sortOrder } + ]).limit(first) + } +} + +export class PaginationModel extends DbErrors(Model) { + QueryBuilderType!: PaginationQueryBuilder + static QueryBuilder = PaginationQueryBuilder +} + +export abstract class BaseModel extends PaginationModel { + public static get modelPaths(): string[] { + return [__dirname] + } + + public id!: string + public createdAt!: Date + public updatedAt!: Date + + public $beforeInsert(context: QueryContext): void { + super.$beforeInsert(context) + this.id = this.id || uuid() + } + + public $beforeUpdate(_opts: ModelOptions, _queryContext: QueryContext): void { + this.updatedAt = new Date() + } + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + createdAt: json.createdAt.toISOString(), + updatedAt: json.updatedAt.toISOString() + } + } +} diff --git a/packages/point-of-sale/src/shared/baseService.ts b/packages/point-of-sale/src/shared/baseService.ts new file mode 100644 index 0000000000..3254a736e2 --- /dev/null +++ b/packages/point-of-sale/src/shared/baseService.ts @@ -0,0 +1,8 @@ +import { TransactionOrKnex } from 'objection' + +import { Logger } from 'pino' + +export interface BaseService { + logger: Logger + knex?: TransactionOrKnex +} diff --git a/packages/point-of-sale/src/shared/filters.ts b/packages/point-of-sale/src/shared/filters.ts new file mode 100644 index 0000000000..dcfeb6c0bc --- /dev/null +++ b/packages/point-of-sale/src/shared/filters.ts @@ -0,0 +1,3 @@ +export interface FilterString { + in?: string[] +} diff --git a/packages/point-of-sale/src/shared/pagination.ts b/packages/point-of-sale/src/shared/pagination.ts new file mode 100644 index 0000000000..06a4bfc4e2 --- /dev/null +++ b/packages/point-of-sale/src/shared/pagination.ts @@ -0,0 +1,53 @@ +import { BaseModel, PageInfo, Pagination, SortOrder } from './baseModel' + +type GetPageInfoArgs = { + getPage: (pagination: Pagination, sortOrder?: SortOrder) => Promise + page: T[] + sortOrder?: SortOrder +} + +export async function getPageInfo({ + getPage, + page, + sortOrder +}: GetPageInfoArgs): Promise { + if (page.length == 0) + return { + hasPreviousPage: false, + hasNextPage: false + } + const firstId = page[0].id + const lastId = page[page.length - 1].id + + let hasNextPage, hasPreviousPage + + try { + hasNextPage = await getPage( + { + after: lastId, + first: 1 + }, + sortOrder + ) + } catch (e) { + hasNextPage = [] + } + try { + hasPreviousPage = await getPage( + { + before: firstId, + last: 1 + }, + sortOrder + ) + } catch (e) { + hasPreviousPage = [] + } + + return { + endCursor: lastId, + hasNextPage: hasNextPage.length == 1, + hasPreviousPage: hasPreviousPage.length == 1, + startCursor: firstId + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82ab71780f..3cf9679cad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -811,6 +811,9 @@ importers: ts-node-dev: specifier: ^2.0.0 version: 2.0.0(@swc/core@1.11.29)(@types/node@20.14.15)(typescript@5.8.3) + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@types/koa': specifier: 2.15.0 @@ -824,6 +827,9 @@ importers: '@types/koa__router': specifier: ^12.0.4 version: 12.0.4 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 packages/token-introspection: dependencies: From b5b401fbf8e768e3ebf5924d856976399e9bb955 Mon Sep 17 00:00:00 2001 From: oana-lolea Date: Tue, 8 Jul 2025 16:17:42 +0300 Subject: [PATCH 05/22] Docker compose fix (for slower PCs) on localenv (#3522) --- localenv/cloud-nine-wallet/dbinit.sql | 12 ++++++------ localenv/cloud-nine-wallet/docker-compose.yml | 2 +- localenv/happy-life-bank/docker-compose.yml | 4 +++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/localenv/cloud-nine-wallet/dbinit.sql b/localenv/cloud-nine-wallet/dbinit.sql index ba680bd520..eff123c390 100644 --- a/localenv/cloud-nine-wallet/dbinit.sql +++ b/localenv/cloud-nine-wallet/dbinit.sql @@ -18,13 +18,13 @@ CREATE USER happy_life_bank_auth WITH PASSWORD 'happy_life_bank_auth'; CREATE DATABASE happy_life_bank_auth; ALTER DATABASE happy_life_bank_auth OWNER TO happy_life_bank_auth; -CREATE USER cloud_nine_wallet_pos WITH PASSWORD 'cloud_nine_wallet_pos'; -CREATE DATABASE cloud_nine_wallet_pos; -ALTER DATABASE cloud_nine_wallet_pos OWNER TO cloud_nine_wallet_pos; +CREATE USER cloud_nine_wallet_point_of_sale WITH PASSWORD 'cloud_nine_wallet_point_of_sale'; +CREATE DATABASE cloud_nine_wallet_point_of_sale; +ALTER DATABASE cloud_nine_wallet_point_of_sale OWNER TO cloud_nine_wallet_point_of_sale; -CREATE USER happy_life_bank_pos WITH PASSWORD 'happy_life_bank_pos'; -CREATE DATABASE happy_life_bank_pos; -ALTER DATABASE happy_life_bank_pos OWNER TO happy_life_bank_pos; +CREATE USER happy_life_bank_point_of_sale WITH PASSWORD 'happy_life_bank_point_of_sale'; +CREATE DATABASE happy_life_bank_point_of_sale; +ALTER DATABASE happy_life_bank_point_of_sale OWNER TO happy_life_bank_point_of_sale; CREATE USER happy_life_bank_card_service WITH PASSWORD 'happy_life_bank_card_service'; CREATE DATABASE happy_life_bank_card_service; diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index c77a684576..2b6a7ec64a 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -34,7 +34,7 @@ services: timeout: 3s cloud-nine-wallet-point-of-sale: hostname: cloud-nine-wallet-point-of-sale - image: rafiki-point-of-sale + image: rafiki-point-of-sale build: context: ../.. dockerfile: ./packages/point-of-sale/Dockerfile.dev diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index c8c3ad885d..7b39fbeefc 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -25,6 +25,7 @@ services: DATABASE_URL: postgresql://happy_life_bank_card_service:happy_life_bank_card_service@shared-database/happy_life_bank_card_service depends_on: - shared-database + - cloud-nine-wallet-card-service healthcheck: test: ["CMD", "wget", "--spider", "http://localhost:3007/healthz"] start_period: 60s @@ -56,7 +57,8 @@ services: PORT: 4008 DATABASE_URL: postgresql://happy_life_bank_point_of_sale:happy_life_bank_point_of_sale@shared-database/happy_life_bank_point_of_sale depends_on: - - shared-database + - shared-database + - cloud-nine-wallet-point-of-sale healthcheck: test: ["CMD", "wget", "--spider", "http://localhost:4008/healthz"] start_period: 60s From dcf0f618ec3e94caf23d7e4fa2734386a88a85b2 Mon Sep 17 00:00:00 2001 From: Arpi Lengyel Date: Tue, 8 Jul 2025 16:20:50 +0300 Subject: [PATCH 06/22] typo --- packages/card-service/src/card-payment/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/card-service/src/card-payment/model.ts b/packages/card-service/src/card-payment/model.ts index 704aeb49d3..3a0e832d92 100644 --- a/packages/card-service/src/card-payment/model.ts +++ b/packages/card-service/src/card-payment/model.ts @@ -2,7 +2,7 @@ import { BaseModel } from '../shared/baseModel' export class CardPayment extends BaseModel { public static get tableName(): string { - return 'card_payments' + return 'cardPayments' } public requestId!: string From 3786e36216563837efd072e563b1f54e0c6f81b7 Mon Sep 17 00:00:00 2001 From: xplicit <111863110+beniaminmunteanu@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:12:52 +0300 Subject: [PATCH 07/22] fix(cards-service): add dependencies to package.json (#3530) * fix(cards-service): add dependencies to package.json * chore(cards-service): lockfile --- packages/card-service/package.json | 3 +++ pnpm-lock.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/card-service/package.json b/packages/card-service/package.json index 39fafcbf8a..574742fcf6 100644 --- a/packages/card-service/package.json +++ b/packages/card-service/package.json @@ -15,9 +15,12 @@ "dependencies": { "@adonisjs/fold": "^8.2.0", "@koa/cors": "^5.0.0", + "@koa/router": "^12.0.2", + "koa-bodyparser": "^4.4.1", "knex": "^3.1.0", "koa": "^2.15.4", "objection": "^3.1.5", + "pg": "^8.11.3", "objection-db-errors": "^1.1.2", "pino": "^8.19.0", "uuid": "^9.0.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cf9679cad..1d2c3a6a31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -556,18 +556,27 @@ importers: '@koa/cors': specifier: ^5.0.0 version: 5.0.0 + '@koa/router': + specifier: ^12.0.2 + version: 12.0.2 knex: specifier: ^3.1.0 version: 3.1.0(pg@8.11.3) koa: specifier: ^2.15.4 version: 2.16.0 + koa-bodyparser: + specifier: ^4.4.1 + version: 4.4.1 objection: specifier: ^3.1.5 version: 3.1.5(knex@3.1.0) objection-db-errors: specifier: ^1.1.2 version: 1.1.2(objection@3.1.5) + pg: + specifier: ^8.11.3 + version: 8.11.3 pino: specifier: ^8.19.0 version: 8.19.0 From 5f78feb6585fddd7a840a9d093f919ba5ffb2f83 Mon Sep 17 00:00:00 2001 From: zeppelin44 Date: Wed, 9 Jul 2025 10:24:46 +0300 Subject: [PATCH 08/22] feat: Added CardService client in rafiki backend (#3510) --- packages/backend/jest.env.js | 1 + packages/backend/src/card/service.test.ts | 71 +++++++++++++++++++ packages/backend/src/card/service.ts | 54 ++++++++++++++ packages/backend/src/config/app.ts | 3 +- packages/backend/src/index.ts | 9 +++ .../cloud-nine-wallet/docker-compose.yml | 1 + .../happy-life-bank/docker-compose.yml | 1 + 7 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/card/service.test.ts create mode 100644 packages/backend/src/card/service.ts diff --git a/packages/backend/jest.env.js b/packages/backend/jest.env.js index 14b2fdd839..3565d21ac4 100644 --- a/packages/backend/jest.env.js +++ b/packages/backend/jest.env.js @@ -16,3 +16,4 @@ process.env.AUTH_ADMIN_API_SECRET = 'test-secret' process.env.OPERATOR_TENANT_ID = 'cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' process.env.API_SECRET = 'KQEXlZO65jUJXakXnLxGO7dk387mt71G9tZ42rULSNU=' process.env.EXCHANGE_RATES_URL = 'http://example.com/rates' +process.env.CARD_SERVICE_HOST = 'http://127.0.0.1:3007' diff --git a/packages/backend/src/card/service.test.ts b/packages/backend/src/card/service.test.ts new file mode 100644 index 0000000000..ebc0f67d0f --- /dev/null +++ b/packages/backend/src/card/service.test.ts @@ -0,0 +1,71 @@ +import { AxiosInstance } from 'axios' +import { createCardService } from './service' +import { Logger } from 'pino' + +describe('Card Service', () => { + let mockAxios: Partial + let cardServiceHost: string + let mockLogger: Partial + let cardService: Awaited> + + beforeAll(async () => { + mockAxios = { + post: jest.fn() + } + mockLogger = { + error: jest.fn(), + child: jest.fn().mockReturnThis() + } + cardServiceHost = 'http://card-service.test' + cardService = await createCardService({ + axios: mockAxios as AxiosInstance, + cardServiceHost, + logger: mockLogger as Logger + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('sendPaymentEvent', () => { + const eventDetails = { + requestId: 'req-123', + outgoingPaymentId: 'out-456', + resultCode: 'completed' + } + + test('sends payment event to the correct endpoint', async () => { + ;(mockAxios.post as jest.Mock).mockResolvedValueOnce({ status: 200 }) + await expect( + cardService.sendPaymentEvent(eventDetails) + ).resolves.toBeUndefined() + expect(mockAxios.post).toHaveBeenCalledWith( + `${cardServiceHost}/payment-event`, + eventDetails + ) + }) + + test('propagates errors from axios', async () => { + const error = new Error('network error') + ;(mockAxios.post as jest.Mock).mockRejectedValueOnce(error) + await expect(cardService.sendPaymentEvent(eventDetails)).rejects.toThrow( + 'network error' + ) + }) + + test('logs and throws if response status is not 200', async () => { + ;(mockAxios.post as jest.Mock).mockResolvedValueOnce({ status: 500 }) + await expect(cardService.sendPaymentEvent(eventDetails)).rejects.toThrow( + `Failed to send payment event with details ${JSON.stringify(eventDetails)}` + ) + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + status: 500, + eventDetails + }), + 'Failed to send payment event' + ) + }) + }) +}) diff --git a/packages/backend/src/card/service.ts b/packages/backend/src/card/service.ts new file mode 100644 index 0000000000..f3407cf08d --- /dev/null +++ b/packages/backend/src/card/service.ts @@ -0,0 +1,54 @@ +import { AxiosInstance } from 'axios' +import { Logger } from 'pino' + +const PAYMENT_FOUNDED_PATH = '/payment-event' + +type EventDetails = { + requestId: string + outgoingPaymentId: string + resultCode: string +} + +export interface CardService { + sendPaymentEvent(eventDetails: EventDetails): Promise +} + +interface ServiceDependencies { + axios: AxiosInstance + cardServiceHost: string + logger: Logger +} + +export async function createCardService( + deps_: ServiceDependencies +): Promise { + const logger = deps_.logger.child({ + service: 'CardService' + }) + const deps = { + ...deps_, + logger + } + + return { + sendPaymentEvent: (eventDetails: EventDetails) => + sendPaymentEvent(deps, eventDetails) + } +} + +async function sendPaymentEvent( + deps: ServiceDependencies, + eventDetails: EventDetails +) { + const { status } = await deps.axios.post( + `${deps.cardServiceHost}${PAYMENT_FOUNDED_PATH}`, + eventDetails + ) + + if (status !== 200) { + deps.logger.error({ status, eventDetails }, 'Failed to send payment event') + throw new Error( + `Failed to send payment event with details ${JSON.stringify(eventDetails)}` + ) + } +} diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index a6710ecc8c..5c8be77e37 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -202,7 +202,8 @@ export const Config = { sendTenantWebhooksToOperator: envBool( 'SEND_TENANT_WEBHOOKS_TO_OPERATOR', false - ) + ), + cardServiceHost: envString('CARD_SERVICE_HOST') } function parseRedisTlsConfig( diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 73cbdfbab6..73ce7155b1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -77,6 +77,7 @@ import { createTenantService } from './tenants/service' import { AuthServiceClient } from './auth-service-client/client' import { createTenantSettingService } from './tenants/settings/service' import { createPaymentMethodProviderService } from './payment-method/provider/service' +import { createCardService } from './card/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -671,6 +672,14 @@ export function initIocContainer( }) }) + container.singleton('cardService', async (deps) => { + return createCardService({ + axios: await deps.use('axios'), + logger: await deps.use('logger'), + cardServiceHost: config.cardServiceHost + }) + }) + return container } diff --git a/test/testenv/cloud-nine-wallet/docker-compose.yml b/test/testenv/cloud-nine-wallet/docker-compose.yml index fcf8630f5f..6324765c81 100644 --- a/test/testenv/cloud-nine-wallet/docker-compose.yml +++ b/test/testenv/cloud-nine-wallet/docker-compose.yml @@ -30,6 +30,7 @@ services: KEY_ID: keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 PRIVATE_KEY_FILE: /workspace/private-key.pem AUTH_SERVER_INTROSPECTION_URL: http://cloud-nine-wallet-test-auth:3107 + CARD_SERVICE_HOST: http://cloud-nine-wallet-card-service:3007 AUTH_SERVER_GRANT_URL: http://cloud-nine-wallet-test-auth:3106 AUTH_ADMIN_API_URL: 'http://cloud-nine-wallet-test-auth:3003/graphql' AUTH_ADMIN_API_SECRET: 'test-secret' diff --git a/test/testenv/happy-life-bank/docker-compose.yml b/test/testenv/happy-life-bank/docker-compose.yml index 951a769ad3..c7637c50cc 100644 --- a/test/testenv/happy-life-bank/docker-compose.yml +++ b/test/testenv/happy-life-bank/docker-compose.yml @@ -26,6 +26,7 @@ services: DATABASE_URL: postgresql://happy_life_bank_test_backend:happy_life_bank_test_backend@shared-database/happy_life_bank_test_backend AUTH_SERVER_GRANT_URL: http://happy-life-bank-test-auth:4106 AUTH_SERVER_INTROSPECTION_URL: http://happy-life-bank-test-auth:4107 + CARD_SERVICE_HOST: http://happy-life-bank-card-service:3007 AUTH_ADMIN_API_URL: 'http://happy-life-bank-test-auth:4003/graphql' AUTH_ADMIN_API_SECRET: 'test-secret' AUTH_SERVICE_API_URL: 'http://happy-life-bank-test-auth:4111/' From 93a0cb86a17e9820ac81bc05e622a4dc59e148eb Mon Sep 17 00:00:00 2001 From: Arpi Lengyel <165793743+lengyel-arpad85@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:12:46 +0300 Subject: [PATCH 09/22] feat(pos): pos card services table (#3526) * merchants table migrations and model --- .../20250708121812_create_pos_device_table.js | 36 ++++++++++++++++++ .../src/merchant/devices/model.ts | 37 +++++++++++++++++++ packages/point-of-sale/src/merchant/model.ts | 14 +++++++ 3 files changed, 87 insertions(+) create mode 100644 packages/point-of-sale/migrations/20250708121812_create_pos_device_table.js create mode 100644 packages/point-of-sale/src/merchant/devices/model.ts diff --git a/packages/point-of-sale/migrations/20250708121812_create_pos_device_table.js b/packages/point-of-sale/migrations/20250708121812_create_pos_device_table.js new file mode 100644 index 0000000000..4d1d6d134b --- /dev/null +++ b/packages/point-of-sale/migrations/20250708121812_create_pos_device_table.js @@ -0,0 +1,36 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('posDevices', function (table) { + table.uuid('id').notNullable().primary() + table + .uuid('merchantId') + .notNullable() + .references('merchants.id') + .onDelete('CASCADE') + .index() + + table.uuid('walletAddressId').notNullable() + + table.string('deviceName').notNullable() + table.string('publicKey') + table.string('keyId') + table.string('algorithm') + + table.string('status').notNullable() + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('posDevices') +} diff --git a/packages/point-of-sale/src/merchant/devices/model.ts b/packages/point-of-sale/src/merchant/devices/model.ts new file mode 100644 index 0000000000..63c7db77e4 --- /dev/null +++ b/packages/point-of-sale/src/merchant/devices/model.ts @@ -0,0 +1,37 @@ +import { BaseModel } from '../../shared/baseModel' +import { Model } from 'objection' +import { join } from 'path' + +export enum DeviceStatus { + Active = 'ACTIVE', + Revoked = 'REVOKED' +} + +export class PosDevice extends BaseModel { + public static get tableName(): string { + return 'posDevices' + } + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + static relationMappings = () => ({ + merchant: { + relation: Model.BelongsToOneRelation, + modelClass: join(__dirname, '../model'), + join: { + from: 'posDevices.merchantId', + to: 'merchants.id' + } + } + }) + + public merchantId!: string // foreign key on merchants table + public walletAddressId!: string + + public publicKey?: string // PEM format + public keyId?: string + public algorithm!: string // ecdsa-p256-sha256 + public status!: DeviceStatus // enum "ACTIVE" | "REVOKED" + + public deviceName!: string + public deletedAt!: Date | null +} diff --git a/packages/point-of-sale/src/merchant/model.ts b/packages/point-of-sale/src/merchant/model.ts index d19a6bb651..02b8c03a80 100644 --- a/packages/point-of-sale/src/merchant/model.ts +++ b/packages/point-of-sale/src/merchant/model.ts @@ -1,10 +1,24 @@ import { BaseModel } from '../shared/baseModel' +import { Model } from 'objection' +import { join } from 'path' export class Merchant extends BaseModel { public static get tableName(): string { return 'merchants' } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + static relationMappings = () => ({ + devices: { + relation: Model.HasManyRelation, + modelClass: join(__dirname, './devices/model'), + join: { + from: 'merchants.id', + to: 'posDevices.merchantId' + } + } + }) + public name!: string public deletedAt!: Date | null } From 7ade8bff7823b1b109d0e8544730354fed65b648 Mon Sep 17 00:00:00 2001 From: xplicit <111863110+beniaminmunteanu@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:50:51 +0300 Subject: [PATCH 10/22] feat(card-service): introduce testcontainers for database and redis (#3533) * feat(card-service): add testcontainers setup * chore(cards-service): lockfile * feat(cards-service): fix test containers setup * fix(cards-service): fix type issue --- packages/card-service/jest.config.js | 17 +++- .../card-service/jest.custom-environment.ts | 9 ++ packages/card-service/jest.env.js | 3 + packages/card-service/jest.setup.ts | 88 +++++++++++++++++++ packages/card-service/jest.teardown.js | 13 +++ packages/card-service/package.json | 11 ++- packages/card-service/scripts/init.sh | 8 ++ packages/card-service/src/tests/app.ts | 37 ++++++++ .../card-service/src/tests/tableManager.ts | 42 +++++++++ pnpm-lock.yaml | 15 ++-- 10 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 packages/card-service/jest.custom-environment.ts create mode 100644 packages/card-service/jest.env.js create mode 100644 packages/card-service/jest.setup.ts create mode 100644 packages/card-service/jest.teardown.js create mode 100755 packages/card-service/scripts/init.sh create mode 100644 packages/card-service/src/tests/app.ts create mode 100644 packages/card-service/src/tests/tableManager.ts diff --git a/packages/card-service/jest.config.js b/packages/card-service/jest.config.js index a2667d704f..cdb688ff95 100644 --- a/packages/card-service/jest.config.js +++ b/packages/card-service/jest.config.js @@ -7,10 +7,23 @@ const packageName = require('./package.json').name module.exports = { ...baseConfig, clearMocks: true, + testTimeout: 30000, roots: [`/packages/${packageName}`], + setupFiles: [`/packages/${packageName}/jest.env.js`], + globalSetup: `/packages/${packageName}/jest.setup.ts`, + globalTeardown: `/packages/${packageName}/jest.teardown.js`, testRegex: `(packages/${packageName}/.*/__tests__/.*|\\.(test|spec))\\.tsx?$`, - moduleDirectories: [`node_modules`, `packages/${packageName}/node_modules`], - modulePaths: [`/packages/${packageName}/src/`], + testEnvironment: `/packages/${packageName}/jest.custom-environment.ts`, + moduleDirectories: [ + `node_modules`, + `packages/${packageName}/node_modules`, + `/node_modules` + ], + modulePaths: [ + `node_modules`, + `/packages/${packageName}/src/`, + `/node_modules` + ], id: packageName, displayName: packageName, rootDir: '../..' diff --git a/packages/card-service/jest.custom-environment.ts b/packages/card-service/jest.custom-environment.ts new file mode 100644 index 0000000000..d725c84b67 --- /dev/null +++ b/packages/card-service/jest.custom-environment.ts @@ -0,0 +1,9 @@ +import { TestEnvironment } from 'jest-environment-node' +import nock from 'nock' + +export default class CustomEnvironment extends TestEnvironment { + constructor(config, context) { + super(config, context) + this.global.nock = nock + } +} diff --git a/packages/card-service/jest.env.js b/packages/card-service/jest.env.js new file mode 100644 index 0000000000..4dc9ca35cb --- /dev/null +++ b/packages/card-service/jest.env.js @@ -0,0 +1,3 @@ +// Jest environment configuration for card-service +process.env.NODE_ENV = 'test' +process.env.LOG_LEVEL = process.env.LOG_LEVEL || 'silent' diff --git a/packages/card-service/jest.setup.ts b/packages/card-service/jest.setup.ts new file mode 100644 index 0000000000..108cd08a3a --- /dev/null +++ b/packages/card-service/jest.setup.ts @@ -0,0 +1,88 @@ +import { knex } from 'knex' +import { GenericContainer, Wait } from 'testcontainers' +require('./jest.env') // set environment variables + +const POSTGRES_PORT = 5432 +const REDIS_PORT = 6379 + +const setup = async (globalConfig): Promise => { + const workers = globalConfig.maxWorkers + + const setupDatabase = async () => { + if (!process.env.DATABASE_URL) { + const postgresContainer = await new GenericContainer('postgres:15') + .withExposedPorts(POSTGRES_PORT) + .withBindMounts([ + { + source: __dirname + '/scripts/init.sh', + target: '/docker-entrypoint-initdb.d/init.sh' + } + ]) + .withEnvironment({ + POSTGRES_PASSWORD: 'password' + }) + .withHealthCheck({ + test: ['CMD-SHELL', 'pg_isready -d testing'], + interval: 10000, + timeout: 5000, + retries: 5 + }) + .withWaitStrategy(Wait.forHealthCheck()) + .start() + + process.env.DATABASE_URL = `postgresql://postgres:password@localhost:${postgresContainer.getMappedPort( + POSTGRES_PORT + )}/testing` + + global.__CARD_SERVICE_POSTGRES__ = postgresContainer + } + + const db = knex({ + client: 'postgresql', + connection: process.env.DATABASE_URL, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'knex_migrations' + } + }) + + // node pg defaults to returning bigint as string. This ensures it parses to bigint + db.client.driver.types.setTypeParser( + db.client.driver.types.builtins.INT8, + 'text', + BigInt + ) + await db.migrate.latest({ + directory: __dirname + '/migrations' + }) + + for (let i = 1; i <= workers; i++) { + const workerDatabaseName = `testing_${i}` + + await db.raw(`DROP DATABASE IF EXISTS ${workerDatabaseName}`) + await db.raw(`CREATE DATABASE ${workerDatabaseName} TEMPLATE testing`) + } + + global.__CARD_SERVICE_KNEX__ = db + } + + const setupRedis = async () => { + if (!process.env.REDIS_URL) { + const redisContainer = await new GenericContainer('redis:7') + .withExposedPorts(REDIS_PORT) + .start() + + global.__CARD_SERVICE_REDIS__ = redisContainer + process.env.REDIS_URL = `redis://localhost:${redisContainer.getMappedPort( + REDIS_PORT + )}` + } + } + + await Promise.all([setupDatabase(), setupRedis()]) +} + +export default setup diff --git a/packages/card-service/jest.teardown.js b/packages/card-service/jest.teardown.js new file mode 100644 index 0000000000..c0774b606c --- /dev/null +++ b/packages/card-service/jest.teardown.js @@ -0,0 +1,13 @@ +module.exports = async () => { + await global.__CARD_SERVICE_KNEX__.migrate.rollback( + { directory: __dirname + '/migrations' }, + true + ) + await global.__CARD_SERVICE_KNEX__.destroy() + if (global.__CARD_SERVICE_POSTGRES__) { + await global.__CARD_SERVICE_POSTGRES__.stop() + } + if (global.__CARD_SERVICE_REDIS__) { + await global.__CARD_SERVICE_REDIS__.stop() + } +} diff --git a/packages/card-service/package.json b/packages/card-service/package.json index 574742fcf6..4a57e1b30d 100644 --- a/packages/card-service/package.json +++ b/packages/card-service/package.json @@ -8,7 +8,9 @@ "scripts": { "build": "pnpm clean && tsc --build tsconfig.json", "clean": "rm -fr dist/", - "test": "jest", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --maxWorkers=50%", + "test:ci": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --maxWorkers=2", + "test:cov": "pnpm test -- --coverage", "dev": "ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only src/index.ts", "knex": "knex" }, @@ -17,8 +19,8 @@ "@koa/cors": "^5.0.0", "@koa/router": "^12.0.2", "koa-bodyparser": "^4.4.1", - "knex": "^3.1.0", "koa": "^2.15.4", + "knex": "^3.1.0", "objection": "^3.1.5", "pg": "^8.11.3", "objection-db-errors": "^1.1.2", @@ -31,6 +33,9 @@ "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.4", "@types/uuid": "^9.0.8", - "ts-node-dev": "^2.0.0" + "jest-environment-node": "^29.7.0", + "testcontainers": "^10.16.0", + "ts-node-dev": "^2.0.0", + "nock": "14.0.0-beta.19" } } diff --git a/packages/card-service/scripts/init.sh b/packages/card-service/scripts/init.sh new file mode 100755 index 0000000000..a1fa255c7d --- /dev/null +++ b/packages/card-service/scripts/init.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DROP DATABASE IF EXISTS TESTING; + CREATE DATABASE testing; + CREATE DATABASE development; +EOSQL diff --git a/packages/card-service/src/tests/app.ts b/packages/card-service/src/tests/app.ts new file mode 100644 index 0000000000..34e4ca37a3 --- /dev/null +++ b/packages/card-service/src/tests/app.ts @@ -0,0 +1,37 @@ +import { Knex } from 'knex' +import { IocContract } from '@adonisjs/fold' + +import { App, AppServices } from '../app' + +export interface TestContainer { + cardServicePort: number + app: App + knex: Knex + connectionUrl: string + shutdown: () => Promise + container: IocContract +} + +export const createTestApp = async ( + container: IocContract +): Promise => { + const config = await container.use('config') + config.cardServicePort = 0 + + const app = new App(container) + await app.boot() + await app.startCardServiceServer(config.cardServicePort) + + const knex = await container.use('knex') + + return { + app, + cardServicePort: app.getCardServicePort(), + knex, + connectionUrl: process.env.DATABASE_URL || '', + shutdown: async () => { + await app.shutdown() + }, + container + } +} diff --git a/packages/card-service/src/tests/tableManager.ts b/packages/card-service/src/tests/tableManager.ts new file mode 100644 index 0000000000..f8536c724b --- /dev/null +++ b/packages/card-service/src/tests/tableManager.ts @@ -0,0 +1,42 @@ +import { IocContract } from '@adonisjs/fold' +import { Knex } from 'knex' +import { AppServices } from '../app' + +export async function truncateTable( + knex: Knex, + tableName: string +): Promise { + const RAW = `TRUNCATE TABLE "${tableName}" RESTART IDENTITY CASCADE` + await knex.raw(RAW) +} + +export async function truncateTables( + deps: IocContract +): Promise { + const knex = await deps.use('knex') + + const ignoreTables = [ + 'knex_migrations', + 'knex_migrations_lock', + 'card_service_knex_migrations', + 'card_service_knex_migrations_lock' + ] + + const tables = await getTables(knex, ignoreTables) + if (tables.length > 0) { + const RAW = `TRUNCATE TABLE "${tables.join('","')}" RESTART IDENTITY CASCADE` + await knex.raw(RAW) + } +} + +async function getTables( + knex: Knex, + ignoredTables: string[] +): Promise { + const result = await knex.raw( + `SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public'` + ) + return result.rows + .map((val: { tablename: string }) => val.tablename) + .filter((tableName: string) => !ignoredTables.includes(tableName)) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d2c3a6a31..6ae15240d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -599,6 +599,15 @@ importers: '@types/uuid': specifier: ^9.0.8 version: 9.0.8 + jest-environment-node: + specifier: ^29.7.0 + version: 29.7.0 + nock: + specifier: 14.0.0-beta.19 + version: 14.0.0-beta.19 + testcontainers: + specifier: ^10.16.0 + version: 10.16.0 ts-node-dev: specifier: ^2.0.0 version: 2.0.0(@swc/core@1.11.29)(@types/node@20.14.15)(typescript@5.8.3) @@ -13530,10 +13539,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - /graceful-fs@4.2.10: - resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} - dev: true - /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -15324,7 +15329,7 @@ packages: '@types/node': 20.14.15 chalk: 4.1.2 ci-info: 3.8.0 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 picomatch: 2.3.1 dev: true From 7758da506481e9c14704b6247c305fd85b967278 Mon Sep 17 00:00:00 2001 From: Arpi Lengyel <165793743+lengyel-arpad85@users.noreply.github.com> Date: Thu, 10 Jul 2025 10:46:51 +0300 Subject: [PATCH 11/22] feat(pos): merchants services (#3528) * merchant service * jest setup * tests --- packages/point-of-sale/jest.config.js | 30 ++++++++ .../point-of-sale/jest.custom-environment.ts | 9 +++ packages/point-of-sale/jest.env.js | 0 packages/point-of-sale/jest.setup.ts | 74 +++++++++++++++++++ packages/point-of-sale/jest.teardown.js | 10 +++ packages/point-of-sale/package.json | 14 +++- packages/point-of-sale/scripts/init.sh | 8 ++ packages/point-of-sale/src/config/app.ts | 3 +- packages/point-of-sale/src/index.ts | 10 +++ .../src/merchant/service.test.ts | 65 ++++++++++++++++ .../point-of-sale/src/merchant/service.ts | 48 ++++++++++++ packages/point-of-sale/src/tests/app.ts | 41 ++++++++++ .../point-of-sale/src/tests/tableManager.ts | 45 +++++++++++ pnpm-lock.yaml | 18 +++++ 14 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 packages/point-of-sale/jest.config.js create mode 100644 packages/point-of-sale/jest.custom-environment.ts create mode 100644 packages/point-of-sale/jest.env.js create mode 100644 packages/point-of-sale/jest.setup.ts create mode 100644 packages/point-of-sale/jest.teardown.js create mode 100755 packages/point-of-sale/scripts/init.sh create mode 100644 packages/point-of-sale/src/merchant/service.test.ts create mode 100644 packages/point-of-sale/src/merchant/service.ts create mode 100644 packages/point-of-sale/src/tests/app.ts create mode 100644 packages/point-of-sale/src/tests/tableManager.ts diff --git a/packages/point-of-sale/jest.config.js b/packages/point-of-sale/jest.config.js new file mode 100644 index 0000000000..cdb688ff95 --- /dev/null +++ b/packages/point-of-sale/jest.config.js @@ -0,0 +1,30 @@ +'use strict' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const baseConfig = require('../../jest.config.base.js') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const packageName = require('./package.json').name + +module.exports = { + ...baseConfig, + clearMocks: true, + testTimeout: 30000, + roots: [`/packages/${packageName}`], + setupFiles: [`/packages/${packageName}/jest.env.js`], + globalSetup: `/packages/${packageName}/jest.setup.ts`, + globalTeardown: `/packages/${packageName}/jest.teardown.js`, + testRegex: `(packages/${packageName}/.*/__tests__/.*|\\.(test|spec))\\.tsx?$`, + testEnvironment: `/packages/${packageName}/jest.custom-environment.ts`, + moduleDirectories: [ + `node_modules`, + `packages/${packageName}/node_modules`, + `/node_modules` + ], + modulePaths: [ + `node_modules`, + `/packages/${packageName}/src/`, + `/node_modules` + ], + id: packageName, + displayName: packageName, + rootDir: '../..' +} diff --git a/packages/point-of-sale/jest.custom-environment.ts b/packages/point-of-sale/jest.custom-environment.ts new file mode 100644 index 0000000000..d725c84b67 --- /dev/null +++ b/packages/point-of-sale/jest.custom-environment.ts @@ -0,0 +1,9 @@ +import { TestEnvironment } from 'jest-environment-node' +import nock from 'nock' + +export default class CustomEnvironment extends TestEnvironment { + constructor(config, context) { + super(config, context) + this.global.nock = nock + } +} diff --git a/packages/point-of-sale/jest.env.js b/packages/point-of-sale/jest.env.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/point-of-sale/jest.setup.ts b/packages/point-of-sale/jest.setup.ts new file mode 100644 index 0000000000..1e38fe1c26 --- /dev/null +++ b/packages/point-of-sale/jest.setup.ts @@ -0,0 +1,74 @@ +import { knex } from 'knex' +import { GenericContainer, Wait } from 'testcontainers' +require('./jest.env') // set environment variables + +const POSTGRES_PORT = 5432 + +const setup = async (globalConfig): Promise => { + const workers = globalConfig.maxWorkers + + const setupDatabase = async () => { + if (!process.env.DATABASE_URL) { + const postgresContainer = await new GenericContainer('postgres:15') + .withExposedPorts(POSTGRES_PORT) + .withBindMounts([ + { + source: __dirname + '/scripts/init.sh', + target: '/docker-entrypoint-initdb.d/init.sh' + } + ]) + .withEnvironment({ + POSTGRES_PASSWORD: 'password' + }) + .withHealthCheck({ + test: ['CMD-SHELL', 'pg_isready -d testing'], + interval: 10000, + timeout: 5000, + retries: 5 + }) + .withWaitStrategy(Wait.forHealthCheck()) + .start() + + process.env.DATABASE_URL = `postgresql://postgres:password@localhost:${postgresContainer.getMappedPort( + POSTGRES_PORT + )}/testing` + + global.__POS_POSTGRES__ = postgresContainer + } + + const db = knex({ + client: 'postgresql', + connection: process.env.DATABASE_URL, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'pos_knex_migrations' + } + }) + + // node pg defaults to returning bigint as string. This ensures it parses to bigint + db.client.driver.types.setTypeParser( + db.client.driver.types.builtins.INT8, + 'text', + BigInt + ) + await db.migrate.latest({ + directory: __dirname + '/migrations' + }) + + for (let i = 1; i <= workers; i++) { + const workerDatabaseName = `testing_${i}` + + await db.raw(`DROP DATABASE IF EXISTS ${workerDatabaseName}`) + await db.raw(`CREATE DATABASE ${workerDatabaseName} TEMPLATE testing`) + } + + global.__POS_KNEX__ = db + } + + await Promise.all([setupDatabase()]) +} + +export default setup diff --git a/packages/point-of-sale/jest.teardown.js b/packages/point-of-sale/jest.teardown.js new file mode 100644 index 0000000000..12f33b679b --- /dev/null +++ b/packages/point-of-sale/jest.teardown.js @@ -0,0 +1,10 @@ +module.exports = async () => { + await global.__POS_KNEX__.migrate.rollback( + { directory: __dirname + '/migrations' }, + true + ) + await global.__POS_KNEX__.destroy() + if (global.__POS_POSTGRES__) { + await global.__POS_POSTGRES__.stop() + } +} diff --git a/packages/point-of-sale/package.json b/packages/point-of-sale/package.json index 75ba472fde..3d94916d10 100644 --- a/packages/point-of-sale/package.json +++ b/packages/point-of-sale/package.json @@ -4,7 +4,11 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --maxWorkers=50%", + "test:ci": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --maxWorkers=2", + "test:cov": "pnpm test -- --coverage", + "test:sincemain": "pnpm test -- --changedSince=main", + "test:sincemain:cov": "pnpm test:sincemain --coverage", "knex": "knex", "dev": "ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only src/index.ts", "build": "pnpm build:deps && pnpm clean && tsc --build tsconfig.json", @@ -35,6 +39,12 @@ "@types/koa-bodyparser": "^4.3.12", "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.4", - "@types/uuid": "^9.0.8" + "@types/uuid": "^9.0.8", + "nock": "14.0.0-beta.19", + "jest-environment-node": "^29.7.0", + "jest-openapi": "^0.14.2", + "testcontainers": "^10.16.0", + "tmp": "^0.2.3", + "@types/tmp": "^0.2.6" } } diff --git a/packages/point-of-sale/scripts/init.sh b/packages/point-of-sale/scripts/init.sh new file mode 100755 index 0000000000..a1fa255c7d --- /dev/null +++ b/packages/point-of-sale/scripts/init.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DROP DATABASE IF EXISTS TESTING; + CREATE DATABASE testing; + CREATE DATABASE development; +EOSQL diff --git a/packages/point-of-sale/src/config/app.ts b/packages/point-of-sale/src/config/app.ts index 2691d7397b..78cb416d37 100644 --- a/packages/point-of-sale/src/config/app.ts +++ b/packages/point-of-sale/src/config/app.ts @@ -34,5 +34,6 @@ export const Config = { env: envString('NODE_ENV', 'development'), port: envInt('PORT', 3008), trustProxy: envBool('TRUST_PROXY', false), - enableManualMigrations: envBool('ENABLE_MANUAl_MIGRATIONS', false) + enableManualMigrations: envBool('ENABLE_MANUAl_MIGRATIONS', false), + dbSchema: undefined as string | undefined } diff --git a/packages/point-of-sale/src/index.ts b/packages/point-of-sale/src/index.ts index d9d359db53..e48406774f 100644 --- a/packages/point-of-sale/src/index.ts +++ b/packages/point-of-sale/src/index.ts @@ -4,6 +4,7 @@ import { Model } from 'objection' import { Config } from './config/app' import { App, AppServices } from './app' import createLogger from 'pino' +import { createMerchantService } from './merchant/service' export function initIocContainer( config: typeof Config @@ -55,6 +56,15 @@ export function initIocContainer( ) return db }) + + container.singleton('merchantService', async (deps) => { + const [logger, knex] = await Promise.all([ + deps.use('logger'), + deps.use('knex') + ]) + return createMerchantService({ logger, knex }) + }) + return container } diff --git a/packages/point-of-sale/src/merchant/service.test.ts b/packages/point-of-sale/src/merchant/service.test.ts new file mode 100644 index 0000000000..61fa24cc16 --- /dev/null +++ b/packages/point-of-sale/src/merchant/service.test.ts @@ -0,0 +1,65 @@ +import { IocContract } from '@adonisjs/fold' + +import { Merchant } from './model' +import { MerchantService } from './service' + +import { createTestApp, TestContainer } from '../tests/app' +import { truncateTables } from '../tests/tableManager' +import { Config } from '../config/app' + +import { initIocContainer } from '../' +import { AppServices } from '../app' + +describe('Merchant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let merchantService: MerchantService + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config + }) + + appContainer = await createTestApp(deps) + merchantService = await deps.use('merchantService') + }) + + afterEach(async (): Promise => { + jest.useRealTimers() + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create', (): void => { + test('creates a merchant', async (): Promise => { + const merchant = await merchantService.create('Test merchant') + expect(merchant).toEqual({ id: merchant.id, name: 'Test merchant' }) + }) + }) + + describe('delete', (): void => { + test('soft deletes an existing merchant', async (): Promise => { + const created = await merchantService.create('Test merchant') + + const result = await merchantService.delete(created.id) + expect(result).toBe(true) + + const deletedMerchant = await Merchant.query() + .findById(created.id) + .whereNotNull('deletedAt') + expect(deletedMerchant).toBeDefined() + expect(deletedMerchant?.deletedAt).toBeDefined() + }) + + test('returns false for already deleted merchant', async (): Promise => { + const created = await merchantService.create('Test merchant') + + await merchantService.delete(created.id) + const secondDelete = await merchantService.delete(created.id) + expect(secondDelete).toBe(false) + }) + }) +}) diff --git a/packages/point-of-sale/src/merchant/service.ts b/packages/point-of-sale/src/merchant/service.ts new file mode 100644 index 0000000000..8540c74c54 --- /dev/null +++ b/packages/point-of-sale/src/merchant/service.ts @@ -0,0 +1,48 @@ +import { BaseService } from '../shared/baseService' +import { TransactionOrKnex } from 'objection' +import { Merchant } from './model' + +export interface MerchantService { + create(name: string): Promise + delete(id: string): Promise +} + +interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex +} + +export async function createMerchantService({ + logger, + knex +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'MerchantService' + }) + const deps: ServiceDependencies = { + logger: log, + knex + } + + return { + create: (input: string) => createMerchant(deps, input), + delete: (id: string) => deleteMerchant(deps, id) + } +} + +async function createMerchant( + deps: ServiceDependencies, + name: string +): Promise { + return await Merchant.query(deps.knex).insert({ name }) +} + +async function deleteMerchant( + deps: ServiceDependencies, + id: string +): Promise { + const deleted = await Merchant.query(deps.knex) + .patch({ deletedAt: new Date() }) + .whereNull('deletedAt') + .where('id', id) + return deleted > 0 +} diff --git a/packages/point-of-sale/src/tests/app.ts b/packages/point-of-sale/src/tests/app.ts new file mode 100644 index 0000000000..09a18d756d --- /dev/null +++ b/packages/point-of-sale/src/tests/app.ts @@ -0,0 +1,41 @@ +import { Knex } from 'knex' +import { IocContract } from '@adonisjs/fold' + +import { start, gracefulShutdown } from '..' +import { App, AppServices } from '../app' + +export interface TestContainer { + app: App + knex: Knex + connectionUrl: string + shutdown: () => Promise + container: IocContract +} + +export const createTestApp = async ( + container: IocContract +): Promise => { + const config = await container.use('config') + + const app = new App(container) + await start(container, app) + + const nock = (global as unknown as { nock: typeof import('nock') }).nock + + const knex = await container.use('knex') + + return { + app, + knex, + connectionUrl: config.databaseUrl, + shutdown: async () => { + nock.cleanAll() + nock.abortPendingRequests() + nock.restore() + nock.activate() + + await gracefulShutdown(container, app) + }, + container + } +} diff --git a/packages/point-of-sale/src/tests/tableManager.ts b/packages/point-of-sale/src/tests/tableManager.ts new file mode 100644 index 0000000000..665a67713d --- /dev/null +++ b/packages/point-of-sale/src/tests/tableManager.ts @@ -0,0 +1,45 @@ +import { IocContract } from '@adonisjs/fold' +import { Knex } from 'knex' +import { AppServices } from '../app' + +export async function truncateTable( + knex: Knex, + tableName: string +): Promise { + const RAW = `TRUNCATE TABLE "${tableName}" RESTART IDENTITY` + await knex.raw(RAW) +} + +export async function truncateTables( + deps: IocContract +): Promise { + const knex = await deps.use('knex') + const config = await deps.use('config') + const dbSchema = config.dbSchema ?? 'public' + + const ignoreTables = [ + 'knex_migrations', + 'knex_migrations_lock', + 'pos_knex_migrations', + 'pos_knex_migrations_lock' + ] + const tables = await getTables(knex, dbSchema, ignoreTables) + const RAW = `TRUNCATE TABLE "${tables}" RESTART IDENTITY` + await knex.raw(RAW) +} + +async function getTables( + knex: Knex, + dbSchema: string = 'public', + ignoredTables: string[] +): Promise { + const result = await knex.raw( + `SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='${dbSchema}'` + ) + return result.rows + .map((val: { tablename: string }) => { + if (!ignoredTables.includes(val.tablename)) return val.tablename + }) + .filter(Boolean) + .join('","') +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ae15240d0..2095556e83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,9 +845,27 @@ importers: '@types/koa__router': specifier: ^12.0.4 version: 12.0.4 + '@types/tmp': + specifier: ^0.2.6 + version: 0.2.6 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 + jest-environment-node: + specifier: ^29.7.0 + version: 29.7.0 + jest-openapi: + specifier: ^0.14.2 + version: 0.14.2 + nock: + specifier: 14.0.0-beta.19 + version: 14.0.0-beta.19 + testcontainers: + specifier: ^10.16.0 + version: 10.16.0 + tmp: + specifier: ^0.2.3 + version: 0.2.3 packages/token-introspection: dependencies: From 18f31a8a3adda7af03e6fc0a486cbfa6bc961d03 Mon Sep 17 00:00:00 2001 From: Arpi Lengyel Date: Thu, 10 Jul 2025 10:47:30 +0300 Subject: [PATCH 12/22] middleware start --- packages/point-of-sale/package.json | 1 + .../point-of-sale/src/merchant/middleware.ts | 43 +++++++++++++++++++ pnpm-lock.yaml | 3 ++ 3 files changed, 47 insertions(+) create mode 100644 packages/point-of-sale/src/merchant/middleware.ts diff --git a/packages/point-of-sale/package.json b/packages/point-of-sale/package.json index 75ba472fde..d4d671ce89 100644 --- a/packages/point-of-sale/package.json +++ b/packages/point-of-sale/package.json @@ -17,6 +17,7 @@ "dependencies": { "@adonisjs/fold": "^8.2.0", "@apollo/server": "^4.11.2", + "@interledger/http-signature-utils": "2.0.2", "@koa/cors": "^5.0.0", "@koa/router": "^12.0.2", "dotenv": "^16.4.7", diff --git a/packages/point-of-sale/src/merchant/middleware.ts b/packages/point-of-sale/src/merchant/middleware.ts new file mode 100644 index 0000000000..da1d8f927d --- /dev/null +++ b/packages/point-of-sale/src/merchant/middleware.ts @@ -0,0 +1,43 @@ +import { + getKeyId, + validateSignature, + validateSignatureHeaders, + RequestLike + } from '@interledger/http-signature-utils' + + import { AppContext } from '../app' + +function contextToRequestLike(ctx: AppContext): RequestLike { + + return { + url: ctx.href, + method: ctx.method, + headers: ctx.headers ? JSON.parse(JSON.stringify(ctx.headers)) : undefined, + body: ctx.request.body ? JSON.stringify(ctx.request.body) : undefined + } + } + +export async function validatePosSignatureMiddleware( + ctx: CreateContext, + next: () => Promise + ): Promise { + if (!validateSignatureHeaders(contextToRequestLike(ctx))) { + throw new GNAPServerRouteError( + 401, + GNAPErrorCode.InvalidClient, + 'invalid signature headers' + ) + } + + const { body } = ctx.request + + const sigVerified = await verifySigFromClient(body.client, ctx) + if (!sigVerified) { + throw new GNAPServerRouteError( + 401, + GNAPErrorCode.InvalidClient, + 'invalid signature' + ) + } + await next() + } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ae15240d0..916dc7c0b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -796,6 +796,9 @@ importers: '@apollo/server': specifier: ^4.11.2 version: 4.11.2(graphql@16.11.0) + '@interledger/http-signature-utils': + specifier: 2.0.2 + version: 2.0.2 '@koa/cors': specifier: ^5.0.0 version: 5.0.0 From 2d4fe936d25e24b5a5881c1066c895572a7ec818 Mon Sep 17 00:00:00 2001 From: zeppelin44 Date: Thu, 10 Jul 2025 11:00:39 +0300 Subject: [PATCH 13/22] feat: Integrate Redis client in Card Service for (requestId, posServiceHost) mapping (#3524) * Integrate Redis client in Card Service for (requestId, posServiceHost) mapping * remove unused dep * prettier fix * Separate logging params from the message itself * ttl * prettier fix * Rewrite redis service tests to use testcontainers instead of mocks --------- Co-authored-by: Antoniu Neacsu --- packages/card-service/package.json | 1 + packages/card-service/src/config/app.ts | 35 +++++- packages/card-service/src/index.ts | 18 ++++ .../src/pos-store/service.test.ts | 97 +++++++++++++++++ .../card-service/src/pos-store/service.ts | 67 ++++++++++++ pnpm-lock.yaml | 102 +++++++++--------- 6 files changed, 270 insertions(+), 50 deletions(-) create mode 100644 packages/card-service/src/pos-store/service.test.ts create mode 100644 packages/card-service/src/pos-store/service.ts diff --git a/packages/card-service/package.json b/packages/card-service/package.json index 4a57e1b30d..106257c74b 100644 --- a/packages/card-service/package.json +++ b/packages/card-service/package.json @@ -17,6 +17,7 @@ "dependencies": { "@adonisjs/fold": "^8.2.0", "@koa/cors": "^5.0.0", + "ioredis": "^5.3.2", "@koa/router": "^12.0.2", "koa-bodyparser": "^4.4.1", "koa": "^2.15.4", diff --git a/packages/card-service/src/config/app.ts b/packages/card-service/src/config/app.ts index 7f1abdf990..af831fad89 100644 --- a/packages/card-service/src/config/app.ts +++ b/packages/card-service/src/config/app.ts @@ -1,3 +1,6 @@ +import { ConnectionOptions } from 'tls' +import * as fs from 'fs' + function envString(name: string, defaultValue?: string): string { const envValue = process.env[name] @@ -27,7 +30,37 @@ export const Config = { enableManualMigrations: envBool('ENABLE_MANUAL_MIGRATIONS', false), trustProxy: envBool('TRUST_PROXY', false), env: envString('NODE_ENV', 'development'), - cardServicePort: envInt('CARD_SERVICE_PORT', 3007) + cardServicePort: envInt('CARD_SERVICE_PORT', 3007), + redisUrl: envString('REDIS_URL', 'redis://127.0.0.1:6379'), + redisTls: parseRedisTlsConfig( + process.env.REDIS_TLS_CA_FILE_PATH, + process.env.REDIS_TLS_KEY_FILE_PATH, + process.env.REDIS_TLS_CERT_FILE_PATH + ) +} + +function parseRedisTlsConfig( + caFile?: string, + keyFile?: string, + certFile?: string +): ConnectionOptions | undefined { + const options: ConnectionOptions = {} + + // self-signed certs. + if (caFile) { + options.ca = fs.readFileSync(caFile) + options.rejectUnauthorized = false + } + + if (certFile) { + options.cert = fs.readFileSync(certFile) + } + + if (keyFile) { + options.key = fs.readFileSync(keyFile) + } + + return Object.keys(options).length > 0 ? options : undefined } export type IAppConfig = typeof Config diff --git a/packages/card-service/src/index.ts b/packages/card-service/src/index.ts index 9d5dba1c0b..f854c5b51c 100644 --- a/packages/card-service/src/index.ts +++ b/packages/card-service/src/index.ts @@ -4,18 +4,22 @@ import { Ioc, IocContract } from '@adonisjs/fold' import createLogger from 'pino' import { knex } from 'knex' import { Model } from 'objection' +import Redis from 'ioredis' +import { createPOSStore, PosStoreService } from './pos-store/service' export function initIocContainer( config: typeof Config ): IocContract { const container: IocContract = new Ioc() container.singleton('config', async () => config) + container.singleton('logger', async (deps: IocContract) => { const config = await deps.use('config') const logger = createLogger() logger.level = config.logLevel return logger }) + container.singleton('knex', async (deps: IocContract) => { const logger = await deps.use('logger') const config = await deps.use('config') @@ -58,6 +62,20 @@ export function initIocContainer( return db }) + container.singleton('redis', async (deps): Promise => { + const config = await deps.use('config') + return new Redis(config.redisUrl, { + tls: config.redisTls, + stringNumbers: true + }) + }) + + container.singleton('pos-store', async (deps): Promise => { + const redis = await deps.use('redis') + const logger = await deps.use('logger') + return createPOSStore({ redis, logger }) + }) + return container } diff --git a/packages/card-service/src/pos-store/service.test.ts b/packages/card-service/src/pos-store/service.test.ts new file mode 100644 index 0000000000..7e8cc99f8c --- /dev/null +++ b/packages/card-service/src/pos-store/service.test.ts @@ -0,0 +1,97 @@ +import { createPOSStore } from './service' +import Redis from 'ioredis' +import { Logger } from 'pino' + +describe('POS Store Service', () => { + let redis: Redis + let logger: jest.Mocked + let service: ReturnType + + const requestId = 'req-123' + const POSHost = 'pos.example.com' + + beforeAll(async () => { + redis = new Redis(process.env.REDIS_URL!) + await redis.ping() + }) + + beforeEach(async () => { + await redis.flushall() + logger = { + child: jest.fn().mockReturnThis(), + info: jest.fn(), + error: jest.fn() + } as unknown as jest.Mocked + service = createPOSStore({ redis, logger }) + }) + + afterAll(async () => { + await redis.quit() + }) + + describe('addPOS', () => { + it('should add a POS for a requestId', async () => { + await service.addPOS(requestId, POSHost) + const value = await redis.get(requestId) + expect(value).toBe(POSHost) + expect(logger.info).toHaveBeenCalledWith( + { requestId, POSHost }, + 'POS was added for the given requestId' + ) + }) + + it('should clear the POS after 5 minutes (TTL)', async () => { + jest.useFakeTimers() + await service.addPOS(requestId, POSHost) + expect(await redis.get(requestId)).toBe(POSHost) + + await expect(service.getPOS(requestId)).resolves.toBe(POSHost) + + jest.advanceTimersByTime(300 * 1000) + // Simulate TTL expiry by manually deleting the key (since fake timers don't affect Redis TTL) + await redis.del(requestId) + await expect(service.getPOS(requestId)).rejects.toThrow( + `No POS found for requestId: ${requestId}` + ) + jest.useRealTimers() + }) + }) + + describe('getPOS', () => { + it('should return the POSHost for a requestId', async () => { + await redis.set(requestId, POSHost, 'EX', 300) + await expect(service.getPOS(requestId)).resolves.toBe(POSHost) + }) + it('should throw if no POS found', async () => { + await redis.del(requestId) + await expect(service.getPOS(requestId)).rejects.toThrow( + `No POS found for requestId: ${requestId}` + ) + expect(logger.error).toHaveBeenCalledWith( + { requestId: 'req-123' }, + 'No POS found for requestId' + ) + }) + }) + + describe('deletePOS', () => { + it('should delete the POS record for a requestId', async () => { + await redis.set(requestId, POSHost, 'EX', 300) + await service.deletePOS(requestId) + const value = await redis.get(requestId) + expect(value).toBe(null) + expect(logger.info).toHaveBeenCalledWith( + `POS record was deleted for requestId: ${requestId}` + ) + }) + it('should throw if no POS record was deleted', async () => { + await redis.del(requestId) + await expect(service.deletePOS(requestId)).rejects.toThrow( + `No POS record was deleted for requestId: ${requestId}` + ) + expect(logger.error).toHaveBeenCalledWith( + `No POS record was deleted for requestId: ${requestId}` + ) + }) + }) +}) diff --git a/packages/card-service/src/pos-store/service.ts b/packages/card-service/src/pos-store/service.ts new file mode 100644 index 0000000000..2d39d162cf --- /dev/null +++ b/packages/card-service/src/pos-store/service.ts @@ -0,0 +1,67 @@ +import Redis from 'ioredis' +import { Logger } from 'pino' + +const POS_TTL = 300 // 5 minutes + +export type StoreDependencies = { + logger: Logger + redis: Redis +} + +export type PosStoreService = { + addPOS: (requestId: string, POSHost: string) => Promise + getPOS: (requestId: string) => Promise + deletePOS: (requestId: string) => Promise +} + +export function createPOSStore(deps_: StoreDependencies): PosStoreService { + const logger = deps_.logger.child({ + service: 'pos-store' + }) + const deps = { + ...deps_, + logger + } + + return { + getPOS: (requestId: string) => getPOS(deps, requestId), + addPOS: (requestId: string, POSHost: string) => + addPOS(deps, requestId, POSHost), + deletePOS: (requestId: string) => deletePOS(deps, requestId) + } +} + +const getPOS = async ( + deps: StoreDependencies, + requestId: string +): Promise => { + const POSHost = await deps.redis.get(requestId) + if (!POSHost) { + deps.logger.error({ requestId }, `No POS found for requestId`) + throw new Error(`No POS found for requestId: ${requestId}`) + } + + return POSHost +} + +const addPOS = async ( + deps: StoreDependencies, + requestId: string, + POSHost: string +) => { + await deps.redis.set(requestId, POSHost, 'EX', POS_TTL) + deps.logger.info( + { requestId, POSHost }, + 'POS was added for the given requestId' + ) +} + +const deletePOS = async (deps: StoreDependencies, requestId: string) => { + const deletedRecords = await deps.redis.del([requestId]) + if (deletedRecords == 0) { + deps.logger.error(`No POS record was deleted for requestId: ${requestId}`) + throw new Error(`No POS record was deleted for requestId: ${requestId}`) + } + + deps.logger.info(`POS record was deleted for requestId: ${requestId}`) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2095556e83..e15616b676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -556,6 +556,9 @@ importers: '@koa/cors': specifier: ^5.0.0 version: 5.0.0 + ioredis: + specifier: ^5.3.2 + version: 5.3.2 '@koa/router': specifier: ^12.0.2 version: 12.0.2 @@ -1450,7 +1453,7 @@ packages: engines: {node: ^18.17.1 || ^20.3.0 || >=22.0.0} dependencies: ci-info: 4.2.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) dlv: 1.1.3 dset: 3.1.4 is-docker: 3.0.0 @@ -1506,7 +1509,7 @@ packages: '@babel/traverse': 7.25.9 '@babel/types': 7.26.0 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1528,7 +1531,7 @@ packages: '@babel/traverse': 7.26.7 '@babel/types': 7.27.0 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1551,7 +1554,7 @@ packages: '@babel/traverse': 7.26.9 '@babel/types': 7.26.9 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1574,7 +1577,7 @@ packages: '@babel/traverse': 7.27.4 '@babel/types': 7.27.3 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1733,7 +1736,7 @@ packages: '@babel/core': 7.26.9 '@babel/helper-compilation-targets': 7.26.5 '@babel/helper-plugin-utils': 7.26.5 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -3185,7 +3188,7 @@ packages: '@babel/parser': 7.26.7 '@babel/template': 7.25.9 '@babel/types': 7.26.7 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3199,7 +3202,7 @@ packages: '@babel/parser': 7.27.0 '@babel/template': 7.25.9 '@babel/types': 7.27.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3213,7 +3216,7 @@ packages: '@babel/parser': 7.26.9 '@babel/template': 7.26.9 '@babel/types': 7.26.9 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3228,7 +3231,7 @@ packages: '@babel/parser': 7.27.5 '@babel/template': 7.27.2 '@babel/types': 7.27.3 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -4100,7 +4103,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) espree: 9.6.1 globals: 13.19.0 ignore: 5.2.4 @@ -4983,7 +4986,7 @@ packages: '@types/json-stable-stringify': 1.0.34 '@whatwg-node/fetch': 0.9.8 chalk: 4.1.2 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@9.4.0) dotenv: 16.4.7 graphql: 16.11.0 graphql-request: 6.1.0(graphql@16.11.0) @@ -5254,7 +5257,7 @@ packages: deprecated: Use @eslint/config-array instead dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -5286,7 +5289,7 @@ packages: '@antfu/install-pkg': 0.4.1 '@antfu/utils': 0.7.10 '@iconify/types': 2.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) kolorist: 1.8.0 local-pkg: 0.5.0 mlly: 1.7.3 @@ -6118,7 +6121,7 @@ packages: resolution: {integrity: sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA==} engines: {node: '>= 12'} dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) http-errors: 2.0.0 koa-compose: 4.1.0 methods: 1.1.2 @@ -8631,7 +8634,7 @@ packages: '@typescript-eslint/scope-manager': 5.60.1 '@typescript-eslint/type-utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) '@typescript-eslint/utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) eslint: 8.57.1 grapheme-splitter: 1.0.4 ignore: 5.2.4 @@ -8685,7 +8688,7 @@ packages: '@typescript-eslint/scope-manager': 5.60.1 '@typescript-eslint/types': 5.60.1 '@typescript-eslint/typescript-estree': 5.60.1(typescript@5.8.3) - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) eslint: 8.57.1 typescript: 5.8.3 transitivePeerDependencies: @@ -8749,7 +8752,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.60.1(typescript@5.8.3) '@typescript-eslint/utils': 5.60.1(eslint@8.57.1)(typescript@5.8.3) - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) eslint: 8.57.1 tsutils: 3.21.0(typescript@5.8.3) typescript: 5.8.3 @@ -8769,7 +8772,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 7.5.0(typescript@5.4.3) '@typescript-eslint/utils': 7.5.0(eslint@8.57.1)(typescript@5.4.3) - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) eslint: 8.57.1 ts-api-utils: 1.0.1(typescript@5.4.3) typescript: 5.4.3 @@ -8803,7 +8806,7 @@ packages: dependencies: '@typescript-eslint/types': 5.60.1 '@typescript-eslint/visitor-keys': 5.60.1 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 @@ -8824,7 +8827,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.3 @@ -8845,7 +8848,7 @@ packages: dependencies: '@typescript-eslint/types': 7.5.0 '@typescript-eslint/visitor-keys': 7.5.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -9319,7 +9322,7 @@ packages: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) transitivePeerDependencies: - supports-color dev: true @@ -9729,7 +9732,7 @@ packages: common-ancestor-path: 1.0.1 cookie: 1.0.2 cssesc: 3.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) deterministic-object-hash: 2.0.2 devalue: 5.1.1 diff: 5.2.0 @@ -9832,7 +9835,7 @@ packages: common-ancestor-path: 1.0.1 cookie: 1.0.2 cssesc: 3.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) deterministic-object-hash: 2.0.2 devalue: 5.1.1 diff: 5.2.0 @@ -11563,7 +11566,7 @@ packages: ms: 2.1.3 dev: true - /debug@4.4.0(supports-color@7.2.0): + /debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} peerDependencies: @@ -11573,10 +11576,10 @@ packages: optional: true dependencies: ms: 2.1.3 - supports-color: 7.2.0 + dev: true - /debug@4.4.0(supports-color@9.4.0): - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + /debug@4.4.1(supports-color@7.2.0): + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -11585,9 +11588,9 @@ packages: optional: true dependencies: ms: 2.1.3 - supports-color: 9.4.0 + supports-color: 7.2.0 - /debug@4.4.1: + /debug@4.4.1(supports-color@9.4.0): resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} peerDependencies: @@ -11597,6 +11600,7 @@ packages: optional: true dependencies: ms: 2.1.3 + supports-color: 9.4.0 /decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} @@ -11793,7 +11797,7 @@ packages: resolution: {integrity: sha512-h0Ow21gclbYsZ3mkHDfsYNDqtRhXS8fXr51bU0qr1dxgTMJj0XufbzX+jhNOvA8KuEEzn6JbvLVhXyv+fny9Uw==} engines: {node: '>= 8.0'} dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) readable-stream: 3.6.0 split-ca: 1.0.1 ssh2: 1.11.0 @@ -12385,7 +12389,7 @@ packages: eslint: '*' eslint-plugin-import: '*' dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) enhanced-resolve: 5.13.0 eslint: 8.57.1 eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.60.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.57.1) @@ -14112,7 +14116,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) transitivePeerDependencies: - supports-color dev: true @@ -14127,7 +14131,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) transitivePeerDependencies: - supports-color dev: true @@ -14176,14 +14180,14 @@ packages: resolution: {integrity: sha512-R7F+SH6Aiipuqoq63gtzy6/HVIfcCK1rEmq8bE8NLSufXJPRoXszNs6RpypQi9HJcZvTcIUPFE15bS/HI+T+/A==} dependencies: '@types/debug': 4.1.7 - debug: 4.4.0(supports-color@7.2.0) + debug: 4.4.1(supports-color@7.2.0) supports-color: 7.2.0 /ilp-logger@1.4.5-alpha.2: resolution: {integrity: sha512-WtbscdjUUPVseRkDpRlfb/YUpsq4zfoOz6PlJSkx+aqJot1P5N+YGd4YKW1g9wm6O8muo5e/xBotyJqCQs0g+Q==} dependencies: '@types/debug': 4.1.7 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@9.4.0) supports-color: 9.4.0 dev: false @@ -14370,7 +14374,7 @@ packages: dependencies: '@ioredis/commands': 1.2.0 cluster-key-slot: 1.1.0 - debug: 4.3.4 + debug: 4.4.1(supports-color@9.4.0) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -14915,7 +14919,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) istanbul-lib-coverage: 3.2.0 source-map: 0.6.1 transitivePeerDependencies: @@ -15654,7 +15658,7 @@ packages: content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.0 delegates: 1.0.0 depd: 2.0.0 destroy: 1.2.0 @@ -15685,7 +15689,7 @@ packages: content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) delegates: 1.0.0 depd: 2.0.0 destroy: 1.2.0 @@ -15715,7 +15719,7 @@ packages: content-disposition: 0.5.4 content-type: 1.0.5 cookies: 0.9.1 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) delegates: 1.0.0 destroy: 1.2.0 encodeurl: 2.0.0 @@ -17107,7 +17111,7 @@ packages: resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} dependencies: '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.1.0 micromark-factory-space: 1.1.0 @@ -17131,7 +17135,7 @@ packages: resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} dependencies: '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.1 @@ -19422,7 +19426,7 @@ packages: resolution: {integrity: sha512-X34iHADNbNDfr6OTStIAHWSAvvKQRYgLO6duASaVf7J2VA3lvmNYboAHOuLC2huav1IwgZJtyEcJCKVzFxOSMQ==} engines: {node: '>=8.6.0'} dependencies: - debug: 4.4.0(supports-color@9.4.0) + debug: 4.4.1(supports-color@9.4.0) module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -21876,7 +21880,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) mlly: 1.7.3 pathe: 1.1.2 picocolors: 1.1.1 @@ -21900,7 +21904,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) mlly: 1.7.3 pathe: 1.1.2 picocolors: 1.1.1 @@ -21924,7 +21928,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) es-module-lexer: 1.6.0 pathe: 1.1.2 vite: 6.2.5(@types/node@18.11.9)(yaml@2.7.0) @@ -21949,7 +21953,7 @@ packages: hasBin: true dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.1(supports-color@9.4.0) es-module-lexer: 1.6.0 pathe: 1.1.2 vite: 6.2.5(@types/node@20.12.7)(yaml@2.7.0) From a61b299795fe2b31388de55612aecf5a8afebbf1 Mon Sep 17 00:00:00 2001 From: Arpi Lengyel <165793743+lengyel-arpad85@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:03:21 +0300 Subject: [PATCH 14/22] feat(pos): create merchant route (#3538) * post merchants route --------- Co-authored-by: Nathan Lie --- .../20250708093546_create_merchants_table.js | 2 +- packages/point-of-sale/package.json | 3 +- packages/point-of-sale/src/app.ts | 18 +++++ packages/point-of-sale/src/index.ts | 8 +++ packages/point-of-sale/src/merchant/errors.ts | 15 +++++ packages/point-of-sale/src/merchant/model.ts | 2 +- .../point-of-sale/src/merchant/routes.test.ts | 67 +++++++++++++++++++ packages/point-of-sale/src/merchant/routes.ts | 54 +++++++++++++++ .../src/merchant/service.test.ts | 8 ++- .../point-of-sale/src/shared/baseModel.ts | 2 + packages/point-of-sale/src/tests/context.ts | 24 +++++++ pnpm-lock.yaml | 3 + 12 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 packages/point-of-sale/src/merchant/errors.ts create mode 100644 packages/point-of-sale/src/merchant/routes.test.ts create mode 100644 packages/point-of-sale/src/merchant/routes.ts create mode 100644 packages/point-of-sale/src/tests/context.ts diff --git a/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js b/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js index 97c7497c30..c75ff42f7f 100644 --- a/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js +++ b/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js @@ -10,7 +10,7 @@ exports.up = function (knex) { table.timestamp('createdAt').defaultTo(knex.fn.now()) table.timestamp('updatedAt').defaultTo(knex.fn.now()) - table.timestamp('deletedAt') + table.timestamp('deletedAt').nullable() }) } diff --git a/packages/point-of-sale/package.json b/packages/point-of-sale/package.json index 3d94916d10..246ca01f42 100644 --- a/packages/point-of-sale/package.json +++ b/packages/point-of-sale/package.json @@ -45,6 +45,7 @@ "jest-openapi": "^0.14.2", "testcontainers": "^10.16.0", "tmp": "^0.2.3", - "@types/tmp": "^0.2.6" + "@types/tmp": "^0.2.6", + "node-mocks-http": "^1.16.2" } } diff --git a/packages/point-of-sale/src/app.ts b/packages/point-of-sale/src/app.ts index 2a661fac78..f6467c33a3 100644 --- a/packages/point-of-sale/src/app.ts +++ b/packages/point-of-sale/src/app.ts @@ -7,11 +7,13 @@ import Koa, { DefaultState } from 'koa' import Router from '@koa/router' import bodyParser from 'koa-bodyparser' import cors from '@koa/cors' +import { CreateMerchantContext, MerchantRoutes } from './merchant/routes' export interface AppServices { logger: Promise knex: Promise config: Promise + merchantRoutes: Promise } export type AppContainer = IocContract @@ -25,6 +27,13 @@ export interface AppContextData { export type AppContext = Koa.ParameterizedContext +export type AppRequest = Omit< + AppContext['request'], + 'params' +> & { + params: Record +} + export class App { private posServer!: Server public isShuttingDown = false @@ -47,6 +56,15 @@ export class App { ctx.status = 200 }) + const merchantRoutes = await this.container.use('merchantRoutes') + + // POST /merchants + // Create merchant + router.post( + '/merchants', + merchantRoutes.create + ) + koa.use(cors()) koa.use(router.routes()) diff --git a/packages/point-of-sale/src/index.ts b/packages/point-of-sale/src/index.ts index e48406774f..e24ed825e1 100644 --- a/packages/point-of-sale/src/index.ts +++ b/packages/point-of-sale/src/index.ts @@ -5,6 +5,7 @@ import { Config } from './config/app' import { App, AppServices } from './app' import createLogger from 'pino' import { createMerchantService } from './merchant/service' +import { createMerchantRoutes } from './merchant/routes' export function initIocContainer( config: typeof Config @@ -65,6 +66,13 @@ export function initIocContainer( return createMerchantService({ logger, knex }) }) + container.singleton('merchantRoutes', async (deps) => { + return createMerchantRoutes({ + logger: await deps.use('logger'), + merchantService: await deps.use('merchantService') + }) + }) + return container } diff --git a/packages/point-of-sale/src/merchant/errors.ts b/packages/point-of-sale/src/merchant/errors.ts new file mode 100644 index 0000000000..c16b966dbd --- /dev/null +++ b/packages/point-of-sale/src/merchant/errors.ts @@ -0,0 +1,15 @@ +export class POSMerchantRouteError extends Error { + public status: number + public details?: Record + + constructor( + status: number, + message: string, + details?: Record + ) { + super(message) + this.name = 'POSMerchantRouteError' + this.status = status + this.details = details + } +} diff --git a/packages/point-of-sale/src/merchant/model.ts b/packages/point-of-sale/src/merchant/model.ts index 02b8c03a80..c49166f405 100644 --- a/packages/point-of-sale/src/merchant/model.ts +++ b/packages/point-of-sale/src/merchant/model.ts @@ -20,5 +20,5 @@ export class Merchant extends BaseModel { }) public name!: string - public deletedAt!: Date | null + public deletedAt?: Date | null } diff --git a/packages/point-of-sale/src/merchant/routes.test.ts b/packages/point-of-sale/src/merchant/routes.test.ts new file mode 100644 index 0000000000..49e652e4f4 --- /dev/null +++ b/packages/point-of-sale/src/merchant/routes.test.ts @@ -0,0 +1,67 @@ +import { IocContract } from '@adonisjs/fold' +import { createContext } from '../tests/context' +import { createTestApp, TestContainer } from '../tests/app' +import { Config } from '../config/app' +import { initIocContainer } from '..' +import { AppServices } from '../app' +import { truncateTables } from '../tests/tableManager' +import { + CreateMerchantContext, + MerchantRoutes, + createMerchantRoutes +} from './routes' +import { MerchantService } from './service' + +describe('Merchant Routes', (): void => { + let deps: IocContract + let appContainer: TestContainer + let merchantRoutes: MerchantRoutes + let merchantService: MerchantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + merchantService = await deps.use('merchantService') + const logger = await deps.use('logger') + + merchantRoutes = createMerchantRoutes({ + merchantService, + logger + }) + }) + + afterEach(async (): Promise => { + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create', (): void => { + test('Creates a merchant', async (): Promise => { + const merchantData = { + name: 'Test Merchant' + } + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + {} + ) + ctx.request.body = merchantData + + await merchantRoutes.create(ctx) + + expect(ctx.status).toBe(200) + expect(ctx.response.body).toEqual({ + id: expect.any(String), + name: merchantData.name + }) + }) + }) +}) diff --git a/packages/point-of-sale/src/merchant/routes.ts b/packages/point-of-sale/src/merchant/routes.ts new file mode 100644 index 0000000000..774510a324 --- /dev/null +++ b/packages/point-of-sale/src/merchant/routes.ts @@ -0,0 +1,54 @@ +import { AppContext } from '../app' +import { BaseService } from '../shared/baseService' +import { MerchantService } from './service' +import { POSMerchantRouteError } from './errors' + +interface ServiceDependencies extends BaseService { + merchantService: MerchantService +} + +type CreateMerchantRequest = Exclude & { + body: { + name: string + } +} + +export type CreateMerchantContext = Exclude & { + request: CreateMerchantRequest +} + +export interface MerchantRoutes { + create(ctx: CreateMerchantContext): Promise +} + +export function createMerchantRoutes( + deps_: ServiceDependencies +): MerchantRoutes { + const log = deps_.logger.child({ + service: 'MerchantRoutes' + }) + + const deps = { + ...deps_, + logger: log + } + + return { + create: (ctx: CreateMerchantContext) => createMerchant(deps, ctx) + } +} + +async function createMerchant( + deps: ServiceDependencies, + ctx: CreateMerchantContext +): Promise { + const { body } = ctx.request + try { + const merchant = await deps.merchantService.create(body.name) + + ctx.status = 200 + ctx.body = { id: merchant.id, name: merchant.name } + } catch (err) { + throw new POSMerchantRouteError(400, 'Could not create merchant', { err }) + } +} diff --git a/packages/point-of-sale/src/merchant/service.test.ts b/packages/point-of-sale/src/merchant/service.test.ts index 61fa24cc16..c1cd6d56b3 100644 --- a/packages/point-of-sale/src/merchant/service.test.ts +++ b/packages/point-of-sale/src/merchant/service.test.ts @@ -36,7 +36,13 @@ describe('Merchant Service', (): void => { describe('create', (): void => { test('creates a merchant', async (): Promise => { const merchant = await merchantService.create('Test merchant') - expect(merchant).toEqual({ id: merchant.id, name: 'Test merchant' }) + expect(merchant).toMatchObject({ + name: 'Test merchant', + createdAt: expect.any(Date), + updatedAt: expect.any(Date) + }) + expect(typeof merchant.id).toBe('string') + expect(merchant.deletedAt).toBeUndefined() }) }) diff --git a/packages/point-of-sale/src/shared/baseModel.ts b/packages/point-of-sale/src/shared/baseModel.ts index 1b361238d1..e6b390d79b 100644 --- a/packages/point-of-sale/src/shared/baseModel.ts +++ b/packages/point-of-sale/src/shared/baseModel.ts @@ -127,6 +127,8 @@ export abstract class BaseModel extends PaginationModel { public $beforeInsert(context: QueryContext): void { super.$beforeInsert(context) this.id = this.id || uuid() + this.createdAt = new Date() + this.updatedAt = new Date() } public $beforeUpdate(_opts: ModelOptions, _queryContext: QueryContext): void { diff --git a/packages/point-of-sale/src/tests/context.ts b/packages/point-of-sale/src/tests/context.ts new file mode 100644 index 0000000000..fffcb0affe --- /dev/null +++ b/packages/point-of-sale/src/tests/context.ts @@ -0,0 +1,24 @@ +import { IocContract } from '@adonisjs/fold' +import * as httpMocks from 'node-mocks-http' +import Koa from 'koa' +import { AppContext, AppContextData, AppRequest } from '../app' + +export function createContext( + reqOpts: httpMocks.RequestOptions, + params: Record = {}, + container?: IocContract +): T { + const req = httpMocks.createRequest(reqOpts) + const res = httpMocks.createResponse({ req }) + const koa = new Koa() + const ctx = koa.createContext(req, res) + ctx.params = (ctx.request as AppRequest).params = params + if (reqOpts.query) { + ctx.request.query = reqOpts.query + } + if (reqOpts.body !== undefined) { + ctx.request.body = reqOpts.body + } + ctx.container = container + return ctx as T +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e15616b676..d831273fbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -863,6 +863,9 @@ importers: nock: specifier: 14.0.0-beta.19 version: 14.0.0-beta.19 + node-mocks-http: + specifier: ^1.16.2 + version: 1.16.2(@types/node@20.14.15) testcontainers: specifier: ^10.16.0 version: 10.16.0 From ba550971f5745d1df5b4413675a8c62b3195d4d4 Mon Sep 17 00:00:00 2001 From: xplicit <111863110+beniaminmunteanu@users.noreply.github.com> Date: Thu, 10 Jul 2025 12:20:01 +0300 Subject: [PATCH 15/22] feat(card-service): introduce AuditLogService (#3525) * feat(card-service): introduce AuditLogService * feat(card-service): add testcontainers setup * chore(cards-service): lockfile * fix service update method and add tests. card_payemnts migration index added * chore(cards-service): remove unnecessary app container start in tests * chore(cards-service): add uuid as a placeholder for open payments tennantId for clarity * chore(cards-service): format imports --- .../20250708070327_card_payments_table.js | 5 +- .../src/card-payment/service.test.ts | 78 ++++++++++++++++ .../card-service/src/card-payment/service.ts | 92 +++++++++++++++++++ .../card-service/src/tests/cardPayment.ts | 29 ++++++ pnpm-lock.yaml | 10 +- 5 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 packages/card-service/src/card-payment/service.test.ts create mode 100644 packages/card-service/src/card-payment/service.ts create mode 100644 packages/card-service/src/tests/cardPayment.ts diff --git a/packages/card-service/migrations/20250708070327_card_payments_table.js b/packages/card-service/migrations/20250708070327_card_payments_table.js index a1adf8d8c0..32cffe2331 100644 --- a/packages/card-service/migrations/20250708070327_card_payments_table.js +++ b/packages/card-service/migrations/20250708070327_card_payments_table.js @@ -5,10 +5,10 @@ exports.up = function (knex) { return knex.schema.createTable('cardPayments', function (table) { table.uuid('id').notNullable().primary() - table.uuid('requestId').notNullable() + table.uuid('requestId').notNullable().unique() table.timestamp('requestedAt').defaultTo(knex.fn.now()) - table.timestamp('finalizedAt').defaultTo(knex.fn.now()) + table.timestamp('finalizedAt').nullable() table.string('cardWalletAddress').notNullable() table.string('incomingPaymentUrl').notNullable() @@ -21,6 +21,7 @@ exports.up = function (knex) { table.timestamp('createdAt').defaultTo(knex.fn.now()) table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.index('requestId') table.index('cardWalletAddress') }) } diff --git a/packages/card-service/src/card-payment/service.test.ts b/packages/card-service/src/card-payment/service.test.ts new file mode 100644 index 0000000000..0342c1f1f3 --- /dev/null +++ b/packages/card-service/src/card-payment/service.test.ts @@ -0,0 +1,78 @@ +import { v4 as uuid } from 'uuid' + +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../' +import { AppServices } from '../app' +import { Config } from '../config/app' +import { createCardPayment, randomCardPayment } from '../tests/cardPayment' +import { truncateTables } from '../tests/tableManager' +import { CardPayment } from './model' +import { + AuditLogService, + createAuditLogService, + UpdateCardPaymentOptions +} from './service' + +describe('AuditLogService', (): void => { + let deps: IocContract + let auditLogService: AuditLogService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + auditLogService = await createAuditLogService({ + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + }) + + afterEach(async (): Promise => { + await truncateTables(deps) + }) + + describe('create', (): void => { + test('CardPayment can be created and fetched', async (): Promise => { + const options = randomCardPayment() + const cardPayment = await auditLogService.create(options) + + expect(cardPayment).toMatchObject({ + ...options, + id: cardPayment.id, + finalizedAt: null, + statusCode: null, + outgoingPaymentId: null + }) + expect(cardPayment.id).toBeDefined() + expect(cardPayment.createdAt).toBeInstanceOf(Date) + expect(cardPayment.updatedAt).toBeInstanceOf(Date) + }) + }) + + describe('update', (): void => { + let existingPayment: CardPayment + + beforeEach(async (): Promise => { + existingPayment = await createCardPayment(deps) + }) + + test('CardPayment can be updated', async (): Promise => { + const finalizedAt = new Date() + const statusCode = 200 + const outgoingPaymentId = uuid() + + const updateOptions: UpdateCardPaymentOptions = { + requestId: existingPayment.requestId, + finalizedAt, + statusCode, + outgoingPaymentId + } + + const updatedPayment = await auditLogService.update(updateOptions) + + expect(updatedPayment?.finalizedAt).toEqual(finalizedAt) + expect(updatedPayment?.statusCode).toBe(statusCode) + expect(updatedPayment?.outgoingPaymentId).toBe(outgoingPaymentId) + expect(updatedPayment?.id).toBe(existingPayment.id) + expect(updatedPayment?.requestId).toBe(existingPayment.requestId) + }) + }) +}) diff --git a/packages/card-service/src/card-payment/service.ts b/packages/card-service/src/card-payment/service.ts new file mode 100644 index 0000000000..aa68ea0a5c --- /dev/null +++ b/packages/card-service/src/card-payment/service.ts @@ -0,0 +1,92 @@ +import { CardPayment } from './model' +import { BaseService } from '../shared/baseService' + +export interface CreateCardPaymentOptions { + requestId: string + requestedAt: Date | null + cardWalletAddress: string + incomingPaymentUrl: string + terminalId: string +} + +export interface UpdateCardPaymentOptions { + requestId: string + finalizedAt?: Date | null + statusCode?: number + outgoingPaymentId?: string +} + +export interface AuditLogService { + create(options: CreateCardPaymentOptions): Promise + update(options: UpdateCardPaymentOptions): Promise +} + +interface ServiceDependencies extends BaseService {} + +export async function createAuditLogService({ + logger, + knex +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'AuditLogService' + }) + + const deps: ServiceDependencies = { + logger: log, + knex + } + + return { + create: (options) => createCardPayment(deps, options), + update: (options) => updateCardPayment(deps, options) + } +} + +async function createCardPayment( + deps: ServiceDependencies, + { + requestId, + requestedAt, + cardWalletAddress, + incomingPaymentUrl, + terminalId + }: CreateCardPaymentOptions +): Promise { + const cardPayment = await CardPayment.query(deps.knex).insertAndFetch({ + requestId, + requestedAt, + cardWalletAddress, + incomingPaymentUrl, + terminalId + }) + + return cardPayment +} + +async function updateCardPayment( + deps: ServiceDependencies, + { + requestId, + finalizedAt, + statusCode, + outgoingPaymentId + }: UpdateCardPaymentOptions +): Promise { + if (!deps.knex) { + throw new Error('Knex undefined') + } + const existingPayment = await CardPayment.query(deps.knex) + .findOne('requestId', requestId) + .throwIfNotFound() + + const cardPayment = await CardPayment.query(deps.knex).patchAndFetchById( + existingPayment.id, + { + finalizedAt, + statusCode, + outgoingPaymentId + } + ) + + return cardPayment +} diff --git a/packages/card-service/src/tests/cardPayment.ts b/packages/card-service/src/tests/cardPayment.ts new file mode 100644 index 0000000000..07b3313502 --- /dev/null +++ b/packages/card-service/src/tests/cardPayment.ts @@ -0,0 +1,29 @@ +import { v4 as uuid } from 'uuid' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../app' +import { CreateCardPaymentOptions } from '../card-payment/service' +import { CardPayment } from '../card-payment/model' + +export const randomCardPayment = (): CreateCardPaymentOptions => ({ + requestId: uuid(), + requestedAt: new Date(), + cardWalletAddress: `https://wallet-${uuid().slice(0, 8)}.example/.well-known/pay`, + incomingPaymentUrl: `https://backend-${uuid().slice(0, 8)}.example/${uuid()}/incoming-payments/${uuid()}`, + terminalId: uuid() +}) + +export const createCardPayment = async ( + deps: IocContract, + options?: Partial +): Promise => { + const { createAuditLogService } = await import('../card-payment/service') + const auditLogService = await createAuditLogService({ + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + + return await auditLogService.create({ + ...randomCardPayment(), + ...options + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d831273fbd..5fa3eaf9ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8463,7 +8463,15 @@ packages: /@types/pg-pool@2.0.4: resolution: {integrity: sha512-qZAvkv1K3QbmHHFYSNRYPkRjOWRLBYrL4B9c+wG0GSVGBw0NtJwPcgx/DSddeDJvRGMHCEQ4VMEVfuJ/0gZ3XQ==} dependencies: - '@types/pg': 8.6.1 + '@types/pg': 8.15.4 + dev: false + + /@types/pg@8.15.4: + resolution: {integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==} + dependencies: + '@types/node': 20.14.15 + pg-protocol: 1.6.0 + pg-types: 2.2.0 dev: false /@types/pg@8.6.1: From c249a7c310140585218acd2c71f88e16ed88307a Mon Sep 17 00:00:00 2001 From: Arpi Lengyel Date: Thu, 10 Jul 2025 12:41:34 +0300 Subject: [PATCH 16/22] merchant middleware - incomplete --- packages/point-of-sale/src/app.ts | 2 + packages/point-of-sale/src/merchant/errors.ts | 8 ++ .../point-of-sale/src/merchant/middleware.ts | 97 ++++++++++++------- packages/point-of-sale/src/merchant/routes.ts | 7 +- pnpm-lock.yaml | 6 +- 5 files changed, 80 insertions(+), 40 deletions(-) diff --git a/packages/point-of-sale/src/app.ts b/packages/point-of-sale/src/app.ts index f6467c33a3..1f190d8b2a 100644 --- a/packages/point-of-sale/src/app.ts +++ b/packages/point-of-sale/src/app.ts @@ -8,6 +8,7 @@ import Router from '@koa/router' import bodyParser from 'koa-bodyparser' import cors from '@koa/cors' import { CreateMerchantContext, MerchantRoutes } from './merchant/routes' +import { validatePosSignatureMiddleware } from './merchant/middleware' export interface AppServices { logger: Promise @@ -62,6 +63,7 @@ export class App { // Create merchant router.post( '/merchants', + validatePosSignatureMiddleware, merchantRoutes.create ) diff --git a/packages/point-of-sale/src/merchant/errors.ts b/packages/point-of-sale/src/merchant/errors.ts index c16b966dbd..2403dd0e86 100644 --- a/packages/point-of-sale/src/merchant/errors.ts +++ b/packages/point-of-sale/src/merchant/errors.ts @@ -1,15 +1,23 @@ +export enum RouteErrorCode { + InvalidRequest = 'invalid_request', + InvalidClient = 'invalid_client' +} + export class POSMerchantRouteError extends Error { public status: number + public code?: RouteErrorCode public details?: Record constructor( status: number, message: string, + code?: RouteErrorCode, details?: Record ) { super(message) this.name = 'POSMerchantRouteError' this.status = status + this.code = code ?? undefined this.details = details } } diff --git a/packages/point-of-sale/src/merchant/middleware.ts b/packages/point-of-sale/src/merchant/middleware.ts index da1d8f927d..3ffd61a998 100644 --- a/packages/point-of-sale/src/merchant/middleware.ts +++ b/packages/point-of-sale/src/merchant/middleware.ts @@ -1,43 +1,68 @@ import { - getKeyId, - validateSignature, - validateSignatureHeaders, - RequestLike - } from '@interledger/http-signature-utils' + getKeyId, + validateSignature, + validateSignatureHeaders, + RequestLike +} from '@interledger/http-signature-utils' - import { AppContext } from '../app' +import { AppContext } from '../app' +import { CreateMerchantContext } from './routes' +import { POSMerchantRouteError, RouteErrorCode } from './errors' function contextToRequestLike(ctx: AppContext): RequestLike { - - return { - url: ctx.href, - method: ctx.method, - headers: ctx.headers ? JSON.parse(JSON.stringify(ctx.headers)) : undefined, - body: ctx.request.body ? JSON.stringify(ctx.request.body) : undefined - } + return { + url: ctx.href, + method: ctx.method, + headers: ctx.headers ? JSON.parse(JSON.stringify(ctx.headers)) : undefined, + body: ctx.request.body ? JSON.stringify(ctx.request.body) : undefined } +} export async function validatePosSignatureMiddleware( - ctx: CreateContext, - next: () => Promise - ): Promise { - if (!validateSignatureHeaders(contextToRequestLike(ctx))) { - throw new GNAPServerRouteError( - 401, - GNAPErrorCode.InvalidClient, - 'invalid signature headers' - ) - } - - const { body } = ctx.request - - const sigVerified = await verifySigFromClient(body.client, ctx) - if (!sigVerified) { - throw new GNAPServerRouteError( - 401, - GNAPErrorCode.InvalidClient, - 'invalid signature' - ) - } - await next() - } \ No newline at end of file + ctx: CreateMerchantContext, + next: () => Promise +): Promise { + if (!validateSignatureHeaders(contextToRequestLike(ctx))) { + throw new POSMerchantRouteError( + 401, + 'invalid signature headers', + RouteErrorCode.InvalidClient + ) + } + + const sigVerified = await verifySigFromClient(ctx) + if (!sigVerified) { + throw new POSMerchantRouteError( + 401, + 'invalid signature', + RouteErrorCode.InvalidClient + ) + } + await next() +} + +async function verifySigFromClient(ctx: AppContext): Promise { + const sigInput = ctx.headers['signature-input'] as string + const keyId = getKeyId(sigInput) + if (!keyId) { + throw new POSMerchantRouteError( + 401, + 'invalid signature input', + RouteErrorCode.InvalidClient + ) + } + + const posService = await ctx.container.use('posService') + const clientKey = await posService.getByKeyId({ + keyId + }) + + if (!clientKey) { + throw new POSMerchantRouteError( + 400, + 'could not determine client', + RouteErrorCode.InvalidClient + ) + } + return validateSignature(clientKey, contextToRequestLike(ctx)) +} diff --git a/packages/point-of-sale/src/merchant/routes.ts b/packages/point-of-sale/src/merchant/routes.ts index 774510a324..95018ebd1d 100644 --- a/packages/point-of-sale/src/merchant/routes.ts +++ b/packages/point-of-sale/src/merchant/routes.ts @@ -49,6 +49,11 @@ async function createMerchant( ctx.status = 200 ctx.body = { id: merchant.id, name: merchant.name } } catch (err) { - throw new POSMerchantRouteError(400, 'Could not create merchant', { err }) + throw new POSMerchantRouteError( + 400, + 'Could not create merchant', + undefined, + { err } + ) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b8d24a22e..486d4c6aca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -556,12 +556,12 @@ importers: '@koa/cors': specifier: ^5.0.0 version: 5.0.0 - ioredis: - specifier: ^5.3.2 - version: 5.3.2 '@koa/router': specifier: ^12.0.2 version: 12.0.2 + ioredis: + specifier: ^5.3.2 + version: 5.3.2 knex: specifier: ^3.1.0 version: 3.1.0(pg@8.11.3) From d260e6fc69b46011df7d473b1d0659ee56cb4b5e Mon Sep 17 00:00:00 2001 From: Arpi Lengyel Date: Thu, 10 Jul 2025 12:45:21 +0300 Subject: [PATCH 17/22] fix no any --- packages/point-of-sale/src/merchant/middleware.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/point-of-sale/src/merchant/middleware.ts b/packages/point-of-sale/src/merchant/middleware.ts index 3ffd61a998..7e18ac77f4 100644 --- a/packages/point-of-sale/src/merchant/middleware.ts +++ b/packages/point-of-sale/src/merchant/middleware.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { getKeyId, validateSignature, From 2b43308f6d68acc19f1f9e44a83212dc2d7defe1 Mon Sep 17 00:00:00 2001 From: Arpi Lengyel Date: Thu, 10 Jul 2025 13:40:23 +0300 Subject: [PATCH 18/22] adjustments --- packages/point-of-sale/src/app.ts | 1 - packages/point-of-sale/src/merchant/errors.ts | 6 +++--- .../point-of-sale/src/merchant/middleware.ts | 20 +++++++++---------- packages/point-of-sale/src/merchant/routes.ts | 4 ++-- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/point-of-sale/src/app.ts b/packages/point-of-sale/src/app.ts index 1f190d8b2a..d2bf934489 100644 --- a/packages/point-of-sale/src/app.ts +++ b/packages/point-of-sale/src/app.ts @@ -63,7 +63,6 @@ export class App { // Create merchant router.post( '/merchants', - validatePosSignatureMiddleware, merchantRoutes.create ) diff --git a/packages/point-of-sale/src/merchant/errors.ts b/packages/point-of-sale/src/merchant/errors.ts index 2403dd0e86..db0faef80b 100644 --- a/packages/point-of-sale/src/merchant/errors.ts +++ b/packages/point-of-sale/src/merchant/errors.ts @@ -1,9 +1,9 @@ export enum RouteErrorCode { - InvalidRequest = 'invalid_request', + InvalidSignature = 'invalid_signature', InvalidClient = 'invalid_client' } -export class POSMerchantRouteError extends Error { +export class MerchantRouteError extends Error { public status: number public code?: RouteErrorCode public details?: Record @@ -15,7 +15,7 @@ export class POSMerchantRouteError extends Error { details?: Record ) { super(message) - this.name = 'POSMerchantRouteError' + this.name = 'MerchantRouteError' this.status = status this.code = code ?? undefined this.details = details diff --git a/packages/point-of-sale/src/merchant/middleware.ts b/packages/point-of-sale/src/merchant/middleware.ts index 7e18ac77f4..8657fa6dd8 100644 --- a/packages/point-of-sale/src/merchant/middleware.ts +++ b/packages/point-of-sale/src/merchant/middleware.ts @@ -8,7 +8,7 @@ import { import { AppContext } from '../app' import { CreateMerchantContext } from './routes' -import { POSMerchantRouteError, RouteErrorCode } from './errors' +import { MerchantRouteError, RouteErrorCode } from './errors' function contextToRequestLike(ctx: AppContext): RequestLike { return { @@ -24,19 +24,19 @@ export async function validatePosSignatureMiddleware( next: () => Promise ): Promise { if (!validateSignatureHeaders(contextToRequestLike(ctx))) { - throw new POSMerchantRouteError( + throw new MerchantRouteError( 401, 'invalid signature headers', - RouteErrorCode.InvalidClient + RouteErrorCode.InvalidSignature ) } const sigVerified = await verifySigFromClient(ctx) if (!sigVerified) { - throw new POSMerchantRouteError( + throw new MerchantRouteError( 401, 'invalid signature', - RouteErrorCode.InvalidClient + RouteErrorCode.InvalidSignature ) } await next() @@ -46,20 +46,20 @@ async function verifySigFromClient(ctx: AppContext): Promise { const sigInput = ctx.headers['signature-input'] as string const keyId = getKeyId(sigInput) if (!keyId) { - throw new POSMerchantRouteError( + throw new MerchantRouteError( 401, 'invalid signature input', - RouteErrorCode.InvalidClient + RouteErrorCode.InvalidSignature ) } - const posService = await ctx.container.use('posService') - const clientKey = await posService.getByKeyId({ + const posDeviceService = await ctx.container.use('posDeviceService') + const clientKey = await posDeviceService.getByKeyId({ keyId }) if (!clientKey) { - throw new POSMerchantRouteError( + throw new MerchantRouteError( 400, 'could not determine client', RouteErrorCode.InvalidClient diff --git a/packages/point-of-sale/src/merchant/routes.ts b/packages/point-of-sale/src/merchant/routes.ts index 95018ebd1d..ddbb4e5453 100644 --- a/packages/point-of-sale/src/merchant/routes.ts +++ b/packages/point-of-sale/src/merchant/routes.ts @@ -1,7 +1,7 @@ import { AppContext } from '../app' import { BaseService } from '../shared/baseService' import { MerchantService } from './service' -import { POSMerchantRouteError } from './errors' +import { MerchantRouteError } from './errors' interface ServiceDependencies extends BaseService { merchantService: MerchantService @@ -49,7 +49,7 @@ async function createMerchant( ctx.status = 200 ctx.body = { id: merchant.id, name: merchant.name } } catch (err) { - throw new POSMerchantRouteError( + throw new MerchantRouteError( 400, 'Could not create merchant', undefined, From ed3d49c606fb259a66b1836f23d4fbc2cc149498 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Thu, 10 Jul 2025 13:43:44 +0300 Subject: [PATCH 19/22] fix(localenv): add required env vars to docker-compose (#3550) --- localenv/cloud-nine-wallet/docker-compose.yml | 1 + localenv/happy-life-bank/docker-compose.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 2b6a7ec64a..6a27730663 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -146,6 +146,7 @@ services: ENABLE_TELEMETRY: true KEY_ID: 7097F83B-CB84-469E-96C6-2141C72E22C0 OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + CARD_SERVICE_HOST: 'http://cloud-nine-wallet-card-service:3007' depends_on: - shared-database - shared-redis diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 7b39fbeefc..358a32f480 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -141,6 +141,7 @@ services: ENABLE_TELEMETRY: true KEY_ID: 53f2d913-e98a-40b9-b270-372d0547f23d OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + CARD_SERVICE_HOST: 'http://happy-life-bank-card-service:4007' depends_on: - cloud-nine-backend healthcheck: From c5b9b7638b725c9de8d6d6c74d9e2529ce6dc9de Mon Sep 17 00:00:00 2001 From: oana-lolea Date: Thu, 10 Jul 2025 13:52:38 +0300 Subject: [PATCH 20/22] feat(point-of-sale): POS Device service (#3548) * pos device service * Added algorithm as param on registering device, updated tests so that we check the device obj --- .../20250708121812_create_pos_device_table.js | 5 +- packages/point-of-sale/src/index.ts | 10 ++ .../src/merchant/devices/errors.ts | 8 ++ .../src/merchant/devices/model.ts | 4 +- .../src/merchant/devices/service.test.ts | 129 ++++++++++++++++++ .../src/merchant/devices/service.ts | 114 ++++++++++++++++ 6 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 packages/point-of-sale/src/merchant/devices/errors.ts create mode 100644 packages/point-of-sale/src/merchant/devices/service.test.ts create mode 100644 packages/point-of-sale/src/merchant/devices/service.ts diff --git a/packages/point-of-sale/migrations/20250708121812_create_pos_device_table.js b/packages/point-of-sale/migrations/20250708121812_create_pos_device_table.js index 4d1d6d134b..61c359c004 100644 --- a/packages/point-of-sale/migrations/20250708121812_create_pos_device_table.js +++ b/packages/point-of-sale/migrations/20250708121812_create_pos_device_table.js @@ -12,11 +12,10 @@ exports.up = function (knex) { .onDelete('CASCADE') .index() - table.uuid('walletAddressId').notNullable() - + table.string('walletAddress').notNullable() table.string('deviceName').notNullable() table.string('publicKey') - table.string('keyId') + table.string('keyId').notNullable().unique() table.string('algorithm') table.string('status').notNullable() diff --git a/packages/point-of-sale/src/index.ts b/packages/point-of-sale/src/index.ts index e24ed825e1..afa863877a 100644 --- a/packages/point-of-sale/src/index.ts +++ b/packages/point-of-sale/src/index.ts @@ -5,6 +5,7 @@ import { Config } from './config/app' import { App, AppServices } from './app' import createLogger from 'pino' import { createMerchantService } from './merchant/service' +import { createPosDeviceService } from './merchant/devices/service' import { createMerchantRoutes } from './merchant/routes' export function initIocContainer( @@ -73,6 +74,15 @@ export function initIocContainer( }) }) + container.singleton( + 'posDeviceService', + async (deps: IocContract) => { + const config = await deps.use('config') + const logger = await deps.use('logger') + const knex = await deps.use('knex') + return await createPosDeviceService({ config, logger, knex }) + } + ) return container } diff --git a/packages/point-of-sale/src/merchant/devices/errors.ts b/packages/point-of-sale/src/merchant/devices/errors.ts new file mode 100644 index 0000000000..f53eba32f2 --- /dev/null +++ b/packages/point-of-sale/src/merchant/devices/errors.ts @@ -0,0 +1,8 @@ +export enum PosDeviceError { + UnknownMerchant = 'UnknownMerchant', + UnknownPosDevice = 'UnknownPosDevice' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +export const isPosDeviceError = (o: any): o is PosDeviceError => + Object.values(PosDeviceError).includes(o) diff --git a/packages/point-of-sale/src/merchant/devices/model.ts b/packages/point-of-sale/src/merchant/devices/model.ts index 63c7db77e4..be9e77c0d1 100644 --- a/packages/point-of-sale/src/merchant/devices/model.ts +++ b/packages/point-of-sale/src/merchant/devices/model.ts @@ -25,10 +25,10 @@ export class PosDevice extends BaseModel { }) public merchantId!: string // foreign key on merchants table - public walletAddressId!: string + public walletAddress!: string // wallet address url public publicKey?: string // PEM format - public keyId?: string + public keyId!: string // generated on creation public algorithm!: string // ecdsa-p256-sha256 public status!: DeviceStatus // enum "ACTIVE" | "REVOKED" diff --git a/packages/point-of-sale/src/merchant/devices/service.test.ts b/packages/point-of-sale/src/merchant/devices/service.test.ts new file mode 100644 index 0000000000..ce427dbf4d --- /dev/null +++ b/packages/point-of-sale/src/merchant/devices/service.test.ts @@ -0,0 +1,129 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { Config } from '../../config/app' +import { CreateOptions, PosDeviceService } from './service' +import { initIocContainer } from '../..' +import { PosDeviceError, isPosDeviceError } from './errors' +import { v4 as uuid } from 'uuid' +import { TestContainer, createTestApp } from '../../tests/app' +import { truncateTables } from '../../tests/tableManager' +import { MerchantService } from '../service' +import { DeviceStatus, PosDevice } from './model' +import assert from 'assert' + +describe('POS Device Service', () => { + let deps: IocContract + let posDeviceService: PosDeviceService + let merchantService: MerchantService + let appContainer: TestContainer + + beforeAll(async () => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + posDeviceService = await deps.use('posDeviceService') + merchantService = await deps.use('merchantService') + }) + + afterEach(async (): Promise => { + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create', () => { + test('device can be created and fetched', async () => { + const merchant = await merchantService.create('merchant') + const createOptions: CreateOptions = { + merchantId: merchant.id, + publicKey: 'publicKey', + deviceName: 'device', + walletAddress: 'walletAddress', + algorithm: 'ecdsa-p256-sha256' + } + + const device = await posDeviceService.registerDevice(createOptions) + assert(!isPosDeviceError(device)) + expect(device).toMatchObject({ + merchantId: merchant.id, + publicKey: 'publicKey', + deviceName: 'device', + walletAddress: 'walletAddress', + algorithm: 'ecdsa-p256-sha256', + keyId: expect.stringMatching(/^pos:device[a-zA-Z0-9-]{6}$/) + }) + }) + + test('returns error if merchant does not exist', async () => { + const createOptions: CreateOptions = { + merchantId: uuid(), + publicKey: 'publicKey', + deviceName: 'device', + walletAddress: 'walletAddress', + algorithm: 'ecdsa-p256-sha256' + } + + const device = await posDeviceService.registerDevice(createOptions) + assert(isPosDeviceError(device)) + expect(device).toBe(PosDeviceError.UnknownMerchant) + }) + }) + + describe('getByKeyId', () => { + test('returns device by keyId', async () => { + const createdDevice = await createDeviceWithMerchant() + const foundDevice = await posDeviceService.getByKeyId(createdDevice.keyId) + expect(foundDevice).toBeDefined() + }) + + test('returns undefined when no device with the given keyId exists', async () => { + const foundDevice = await posDeviceService.getByKeyId(uuid()) + expect(foundDevice).toBeUndefined() + }) + }) + + describe('revoke', () => { + test('returns device with revoked status', async () => { + const createdDevice = await createDeviceWithMerchant() + expect(createdDevice).toMatchObject({ + status: DeviceStatus.Active, + deletedAt: null + }) + const revokedDevice = await posDeviceService.revoke(createdDevice.id) + assert(!isPosDeviceError(revokedDevice)) + expect(revokedDevice).toMatchObject({ + status: DeviceStatus.Revoked, + deletedAt: expect.any(Date) + }) + + // Checking if it was deleted recently + assert(revokedDevice.deletedAt) + expect( + Math.abs(revokedDevice.deletedAt.getTime() - new Date().getTime()) + ).toBeLessThan(5000) + }) + + test('returns error when there is no device with the given id', async () => { + await createDeviceWithMerchant() + const revokedDevice = await posDeviceService.revoke(uuid()) + assert(isPosDeviceError(revokedDevice)) + expect(revokedDevice).toBe(PosDeviceError.UnknownPosDevice) + }) + }) + + async function createDeviceWithMerchant(): Promise { + const merchant = await merchantService.create('merchant') + const createOptions: CreateOptions = { + merchantId: merchant.id, + publicKey: 'publicKey', + deviceName: 'device', + walletAddress: 'walletAddress', + algorithm: 'ecdsa-p256-sha256' + } + + const device = await posDeviceService.registerDevice(createOptions) + assert(!isPosDeviceError(device)) + return device + } +}) diff --git a/packages/point-of-sale/src/merchant/devices/service.ts b/packages/point-of-sale/src/merchant/devices/service.ts new file mode 100644 index 0000000000..90d93176ed --- /dev/null +++ b/packages/point-of-sale/src/merchant/devices/service.ts @@ -0,0 +1,114 @@ +import { NotFoundError } from 'objection' +import { IAppConfig } from '../../config/app' +import { BaseService } from '../../shared/baseService' +import { Merchant } from '../model' +import { PosDeviceError } from './errors' +import { DeviceStatus, PosDevice } from './model' +import { v4 as uuid } from 'uuid' + +export interface PosDeviceService { + registerDevice(options: CreateOptions): Promise + + getByKeyId(keyId: string): Promise + + revoke(id: string): Promise +} + +export interface CreateOptions { + merchantId: string + publicKey: string + deviceName: string + walletAddress: string + algorithm: string +} + +interface ServiceDependencies extends BaseService { + config: IAppConfig +} + +export async function createPosDeviceService({ + config, + logger, + knex +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'PosDeviceService' + }) + const deps: ServiceDependencies = { + config, + logger: log, + knex + } + + return { + registerDevice: (options) => registerDevice(deps, options), + getByKeyId: (keyId) => getByKeyId(deps, keyId), + revoke: (id) => revoke(deps, id) + } +} + +async function registerDevice( + deps: ServiceDependencies, + { merchantId, publicKey, deviceName, walletAddress, algorithm }: CreateOptions +): Promise { + const merchant = await Merchant.query(deps.knex).findById(merchantId) + if (!merchant) { + return PosDeviceError.UnknownMerchant + } + + const device = await PosDevice.query(deps.knex).insertAndFetch({ + walletAddress, + merchantId, + publicKey, + deviceName, + status: DeviceStatus.Active, + keyId: generateKeyId(deviceName), + algorithm + }) + return device +} + +async function getByKeyId( + deps: ServiceDependencies, + keyId: string +): Promise { + const device = await PosDevice.query(deps.knex) + .where({ + keyId + }) + .first() + return device +} + +async function revoke( + deps: ServiceDependencies, + id: string +): Promise { + try { + const device = await PosDevice.query(deps.knex) + .patchAndFetchById(id, { + status: DeviceStatus.Revoked, + deletedAt: new Date() + }) + .throwIfNotFound() + return device + } catch (err) { + if (err instanceof NotFoundError) { + return PosDeviceError.UnknownPosDevice + } + throw err + } +} + +function generateKeyId(deviceName: string): string { + const deviceNameTrimmed = deviceName.replace(/\s/g, '') + const PREFIX = 'pos:' + const MAX_LENGTH = 6 + const TOTAL_LENGTH = 12 + if (deviceNameTrimmed.length < MAX_LENGTH) { + // if the name has less than 6 chars, we'll generate extra missing chars to maintain length + const uuidPartLength = TOTAL_LENGTH - deviceNameTrimmed.length + return `${PREFIX}${deviceNameTrimmed}${uuid().substring(0, uuidPartLength)}` + } + return `${PREFIX}${deviceNameTrimmed.substring(0, MAX_LENGTH)}${uuid().substring(0, MAX_LENGTH)}` +} From a9f122daa5c8ecee8fda355cec3d58a34378d5f5 Mon Sep 17 00:00:00 2001 From: Arpi Lengyel Date: Thu, 10 Jul 2025 14:05:14 +0300 Subject: [PATCH 21/22] format --- packages/point-of-sale/src/merchant/routes.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/point-of-sale/src/merchant/routes.ts b/packages/point-of-sale/src/merchant/routes.ts index ddbb4e5453..5a83735579 100644 --- a/packages/point-of-sale/src/merchant/routes.ts +++ b/packages/point-of-sale/src/merchant/routes.ts @@ -49,11 +49,8 @@ async function createMerchant( ctx.status = 200 ctx.body = { id: merchant.id, name: merchant.name } } catch (err) { - throw new MerchantRouteError( - 400, - 'Could not create merchant', - undefined, - { err } - ) + throw new MerchantRouteError(400, 'Could not create merchant', undefined, { + err + }) } } From 3a78f17c9014ec3269fb2dd5f2568c9a3757bb32 Mon Sep 17 00:00:00 2001 From: Arpi Lengyel Date: Thu, 10 Jul 2025 14:42:17 +0300 Subject: [PATCH 22/22] unused --- packages/point-of-sale/src/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/point-of-sale/src/app.ts b/packages/point-of-sale/src/app.ts index d2bf934489..f6467c33a3 100644 --- a/packages/point-of-sale/src/app.ts +++ b/packages/point-of-sale/src/app.ts @@ -8,7 +8,6 @@ import Router from '@koa/router' import bodyParser from 'koa-bodyparser' import cors from '@koa/cors' import { CreateMerchantContext, MerchantRoutes } from './merchant/routes' -import { validatePosSignatureMiddleware } from './merchant/middleware' export interface AppServices { logger: Promise