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 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-09 11:01:45 +02:00
1 parent 8bf9aa467d
commit 93d0f2b208
3 files changed
+53 -3

No files matched your search

@@ -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;
@@ -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);
});
});
+15 -1
View File
@@ -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