From 4312740ede96984737dce60f1840f1b63c44120b Mon Sep 17 00:00:00 2001 From: beniaminmunteanu Date: Thu, 10 Jul 2025 14:28:27 +0300 Subject: [PATCH] feat(post-service): revoke merchant route --- packages/point-of-sale/src/app.ts | 13 +++- packages/point-of-sale/src/index.ts | 7 +- .../src/merchant/devices/service.test.ts | 72 +++++++++++++++++++ .../src/merchant/devices/service.ts | 21 +++++- .../point-of-sale/src/merchant/routes.test.ts | 67 +++++++++++++++-- packages/point-of-sale/src/merchant/routes.ts | 35 ++++++++- .../src/merchant/service.test.ts | 63 +++++++++++++++- .../point-of-sale/src/merchant/service.ts | 30 ++++++-- 8 files changed, 289 insertions(+), 19 deletions(-) diff --git a/packages/point-of-sale/src/app.ts b/packages/point-of-sale/src/app.ts index f6467c33a3..3ff420de36 100644 --- a/packages/point-of-sale/src/app.ts +++ b/packages/point-of-sale/src/app.ts @@ -7,7 +7,11 @@ 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' +import { + CreateMerchantContext, + DeleteMerchantContext, + MerchantRoutes +} from './merchant/routes' export interface AppServices { logger: Promise @@ -65,6 +69,13 @@ export class App { merchantRoutes.create ) + // DELETE /merchants/:merchantId + // Delete merchant + router.delete( + '/merchants/:merchantId', + merchantRoutes.delete + ) + 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 afa863877a..ef797b7f5d 100644 --- a/packages/point-of-sale/src/index.ts +++ b/packages/point-of-sale/src/index.ts @@ -60,11 +60,12 @@ export function initIocContainer( }) container.singleton('merchantService', async (deps) => { - const [logger, knex] = await Promise.all([ + const [logger, knex, posDeviceService] = await Promise.all([ deps.use('logger'), - deps.use('knex') + deps.use('knex'), + deps.use('posDeviceService') ]) - return createMerchantService({ logger, knex }) + return createMerchantService({ logger, knex, posDeviceService }) }) container.singleton('merchantRoutes', async (deps) => { diff --git a/packages/point-of-sale/src/merchant/devices/service.test.ts b/packages/point-of-sale/src/merchant/devices/service.test.ts index ce427dbf4d..db77375450 100644 --- a/packages/point-of-sale/src/merchant/devices/service.test.ts +++ b/packages/point-of-sale/src/merchant/devices/service.test.ts @@ -112,6 +112,78 @@ describe('POS Device Service', () => { }) }) + describe('revokeAllByMerchantId', () => { + test('revokes all devices for a merchant', async () => { + const merchant = await merchantService.create('Test Merchant') + + const device1 = await posDeviceService.registerDevice({ + merchantId: merchant.id, + publicKey: 'publicKey1', + deviceName: 'device1', + walletAddress: 'walletAddress1', + algorithm: 'ecdsa-p256-sha256' + }) + + const device2 = await posDeviceService.registerDevice({ + merchantId: merchant.id, + publicKey: 'publicKey2', + deviceName: 'device2', + walletAddress: 'walletAddress2', + algorithm: 'ecdsa-p256-sha256' + }) + + assert(!isPosDeviceError(device1)) + assert(!isPosDeviceError(device2)) + expect(device1.status).toBe(DeviceStatus.Active) + expect(device2.status).toBe(DeviceStatus.Active) + + const revokedCount = await posDeviceService.revokeAllByMerchantId( + merchant.id + ) + expect(revokedCount).toBe(2) + + const knex = await deps.use('knex') + const revokedDevices = await PosDevice.query(knex) + .where('merchantId', merchant.id) + .whereNotNull('deletedAt') + + expect(revokedDevices).toHaveLength(2) + expect(revokedDevices[0].status).toBe(DeviceStatus.Revoked) + expect(revokedDevices[0].deletedAt).toBeDefined() + expect(revokedDevices[1].status).toBe(DeviceStatus.Revoked) + expect(revokedDevices[1].deletedAt).toBeDefined() + }) + + test('returns 0 when no devices exist for merchant', async () => { + const merchant = await merchantService.create('Test Merchant') + const revokedCount = await posDeviceService.revokeAllByMerchantId( + merchant.id + ) + expect(revokedCount).toBe(0) + }) + + test('returns 0 when all devices are already revoked', async () => { + const merchant = await merchantService.create('Test Merchant') + + const device = await posDeviceService.registerDevice({ + merchantId: merchant.id, + publicKey: 'publicKey', + deviceName: 'device', + walletAddress: 'walletAddress', + algorithm: 'ecdsa-p256-sha256' + }) + + assert(!isPosDeviceError(device)) + + await posDeviceService.revoke(device.id) + + const revokedCount = await posDeviceService.revokeAllByMerchantId( + merchant.id + ) + expect(revokedCount).toBe(0) + }) + }) + async function createDeviceWithMerchant(): Promise { const merchant = await merchantService.create('merchant') const createOptions: CreateOptions = { diff --git a/packages/point-of-sale/src/merchant/devices/service.ts b/packages/point-of-sale/src/merchant/devices/service.ts index 90d93176ed..1cd2f37cd1 100644 --- a/packages/point-of-sale/src/merchant/devices/service.ts +++ b/packages/point-of-sale/src/merchant/devices/service.ts @@ -12,6 +12,8 @@ export interface PosDeviceService { getByKeyId(keyId: string): Promise revoke(id: string): Promise + + revokeAllByMerchantId(merchantId: string): Promise } export interface CreateOptions { @@ -43,7 +45,9 @@ export async function createPosDeviceService({ return { registerDevice: (options) => registerDevice(deps, options), getByKeyId: (keyId) => getByKeyId(deps, keyId), - revoke: (id) => revoke(deps, id) + revoke: (id) => revoke(deps, id), + revokeAllByMerchantId: (merchantId) => + revokeAllByMerchantId(deps, merchantId) } } @@ -100,6 +104,21 @@ async function revoke( } } +async function revokeAllByMerchantId( + deps: ServiceDependencies, + merchantId: string +): Promise { + const revokedCount = await PosDevice.query(deps.knex) + .patch({ + status: DeviceStatus.Revoked, + deletedAt: new Date() + }) + .where('merchantId', merchantId) + .whereNull('deletedAt') + + return revokedCount +} + function generateKeyId(deviceName: string): string { const deviceNameTrimmed = deviceName.replace(/\s/g, '') const PREFIX = 'pos:' diff --git a/packages/point-of-sale/src/merchant/routes.test.ts b/packages/point-of-sale/src/merchant/routes.test.ts index 49e652e4f4..5f38c2ee6f 100644 --- a/packages/point-of-sale/src/merchant/routes.test.ts +++ b/packages/point-of-sale/src/merchant/routes.test.ts @@ -1,14 +1,16 @@ import { IocContract } from '@adonisjs/fold' -import { createContext } from '../tests/context' -import { createTestApp, TestContainer } from '../tests/app' -import { Config } from '../config/app' +import { v4 as uuid } from 'uuid' import { initIocContainer } from '..' import { AppServices } from '../app' +import { Config } from '../config/app' +import { createTestApp, TestContainer } from '../tests/app' +import { createContext } from '../tests/context' import { truncateTables } from '../tests/tableManager' import { CreateMerchantContext, - MerchantRoutes, - createMerchantRoutes + createMerchantRoutes, + DeleteMerchantContext, + MerchantRoutes } from './routes' import { MerchantService } from './service' @@ -64,4 +66,59 @@ describe('Merchant Routes', (): void => { }) }) }) + + describe('delete', (): void => { + test('Deletes a merchant', async (): Promise => { + const merchant = await merchantService.create('Test Merchant') + + const ctx = createContext( + { + headers: { + Accept: 'application/json' + } + }, + {} + ) + ctx.request.params = { merchantId: merchant.id } + + await merchantRoutes.delete(ctx) + + expect(ctx.status).toBe(204) + }) + + test('Returns 404 for non-existent merchant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json' + } + }, + {} + ) + ctx.request.params = { merchantId: uuid() } + + await expect(merchantRoutes.delete(ctx)).rejects.toThrow( + 'Merchant not found' + ) + }) + + test('Returns 404 for already deleted merchant', async (): Promise => { + const merchant = await merchantService.create('Test Merchant') + await merchantService.delete(merchant.id) + + const ctx = createContext( + { + headers: { + Accept: 'application/json' + } + }, + {} + ) + ctx.request.params = { merchantId: merchant.id } + + await expect(merchantRoutes.delete(ctx)).rejects.toThrow( + 'Merchant not found' + ) + }) + }) }) diff --git a/packages/point-of-sale/src/merchant/routes.ts b/packages/point-of-sale/src/merchant/routes.ts index 774510a324..7038f2144e 100644 --- a/packages/point-of-sale/src/merchant/routes.ts +++ b/packages/point-of-sale/src/merchant/routes.ts @@ -17,8 +17,19 @@ export type CreateMerchantContext = Exclude & { request: CreateMerchantRequest } +type DeleteMerchantRequest = Exclude & { + params: { + merchantId: string + } +} + +export type DeleteMerchantContext = Exclude & { + request: DeleteMerchantRequest +} + export interface MerchantRoutes { create(ctx: CreateMerchantContext): Promise + delete(ctx: DeleteMerchantContext): Promise } export function createMerchantRoutes( @@ -34,7 +45,8 @@ export function createMerchantRoutes( } return { - create: (ctx: CreateMerchantContext) => createMerchant(deps, ctx) + create: (ctx: CreateMerchantContext) => createMerchant(deps, ctx), + delete: (ctx: DeleteMerchantContext) => deleteMerchant(deps, ctx) } } @@ -52,3 +64,24 @@ async function createMerchant( throw new POSMerchantRouteError(400, 'Could not create merchant', { err }) } } + +async function deleteMerchant( + deps: ServiceDependencies, + ctx: DeleteMerchantContext +): Promise { + const { merchantId } = ctx.request.params + try { + const deleted = await deps.merchantService.delete(merchantId) + + if (!deleted) { + throw new POSMerchantRouteError(404, 'Merchant not found') + } + + ctx.status = 204 + } catch (err) { + if (err instanceof POSMerchantRouteError) { + throw err + } + throw new POSMerchantRouteError(400, 'Could not delete 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 c1cd6d56b3..aeb636dc0d 100644 --- a/packages/point-of-sale/src/merchant/service.test.ts +++ b/packages/point-of-sale/src/merchant/service.test.ts @@ -1,11 +1,16 @@ import { IocContract } from '@adonisjs/fold' +import { v4 as uuid } from 'uuid' + +import { isPosDeviceError } from './devices/errors' +import { DeviceStatus, PosDevice } from './devices/model' +import { PosDeviceService } from './devices/service' import { Merchant } from './model' import { MerchantService } from './service' +import { Config } from '../config/app' import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' -import { Config } from '../config/app' import { initIocContainer } from '../' import { AppServices } from '../app' @@ -14,6 +19,7 @@ describe('Merchant Service', (): void => { let deps: IocContract let appContainer: TestContainer let merchantService: MerchantService + let posDeviceService: PosDeviceService beforeAll(async (): Promise => { deps = initIocContainer({ @@ -22,6 +28,7 @@ describe('Merchant Service', (): void => { appContainer = await createTestApp(deps) merchantService = await deps.use('merchantService') + posDeviceService = await deps.use('posDeviceService') }) afterEach(async (): Promise => { @@ -67,5 +74,59 @@ describe('Merchant Service', (): void => { const secondDelete = await merchantService.delete(created.id) expect(secondDelete).toBe(false) }) + + test('soft deletes and revokes associated devices when deleting merchant', async (): Promise => { + const knex = await deps.use('knex') + const created = await merchantService.create('Test merchant') + + const device1Result = await posDeviceService.registerDevice({ + merchantId: created.id, + walletAddress: 'wallet1', + algorithm: 'ecdsa-p256-sha256', + deviceName: 'Device 1', + publicKey: 'test-public-key-1' + }) + + const device2Result = await posDeviceService.registerDevice({ + merchantId: created.id, + walletAddress: 'wallet2', + algorithm: 'ecdsa-p256-sha256', + deviceName: 'Device 2', + publicKey: 'test-public-key-2' + }) + + expect(isPosDeviceError(device1Result)).toBe(false) + expect(isPosDeviceError(device2Result)).toBe(false) + if (!isPosDeviceError(device1Result)) { + expect(device1Result.status).toBe(DeviceStatus.Active) + } + if (!isPosDeviceError(device2Result)) { + expect(device2Result.status).toBe(DeviceStatus.Active) + } + + const result = await merchantService.delete(created.id) + expect(result).toBe(true) + + const deletedMerchant = await Merchant.query(knex) + .findById(created.id) + .whereNotNull('deletedAt') + expect(deletedMerchant).toBeDefined() + expect(deletedMerchant?.deletedAt).toBeDefined() + + const deletedDevices = await PosDevice.query(knex) + .where('merchantId', created.id) + .whereNotNull('deletedAt') + + expect(deletedDevices).toHaveLength(2) + expect(deletedDevices[0].status).toBe(DeviceStatus.Revoked) + expect(deletedDevices[0].deletedAt).toBeDefined() + expect(deletedDevices[1].status).toBe(DeviceStatus.Revoked) + expect(deletedDevices[1].deletedAt).toBeDefined() + }) + + test('returns false for non-existent merchant', async (): Promise => { + const result = await merchantService.delete(uuid()) + expect(result).toBe(false) + }) }) }) diff --git a/packages/point-of-sale/src/merchant/service.ts b/packages/point-of-sale/src/merchant/service.ts index 8540c74c54..52e1b6419f 100644 --- a/packages/point-of-sale/src/merchant/service.ts +++ b/packages/point-of-sale/src/merchant/service.ts @@ -1,6 +1,7 @@ import { BaseService } from '../shared/baseService' import { TransactionOrKnex } from 'objection' import { Merchant } from './model' +import { PosDeviceService } from './devices/service' export interface MerchantService { create(name: string): Promise @@ -9,18 +10,21 @@ export interface MerchantService { interface ServiceDependencies extends BaseService { knex: TransactionOrKnex + posDeviceService: PosDeviceService } export async function createMerchantService({ logger, - knex + knex, + posDeviceService }: ServiceDependencies): Promise { const log = logger.child({ service: 'MerchantService' }) const deps: ServiceDependencies = { logger: log, - knex + knex, + posDeviceService } return { @@ -40,9 +44,21 @@ 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 + const trx = await deps.knex.transaction() + try { + const deleted = await Merchant.query(trx) + .patch({ deletedAt: new Date() }) + .whereNull('deletedAt') + .where('id', id) + + if (deleted > 0) { + await deps.posDeviceService.revokeAllByMerchantId(id) + } + + await trx.commit() + return deleted > 0 + } catch (error) { + await trx.rollback() + throw error + } }