Skip to content

Swaps - Troca de Itens

O sistema de Swap permite que usuários troquem itens entre si de forma segura e justa, com validação de valores e proteção contra fraudes.

Como Funciona

Modelo de Dados

prisma
model Swap {
  id              BigInt      @id @default(autoincrement())
  
  initiatorId     BigInt
  receiverId      BigInt?
  
  status          SwapStatus
  
  // Valores
  initiatorValueCents  BigInt
  receiverValueCents   BigInt?
  valueDifference      BigInt?
  
  // Metadados
  message         String?
  expiresAt       DateTime
  
  initiator       User        @relation("SwapInitiator", ...)
  receiver        User?       @relation("SwapReceiver", ...)
  initiatorItems  SwapItem[]  @relation("SwapInitiatorItems")
  receiverItems   SwapItem[]  @relation("SwapReceiverItems")
  
  createdAt       DateTime    @default(now())
  completedAt     DateTime?
  
  @@index([initiatorId])
  @@index([receiverId])
  @@index([status])
}

enum SwapStatus {
  PENDING         // Aguardando aceite
  ACCEPTED        // Aceito, processando
  COMPLETED       // Concluído
  REJECTED        // Rejeitado
  CANCELLED       // Cancelado pelo iniciador
  EXPIRED         // Expirado
}

model SwapItem {
  id              BigInt    @id @default(autoincrement())
  swapId          BigInt
  itemId          BigInt
  inventoryId     BigInt
  side            SwapSide  // INITIATOR ou RECEIVER
  valueCents      BigInt
  
  swap            Swap      @relation(...)
  item            Item      @relation(...)
  
  @@index([swapId])
}

enum SwapSide {
  INITIATOR
  RECEIVER
}

Implementação

Criar Swap

typescript
// create-swap.use-case.ts
@Injectable()
export class CreateSwapUseCase {
  async execute(
    initiatorId: bigint,
    dto: CreateSwapDto,
  ): Promise<Swap> {
    const lockKey = `user:${initiatorId}:swap-create`;
    
    return await this.redlock.using([lockKey], 30000, async () => {
      // 1. Validar itens do iniciador
      const initiatorItems = await this.validateAndLockItems(
        initiatorId,
        dto.itemIds,
      );
      
      // 2. Calcular valor total
      const initiatorValue = initiatorItems.reduce(
        (sum, item) => sum + item.valueCents,
        0n,
      );
      
      // 3. Validar limites
      if (initiatorValue < MIN_SWAP_VALUE) {
        throw new SwapValueTooLowException();
      }
      
      return await this.prisma.$transaction(async (tx) => {
        // 4. Bloquear itens
        for (const item of initiatorItems) {
          await tx.userInventory.update({
            where: { id: item.inventoryId },
            data: { status: 'LOCKED' },
          });
        }
        
        // 5. Criar swap
        const swap = await tx.swap.create({
          data: {
            initiatorId,
            status: SwapStatus.PENDING,
            initiatorValueCents: initiatorValue,
            message: dto.message,
            expiresAt: addHours(new Date(), 24), // 24h para aceitar
          },
        });
        
        // 6. Registrar itens
        for (const item of initiatorItems) {
          await tx.swapItem.create({
            data: {
              swapId: swap.id,
              itemId: item.itemId,
              inventoryId: item.inventoryId,
              side: SwapSide.INITIATOR,
              valueCents: item.valueCents,
            },
          });
        }
        
        return swap;
      });
    });
  }
}

Aceitar Swap

typescript
// accept-swap.use-case.ts
@Injectable()
export class AcceptSwapUseCase {
  async execute(
    receiverId: bigint,
    swapId: bigint,
    dto: AcceptSwapDto,
  ): Promise<Swap> {
    const lockKey = `swap:${swapId}:accept`;
    
    return await this.redlock.using([lockKey], 30000, async () => {
      // 1. Buscar swap
      const swap = await this.swapRepository.findById(swapId);
      
      // 2. Validar status
      if (swap.status !== SwapStatus.PENDING) {
        throw new SwapNotPendingException();
      }
      
      // 3. Validar expiração
      if (new Date() > swap.expiresAt) {
        await this.expireSwap(swapId);
        throw new SwapExpiredException();
      }
      
      // 4. Não pode aceitar próprio swap
      if (swap.initiatorId === receiverId) {
        throw new CannotAcceptOwnSwapException();
      }
      
      // 5. Validar itens do receiver
      const receiverItems = await this.validateAndLockItems(
        receiverId,
        dto.itemIds,
      );
      
      // 6. Calcular valor e diferença
      const receiverValue = receiverItems.reduce(
        (sum, item) => sum + item.valueCents,
        0n,
      );
      
      const difference = swap.initiatorValueCents - receiverValue;
      
      // 7. Validar diferença máxima (10%)
      const maxDifference = swap.initiatorValueCents / 10n;
      if (Math.abs(Number(difference)) > Number(maxDifference)) {
        throw new SwapValueMismatchException(
          `Diferença de valor excede 10%`,
        );
      }
      
      return await this.prisma.$transaction(async (tx) => {
        // 8. Bloquear itens do receiver
        for (const item of receiverItems) {
          await tx.userInventory.update({
            where: { id: item.inventoryId },
            data: { status: 'LOCKED' },
          });
        }
        
        // 9. Registrar itens do receiver
        for (const item of receiverItems) {
          await tx.swapItem.create({
            data: {
              swapId: swap.id,
              itemId: item.itemId,
              inventoryId: item.inventoryId,
              side: SwapSide.RECEIVER,
              valueCents: item.valueCents,
            },
          });
        }
        
        // 10. Atualizar swap
        await tx.swap.update({
          where: { id: swapId },
          data: {
            receiverId,
            receiverValueCents: receiverValue,
            valueDifference: difference,
            status: SwapStatus.ACCEPTED,
          },
        });
        
        // 11. Executar troca
        return await this.executeSwap(tx, swap.id);
      });
    });
  }
  
  private async executeSwap(tx: PrismaTransaction, swapId: bigint): Promise<Swap> {
    const swap = await tx.swap.findUnique({
      where: { id: swapId },
      include: {
        initiatorItems: { include: { item: true } },
        receiverItems: { include: { item: true } },
      },
    });
    
    // Mover itens do iniciador para receiver
    for (const swapItem of swap.initiatorItems) {
      await tx.userInventory.update({
        where: { id: swapItem.inventoryId },
        data: {
          userId: swap.receiverId,
          status: 'AVAILABLE',
        },
      });
    }
    
    // Mover itens do receiver para iniciador
    for (const swapItem of swap.receiverItems) {
      await tx.userInventory.update({
        where: { id: swapItem.inventoryId },
        data: {
          userId: swap.initiatorId,
          status: 'AVAILABLE',
        },
      });
    }
    
    // Finalizar swap
    return await tx.swap.update({
      where: { id: swapId },
      data: {
        status: SwapStatus.COMPLETED,
        completedAt: new Date(),
      },
    });
  }
}

Rejeitar/Cancelar Swap

typescript
// reject-swap.use-case.ts
async execute(userId: bigint, swapId: bigint): Promise<Swap> {
  const swap = await this.swapRepository.findById(swapId);
  
  // Validar que user pode rejeitar
  if (swap.initiatorId !== userId && swap.receiverId !== userId) {
    throw new UnauthorizedException();
  }
  
  // Validar status
  if (!['PENDING', 'ACCEPTED'].includes(swap.status)) {
    throw new InvalidSwapStatusException();
  }
  
  return await this.prisma.$transaction(async (tx) => {
    // Desbloquear itens do iniciador
    for (const item of swap.initiatorItems) {
      await tx.userInventory.update({
        where: { id: item.inventoryId },
        data: { status: 'AVAILABLE' },
      });
    }
    
    // Desbloquear itens do receiver (se houver)
    for (const item of swap.receiverItems) {
      await tx.userInventory.update({
        where: { id: item.inventoryId },
        data: { status: 'AVAILABLE' },
      });
    }
    
    // Atualizar status
    const newStatus = swap.initiatorId === userId
      ? SwapStatus.CANCELLED
      : SwapStatus.REJECTED;
    
    return await tx.swap.update({
      where: { id: swapId },
      data: { status: newStatus },
    });
  });
}

Frontend

Tela de Criação de Swap

tsx
const CreateSwapPage = () => {
  const [selectedItems, setSelectedItems] = useState<Item[]>([]);
  const [message, setMessage] = useState('');
  const { inventory } = useInventory();
  
  const totalValue = selectedItems.reduce(
    (sum, item) => sum + item.valueCents,
    0,
  );
  
  const handleCreate = async () => {
    const result = await api.post('/swaps', {
      itemIds: selectedItems.map((i) => i.id),
      message,
    });
    
    router.push(`/swaps/${result.data.id}`);
  };
  
  return (
    <div className="create-swap">
      <h1>Criar Troca</h1>
      
      {/* Inventário do usuário */}
      <div className="inventory-grid">
        {inventory.map((item) => (
          <ItemCard
            key={item.id}
            item={item}
            selected={selectedItems.includes(item)}
            onClick={() => toggleItem(item)}
          />
        ))}
      </div>
      
      {/* Itens selecionados */}
      <div className="selected-items">
        <h3>Itens para troca</h3>
        <div className="items-row">
          {selectedItems.map((item) => (
            <ItemCard key={item.id} item={item} />
          ))}
        </div>
        <p>Valor total: R$ {(totalValue / 100).toFixed(2)}</p>
      </div>
      
      {/* Mensagem opcional */}
      <textarea
        placeholder="Mensagem para o outro usuário..."
        value={message}
        onChange={(e) => setMessage(e.target.value)}
      />
      
      <Button onClick={handleCreate} disabled={selectedItems.length === 0}>
        Criar Troca
      </Button>
    </div>
  );
};

Tela de Aceite

tsx
const AcceptSwapPage = ({ swap }: { swap: Swap }) => {
  const [myItems, setMyItems] = useState<Item[]>([]);
  const { inventory } = useInventory();
  
  const myValue = myItems.reduce((sum, item) => sum + item.valueCents, 0);
  const difference = swap.initiatorValueCents - myValue;
  const percentDiff = (Number(difference) / Number(swap.initiatorValueCents)) * 100;
  
  const isValidDifference = Math.abs(percentDiff) <= 10;
  
  return (
    <div className="accept-swap">
      {/* Itens do iniciador */}
      <div className="initiator-items">
        <h3>{swap.initiator.username} oferece:</h3>
        <div className="items-row">
          {swap.initiatorItems.map((swapItem) => (
            <ItemCard key={swapItem.id} item={swapItem.item} />
          ))}
        </div>
        <p>Valor: R$ {(swap.initiatorValueCents / 100).toFixed(2)}</p>
      </div>
      
      {/* Meus itens */}
      <div className="my-items">
        <h3>Seus itens:</h3>
        <div className="inventory-grid">
          {inventory.map((item) => (
            <ItemCard
              key={item.id}
              item={item}
              selected={myItems.includes(item)}
              onClick={() => toggleItem(item)}
            />
          ))}
        </div>
        <p>Valor: R$ {(myValue / 100).toFixed(2)}</p>
      </div>
      
      {/* Diferença */}
      <div className={cn(
        "difference",
        isValidDifference ? "text-green-500" : "text-red-500"
      )}>
        <p>Diferença: {percentDiff.toFixed(1)}%</p>
        {!isValidDifference && (
          <p className="text-sm">Diferença máxima permitida: 10%</p>
        )}
      </div>
      
      <div className="actions">
        <Button variant="destructive" onClick={() => rejectSwap(swap.id)}>
          Rejeitar
        </Button>
        <Button
          onClick={() => acceptSwap(swap.id, myItems)}
          disabled={!isValidDifference || myItems.length === 0}
        >
          Aceitar Troca
        </Button>
      </div>
    </div>
  );
};

Endpoints da API

Criar Swap

http
POST /api/swaps
Authorization: Bearer {sessionId}
Content-Type: application/json

{
  "itemIds": ["123", "456"],
  "message": "Troca justa!"
}

Listar Swaps

http
GET /api/swaps?status=PENDING&page=1

Aceitar Swap

http
POST /api/swaps/:id/accept
Content-Type: application/json

{
  "itemIds": ["789", "101"]
}

Rejeitar/Cancelar

http
POST /api/swaps/:id/reject
http
POST /api/swaps/:id/cancel

Validações

typescript
// swap-validation.service.ts
validateSwap(
  initiatorItems: Item[],
  receiverItems: Item[],
): ValidationResult {
  const errors: string[] = [];
  
  // 1. Mínimo de itens
  if (initiatorItems.length === 0 || receiverItems.length === 0) {
    errors.push('Ambos os lados devem ter pelo menos 1 item');
  }
  
  // 2. Valor mínimo
  const MIN_VALUE = 1000n; // R$ 10
  const initiatorValue = sum(initiatorItems);
  const receiverValue = sum(receiverItems);
  
  if (initiatorValue < MIN_VALUE || receiverValue < MIN_VALUE) {
    errors.push('Valor mínimo de R$ 10 por lado');
  }
  
  // 3. Diferença máxima (10%)
  const difference = initiatorValue - receiverValue;
  const maxDiff = initiatorValue / 10n;
  
  if (Math.abs(Number(difference)) > Number(maxDiff)) {
    errors.push('Diferença de valor não pode exceder 10%');
  }
  
  // 4. Itens disponíveis
  const unavailable = [...initiatorItems, ...receiverItems].filter(
    (i) => i.status !== 'AVAILABLE',
  );
  
  if (unavailable.length > 0) {
    errors.push('Alguns itens não estão disponíveis');
  }
  
  return { valid: errors.length === 0, errors };
}

Expiração Automática

typescript
// swap-expiration.scheduler.ts
@Cron('*/5 * * * *') // A cada 5 minutos
async expireOldSwaps() {
  const expiredSwaps = await this.swapRepository.findExpired();
  
  for (const swap of expiredSwaps) {
    await this.prisma.$transaction(async (tx) => {
      // Desbloquear itens
      for (const item of swap.initiatorItems) {
        await tx.userInventory.update({
          where: { id: item.inventoryId },
          data: { status: 'AVAILABLE' },
        });
      }
      
      // Atualizar status
      await tx.swap.update({
        where: { id: swap.id },
        data: { status: SwapStatus.EXPIRED },
      });
    });
    
    // Notificar iniciador
    await this.notificationService.send(swap.initiatorId, {
      type: 'SWAP_EXPIRED',
      message: 'Sua proposta de troca expirou',
    });
  }
}

Troubleshooting

Itens Travados

typescript
// Verificar itens com status LOCKED sem swap ativo
const lockedItems = await inventoryRepository.findByStatus('LOCKED');

for (const item of lockedItems) {
  const swap = await swapRepository.findByInventoryId(item.id);
  
  if (!swap || ['COMPLETED', 'REJECTED', 'CANCELLED', 'EXPIRED'].includes(swap.status)) {
    // Desbloquear item órfão
    await inventoryRepository.update(item.id, { status: 'AVAILABLE' });
    console.log(`Desbloqueado item órfão: ${item.id}`);
  }
}

Documentação Técnica CSGOFlip