From 93d0f2b208313ec76a5d173fbb0dc0c4389e6ec9 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 9 Sep 2026 11:01:45 +0200 Subject: [PATCH] fix: reject a negative quantity on IN/OUT stock movements addMovement() negates quantity internally for OUT (delta = -quantity) and applies it directly for IN. Nothing stopped a negative quantity from being submitted, so e.g. { type: 'OUT', quantity: -5 } would compute delta = -(-5) = +5 - silently ADDING stock while being recorded in the audit trail as a removal. The inverse held for IN. ADJUSTMENT is the one type that must stay allowed to be negative, since that's the only way to correct a stock count downward, so this is a service-level guard (BadRequestException) rather than a DTO validator - class-validator's @ValidateIf skips every validator on a property when its condition is false, including @IsNumber, so it can't express "always a number, but only sometimes positive" cleanly; verified this empirically before choosing the service-level approach. Co-Authored-By: Claude Sonnet 5 --- .../storage/dto/create-stock-movement.dto.ts | 6 +++- .../src/storage/storage.service.spec.ts | 34 ++++++++++++++++++- .../src/storage/storage.service.ts | 16 ++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/ApiCore-Backend/src/storage/dto/create-stock-movement.dto.ts b/ApiCore-Backend/src/storage/dto/create-stock-movement.dto.ts index 3974133..ef53adc 100644 --- a/ApiCore-Backend/src/storage/dto/create-stock-movement.dto.ts +++ b/ApiCore-Backend/src/storage/dto/create-stock-movement.dto.ts @@ -7,7 +7,11 @@ export class CreateStockMovementDto { @IsEnum(StockMovementType) type!: StockMovementType; - @ApiProperty() + @ApiProperty({ + description: + 'Für IN/OUT immer positiv angeben - die Richtung ergibt sich aus "type". ' + + 'Nur ADJUSTMENT erlaubt einen negativen Wert, um den Bestand direkt zu korrigieren.', + }) @IsNumber() quantity!: number; diff --git a/ApiCore-Backend/src/storage/storage.service.spec.ts b/ApiCore-Backend/src/storage/storage.service.spec.ts index 496def0..d0946d2 100644 --- a/ApiCore-Backend/src/storage/storage.service.spec.ts +++ b/ApiCore-Backend/src/storage/storage.service.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { StorageService } from './storage.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; @@ -100,4 +100,36 @@ describe('StorageService', () => { expect(tx.stockItem.update).not.toHaveBeenCalled(); }); + + it('addMovement rejects a negative quantity for OUT instead of silently adding stock', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + + await expect( + service.addMovement('item-1', { type: 'OUT', quantity: -3 }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(tx.stockItem.update).not.toHaveBeenCalled(); + }); + + it('addMovement rejects a negative quantity for IN', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + + await expect( + service.addMovement('item-1', { type: 'IN', quantity: -3 }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('addMovement allows a negative quantity for ADJUSTMENT', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + + await service.addMovement( + 'item-1', + { type: 'ADJUSTMENT', quantity: -3 }, + 'user-1', + ); + + const [[updateArg]] = tx.stockItem.update.mock.calls as [ + [{ data: { quantity: { increment: number } } }], + ]; + expect(updateArg.data.quantity.increment).toBe(-3); + }); }); diff --git a/ApiCore-Backend/src/storage/storage.service.ts b/ApiCore-Backend/src/storage/storage.service.ts index cf48a2d..839fef5 100644 --- a/ApiCore-Backend/src/storage/storage.service.ts +++ b/ApiCore-Backend/src/storage/storage.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; @@ -123,6 +127,16 @@ export class StorageService { actingUserId: string, ) { await this.findStockItemById(id); + // IN/OUT direction comes entirely from `type`, which then negates + // quantity for OUT below - a negative quantity submitted for OUT would + // therefore silently ADD stock while being logged as a removal, + // inverting the audit trail. Only ADJUSTMENT may be negative, since + // that is the only way to correct a stock count downward. + if (dto.type !== 'ADJUSTMENT' && dto.quantity <= 0) { + throw new BadRequestException( + 'quantity must be positive for IN/OUT movements - the direction is determined by "type"', + ); + } const delta = dto.type === 'IN' || dto.type === 'ADJUSTMENT' ? dto.quantity