Skip to content

Sistema de Swap - Troca de Itens

Documentacao completa do sistema de Swap do ProCases. O sistema possui dois modos de operacao independentes:

  1. Swap entre usuarios (User-to-User): Criacao, aceite, recusa e cancelamento de propostas de troca de itens entre jogadores.
  2. Swap com o site (Site Swap via Waxpeer): O usuario entrega itens do inventario e recebe um item do marketplace Waxpeer com preco em tempo real.

Indice

  1. Modelo de Dados
  2. Diagrama de Fluxo
  3. Status do Inventario
  4. Sistema de Locks
  5. Endpoints da API
  6. Regras de Negocio
  7. Calculo de Precos (Site Swap)
  8. Prevencao de Deadlocks
  9. Edge Cases e Cenarios Criticos
  10. Exemplos Numericos
  11. Arquivos do Sistema
  12. Debugging e Troubleshooting

Modelo de Dados

Enum SwapStatus

prisma
enum SwapStatus {
  PENDING       // Aguardando aceite do outro usuario
  ACCEPTED      // Troca concluida com sucesso
  DECLINED      // Recusada pelo acceptor
  CANCELLED     // Cancelada pelo creator
  EXPIRED       // Expirou apos 24 horas sem aceite
}

Localizacao: prisma/schema.prisma (linha 697)

Enum InventoryItemStatus

prisma
enum InventoryItemStatus {
  AVAILABLE         // Disponivel para uso
  LOCKED            // Bloqueado (saque pendente)
  SOLD              // Vendido por saldo
  WITHDRAWN         // Sacado via Steam
  USED_IN_UPGRADE   // Consumido em upgrade
  USED_IN_BATTLE    // Consumido em batalha
  IN_SWAP           // Reservado em proposta de swap ativa
}

Localizacao: prisma/schema.prisma (linha 414)

Tabela Swap (User-to-User)

prisma
model Swap {
  id                BigInt      @id
  creatorId         BigInt      @map("creator_id")
  acceptorId        BigInt?     @map("acceptor_id")
  status            SwapStatus  @default(PENDING)
  creatorValueCents BigInt      @map("creator_value_cents")
  acceptorValueCents BigInt?    @map("acceptor_value_cents")
  isPublic          Boolean     @default(false) @map("is_public")
  message           String?
  expiresAt         DateTime    @map("expires_at")
  createdAt         DateTime    @default(now()) @map("created_at")
  completedAt       DateTime?   @map("completed_at")

  creator           User        @relation("SwapCreator", fields: [creatorId], references: [id])
  acceptor          User?       @relation("SwapAcceptor", fields: [acceptorId], references: [id])
  items             SwapItem[]

  @@map("swaps")
  @@index([creatorId, status])
  @@index([acceptorId, status])
  @@index([status, createdAt])
  @@index([isPublic, status])
  @@index([expiresAt])
}

Localizacao: prisma/schema.prisma (linha 705)

Campos importantes:

CampoTipoDescricao
idBigInt (Snowflake)Identificador unico gerado pelo SnowflakeService
creatorIdBigIntID do usuario que criou a proposta
acceptorIdBigInt?ID do acceptor designado (null = swap publico)
statusSwapStatusEstado atual do swap
creatorValueCentsBigIntSoma dos valores dos itens do creator em centavos BRL
acceptorValueCentsBigInt?Soma dos valores dos itens do acceptor em centavos BRL (preenchido no aceite)
isPublicBooleanSe true, qualquer usuario pode aceitar
messageString?Mensagem opcional do creator
expiresAtDateTimeData de expiracao (createdAt + 24 horas)
completedAtDateTime?Timestamp de quando o swap foi aceito/concluido

Tabela SwapItem

prisma
model SwapItem {
  id              BigInt   @id
  swapId          BigInt   @map("swap_id")
  inventoryItemId BigInt   @map("inventory_item_id")
  isCreatorItem   Boolean  @map("is_creator_item")
  itemValueCents  BigInt   @map("item_value_cents")

  swap            Swap          @relation(fields: [swapId], references: [id], onDelete: Cascade)
  inventoryItem   UserInventory @relation(fields: [inventoryItemId], references: [id])

  @@map("swap_items")
  @@index([swapId])
  @@index([inventoryItemId])
}

Localizacao: prisma/schema.prisma (linha 730)

Campos importantes:

CampoTipoDescricao
idBigInt (Snowflake)Identificador unico
swapIdBigIntFK para o swap
inventoryItemIdBigIntFK para o UserInventory
isCreatorItemBooleantrue = item do creator, false = item do acceptor
itemValueCentsBigIntSnapshot do valor em centavos BRL no momento da criacao

O campo isCreatorItem e fundamental para distinguir de qual lado da troca o item pertence. Na criacao do swap, os itens sao registrados com isCreatorItem = true. No aceite, os itens do acceptor sao registrados com isCreatorItem = false.

Tabela SiteSwap (Waxpeer)

prisma
model SiteSwap {
  id                    BigInt   @id
  userId                BigInt   @map("user_id")
  targetItemId          BigInt   @map("target_item_id")
  targetItemValueCents  BigInt   @map("target_item_value_cents")
  totalOfferedValueCents BigInt  @map("total_offered_value_cents")
  createdAt             DateTime @default(now()) @map("created_at")

  user                  User           @relation(fields: [userId], references: [id])
  targetItem            Item           @relation(fields: [targetItemId], references: [id])
  items                 SiteSwapItem[]

  @@map("site_swaps")
  @@index([userId])
  @@index([createdAt])
  @@index([targetItemValueCents])
}

Localizacao: prisma/schema.prisma (linha 745)

Campos importantes:

CampoTipoDescricao
idBigInt (Snowflake)Identificador unico
userIdBigIntID do usuario que fez o swap
targetItemIdBigIntFK para o Item que o usuario recebeu
targetItemValueCentsBigIntValor do item alvo em centavos BRL (com margem dinamica via PriceMarginService)
totalOfferedValueCentsBigIntSoma dos itens oferecidos pelo usuario em centavos BRL

Tabela SiteSwapItem

prisma
model SiteSwapItem {
  id              BigInt   @id
  siteSwapId      BigInt   @map("site_swap_id")
  userInventoryId BigInt   @map("user_inventory_id")
  itemId          BigInt   @map("item_id")
  itemValueCents  BigInt   @map("item_value_cents")
  createdAt       DateTime @default(now()) @map("created_at")

  siteSwap        SiteSwap      @relation(fields: [siteSwapId], references: [id], onDelete: Cascade)
  userInventory   UserInventory @relation(fields: [userInventoryId], references: [id])
  item            Item          @relation(fields: [itemId], references: [id])

  @@map("site_swap_items")
  @@index([siteSwapId])
  @@index([userInventoryId])
}

Localizacao: prisma/schema.prisma (linha 763)

Campos importantes:

CampoTipoDescricao
idBigInt (Snowflake)Identificador unico
siteSwapIdBigIntFK para o SiteSwap
userInventoryIdBigIntFK para o item do inventario do usuario
itemIdBigIntFK para o Item base
itemValueCentsBigIntSnapshot do valor em centavos BRL

Diagrama de Fluxo

Swap User-to-User

                    CRIACAO                          ACEITE
                    -------                          ------

  Creator                  Backend                  Acceptor
    |                        |                         |
    |-- POST /swap --------->|                         |
    |                        |-- Lock user:balance     |
    |                        |-- Valida itens          |
    |                        |-- Calcula totalValue    |
    |                        |-- Marca itens IN_SWAP   |
    |                        |-- Cria Swap PENDING     |
    |                        |-- Cria SwapItems        |
    |<-- Swap criado --------|                         |
    |                        |                         |
    |                        |<-- PUT /swap/:id/accept-|
    |                        |                         |
    |                        |-- Lock swap:{id}        |
    |                        |-- Lock user:balance (menor ID primeiro)
    |                        |-- Lock user:balance (maior ID segundo)
    |                        |                         |
    |                        |-- Re-fetch swap (PENDING?)
    |                        |-- Valida itens acceptor |
    |                        |-- Calcula totalValue    |
    |                        |                         |
    |                        |-- TRANSACTION BEGIN     |
    |                        |   setAcceptor()         |
    |                        |   Marca itens IN_SWAP   |
    |                        |   Cria SwapItems        |
    |                        |   Transfer creator->acceptor
    |                        |   Transfer acceptor->creator
    |                        |   Status = ACCEPTED     |
    |                        |-- TRANSACTION COMMIT    |
    |                        |                         |
    |                        |-- Swap aceito --------->|

Swap com Site (Waxpeer)

  Usuario                  Backend                  Waxpeer
    |                        |                         |
    |-- POST /swap/site ---->|                         |
    |                        |-- getItemPrice() ------>|
    |                        |<-- priceCentsUsd -------|
    |                        |                         |
    |                        |-- Aplica margem dinamica|
    |                        |-- Converte USD -> BRL   |
    |                        |                         |
    |                        |-- Lock user:balance     |
    |                        |-- TRANSACTION BEGIN     |
    |                        |   Valida itens          |
    |                        |   Calcula total user    |
    |                        |   Valida range valor    |
    |                        |   Find/create item DB   |
    |                        |   Marca itens IN_SWAP   |
    |                        |   Cria inventory AVAILABLE
    |                        |   Cria SiteSwap         |
    |                        |   Cria SiteSwapItems    |
    |                        |   Credita refund (diff) |
    |                        |-- TRANSACTION COMMIT    |
    |                        |                         |
    |                        |-- setTimeout(3s)        |
    |                        |   emitLiveDrop()        |
    |                        |                         |
    |<-- resultado -----------|                         |

Status do Inventario

Transicoes de Status dos Itens durante Swaps

SWAP USER-TO-USER:

  Criar Swap:
    Creator items:  AVAILABLE --> IN_SWAP

  Aceitar Swap:
    Acceptor items: AVAILABLE --> IN_SWAP
    Creator items:  IN_SWAP --> AVAILABLE (novo dono: acceptor)
    Acceptor items: IN_SWAP --> AVAILABLE (novo dono: creator)

  Recusar Swap (Decline):
    Creator items:  IN_SWAP --> AVAILABLE (devolvidos ao creator)
    Acceptor items: (nao afetados, nunca foram commitados)

  Cancelar Swap (Cancel):
    Creator items:  IN_SWAP --> AVAILABLE (devolvidos ao creator)
    Acceptor items: IN_SWAP --> AVAILABLE (devolvidos ao acceptor, se existirem)


SWAP COM SITE (WAXPEER):

  Perform Site Swap:
    User items:     AVAILABLE --> IN_SWAP (consumidos)
    Novo item:      AVAILABLE (criado, acquiredFrom = 'Swap')

Diferenca critica entre Cancel e Decline:

  • Cancel: Executado pelo creator. Devolve itens de AMBOS os lados (creator e acceptor), caso o acceptor ja tenha adicionado itens.
  • Decline: Executado pelo acceptor (ou qualquer usuario em swaps publicos). Devolve APENAS os itens do creator. O acceptor nunca chega a commitar itens no decline.

Sistema de Locks

O sistema de Swap utiliza distributed locks via Redlock para prevenir race conditions.

Configuracao do Redlock

typescript
// src/infrastructure/locks/lock.service.ts
this.redlock = new Redlock([client], {
  driftFactor: 0.01,
  retryCount: 10,
  retryDelay: 200,       // ms
  retryJitter: 200,      // ms
  automaticExtensionThreshold: 500, // ms
});
ParametroValorDescricao
retryCount10Numero maximo de tentativas para adquirir o lock
retryDelay200msTempo base entre tentativas
retryJitter200msVariacao aleatoria adicionada ao delay
automaticExtensionThreshold500msExtensao automatica se o lock esta proximo de expirar

Locks por Operacao

OperacaoLock(s)TTLDescricao
Create Swaplock:user:balance:{userId}10sPrevine operacoes concorrentes no inventario do creator
Accept Swaplock:swap:{swapId} + lock:user:balance:{firstId} + lock:user:balance:{secondId}5s + 10s + 10sLock triplo com ordenacao para prevenir deadlocks
Site Swaplock:user:balance:{userId}10sPrevine operacoes concorrentes no inventario do usuario
Cancel SwapNenhum (Prisma transaction)-Apenas transaction do Prisma, sem distributed lock
Decline SwapNenhum (Prisma transaction)-Apenas transaction do Prisma, sem distributed lock

Ordenacao de Locks no Accept (Prevencao de Deadlocks)

O accept-swap adquire tres locks em sequencia rigorosa. A ordenacao dos user balance locks e feita pelo userId em ordem crescente (ASC) para garantir que dois swaps simultaneos entre os mesmos usuarios nunca causem deadlock:

typescript
// src/application/use-cases/swap/accept-swap.use-case.ts
const [firstUserId, secondUserId] = [userId, swap.creatorId].sort((a, b) =>
  a < b ? -1 : a > b ? 1 : 0,
);

return this.lockService.withLock(`swap:${swapId}`, async () => {
  return this.lockService.withUserBalanceLock(firstUserId, async () => {
    return this.lockService.withUserBalanceLock(secondUserId, async () => {
      // operacao segura aqui
    });
  });
});

Exemplo de prevencao:

Cenario: Usuario A (id=100) e Usuario B (id=200) tentam aceitar swaps um do outro ao mesmo tempo.

Sem ordenacao (DEADLOCK):

Thread 1: lock(user:100) --> tentando lock(user:200) [BLOCKED]
Thread 2: lock(user:200) --> tentando lock(user:100) [BLOCKED]
= DEADLOCK

Com ordenacao (SEGURO):

Thread 1: lock(swap:X) --> lock(user:100) --> lock(user:200) [OK]
Thread 2: lock(swap:Y) --> tentando lock(user:100) [ESPERA] --> lock(user:200) [OK]
= SEM DEADLOCK (Thread 2 espera Thread 1 terminar)

A regra e: sempre adquirir locks de usuario na ordem crescente de userId.


Endpoints da API

Todos os endpoints estao sob o prefixo /swap e requerem autenticacao (exceto GET /swap/targets).

Controller: src/presentation/controllers/swap.controller.ts

1. POST /swap - Criar Swap

Cria uma nova proposta de troca de itens. O swap pode ser privado (direcionado a um usuario especifico) ou publico (qualquer usuario pode aceitar).

Autenticacao: Obrigatoria (AuthGuard)

Use Case: src/application/use-cases/swap/create-swap.use-case.ts

Request

http
POST /swap
Content-Type: application/json
Cookie: sessionId=abc123

{
  "inventoryItemIds": ["839201847382", "839201847383"],
  "acceptorId": "738291038271",
  "isPublic": false,
  "message": "Troca justa, aceita ai"
}
CampoTipoObrigatorioDescricao
inventoryItemIdsstring[]SimIDs dos itens do inventario (1 a 10 itens)
acceptorIdstringCondicionalObrigatorio se isPublic = false
isPublicbooleanSimSe true, qualquer usuario pode aceitar
messagestringNaoMensagem opcional para o acceptor

Validacoes

  1. Quantidade de itens: Minimo 1, maximo 10.
  2. Swap privado: Se isPublic = false, o campo acceptorId e obrigatorio.
  3. Auto-swap: O acceptorId nao pode ser igual ao userId do creator.
  4. Existencia dos itens: Todos os inventoryItemIds devem existir no banco.
  5. Propriedade: Todos os itens devem pertencer ao userId autenticado.
  6. Disponibilidade: Todos os itens devem ter status AVAILABLE.

Logica de Execucao

typescript
// create-swap.use-case.ts (simplificado)

async execute(userId, inventoryItemIds, acceptorId?, isPublic, message?) {
  // 1. Validacoes de input
  if (inventoryItemIds.length === 0 || inventoryItemIds.length > 10) {
    throw new BadRequestException('Must select between 1 and 10 items');
  }
  if (!isPublic && !acceptorId) {
    throw new BadRequestException('Private swaps must specify an acceptor');
  }
  if (acceptorId && acceptorId === userId) {
    throw new BadRequestException('Cannot create swap with yourself');
  }

  // 2. Adquire lock do usuario
  return this.lockService.withUserBalanceLock(userId, async () => {
    // 3. Busca e valida cada item
    const items = await Promise.all(
      inventoryItemIds.map((id) => this.inventoryRepository.findById(id)),
    );

    for (const item of items) {
      if (!item) throw new NotFoundException('One or more items not found');
      if (item.userId !== userId) throw new BadRequestException('You do not own all selected items');
      if (item.status !== InventoryItemStatus.AVAILABLE) {
        throw new BadRequestException('One or more items are not available');
      }
    }

    // 4. Calcula valor total em centavos BRL
    const totalValueCents = items.reduce(
      (sum, item) => sum + item.item.valueCentsBrl,
      BigInt(0),
    );

    // 5. Define expiracao (24 horas)
    const expiresAt = new Date();
    expiresAt.setHours(expiresAt.getHours() + 24);

    // 6. Cria o swap no banco
    const swap = await this.swapRepository.create({
      creatorId: userId,
      acceptorId,
      creatorValueCents: totalValueCents,
      isPublic,
      message,
      expiresAt,
    });

    // 7. Marca itens como IN_SWAP e registra SwapItems
    for (const item of items) {
      await this.inventoryRepository.updateStatus(item.id, InventoryItemStatus.IN_SWAP);
      await this.swapRepository.addItem({
        swapId: swap.id,
        inventoryItemId: item.id,
        isCreatorItem: true,
        itemValueCents: item.item.valueCentsBrl,
      });
    }

    // 8. Retorna swap completo com relations
    return this.swapRepository.findById(swap.id);
  });
}

Response (200 OK)

json
{
  "success": true,
  "swap": {
    "id": "839201847500",
    "creatorId": "738291038270",
    "acceptorId": "738291038271",
    "status": "PENDING",
    "creatorValueCents": "125000",
    "acceptorValueCents": null,
    "isPublic": false,
    "message": "Troca justa, aceita ai",
    "expiresAt": "2026-02-12T14:30:00.000Z",
    "createdAt": "2026-02-11T14:30:00.000Z",
    "completedAt": null,
    "creator": {
      "id": "738291038270",
      "username": "SkinMaster",
      "avatarUrl": "https://..."
    },
    "acceptor": {
      "id": "738291038271",
      "username": "TradeKing",
      "avatarUrl": "https://..."
    },
    "items": [
      {
        "id": "839201847501",
        "swapId": "839201847500",
        "inventoryItemId": "839201847382",
        "isCreatorItem": true,
        "itemValueCents": "75000",
        "inventoryItem": {
          "id": "839201847382",
          "userId": "738291038270",
          "itemId": "100001",
          "status": "IN_SWAP",
          "item": {
            "id": "100001",
            "name": "AK-47 | Redline",
            "marketHashName": "AK-47 | Redline (Field-Tested)",
            "imageUrl": "https://...",
            "rarity": "CLASSIFIED",
            "valueCentsBrl": "75000"
          }
        }
      },
      {
        "id": "839201847502",
        "swapId": "839201847500",
        "inventoryItemId": "839201847383",
        "isCreatorItem": true,
        "itemValueCents": "50000",
        "inventoryItem": {
          "id": "839201847383",
          "userId": "738291038270",
          "itemId": "100002",
          "status": "IN_SWAP",
          "item": {
            "id": "100002",
            "name": "USP-S | Kill Confirmed",
            "marketHashName": "USP-S | Kill Confirmed (Minimal Wear)",
            "imageUrl": "https://...",
            "rarity": "COVERT",
            "valueCentsBrl": "50000"
          }
        }
      }
    ]
  }
}

Erros Possiveis

StatusMensagemCausa
400Must select between 1 and 10 itemsNenhum item selecionado ou mais de 10
400Private swaps must specify an acceptorSwap privado sem acceptorId
400Cannot create swap with yourselfacceptorId === userId
400You do not own all selected itemsItem pertence a outro usuario
400One or more items are not availableItem com status diferente de AVAILABLE
404One or more items not foundID de item inexistente
404Item details not foundInventory item sem relacao com Item

2. PUT /swap/:id/accept - Aceitar Swap

Aceita uma proposta de swap existente, realizando a transferencia atomica de itens entre os dois usuarios.

Autenticacao: Obrigatoria (AuthGuard)

Use Case: src/application/use-cases/swap/accept-swap.use-case.ts

Request

http
PUT /swap/839201847500/accept
Content-Type: application/json
Cookie: sessionId=def456

{
  "inventoryItemIds": ["839201847400", "839201847401"]
}
CampoTipoObrigatorioDescricao
inventoryItemIdsstring[]SimIDs dos itens do acceptor para trocar

Validacoes (Pre-Lock)

  1. Existencia do swap: O swap deve existir no banco.
  2. Status PENDING: O swap deve estar com status PENDING.
  3. Nao expirado: expiresAt deve ser maior que a data atual.
  4. Autorizacao: Se o swap for privado (acceptorId != null), apenas o acceptor designado pode aceitar.
  5. Auto-aceite: O creator nao pode aceitar o proprio swap.

Validacoes (Dentro do Lock)

  1. Re-fetch: O swap e buscado novamente dentro do lock para garantir que ainda esta PENDING.
  2. Itens do acceptor: Todos devem existir, pertencer ao userId e estar AVAILABLE.

Logica de Execucao

typescript
// accept-swap.use-case.ts (simplificado)

async execute(userId, swapId, inventoryItemIds) {
  // 1. Busca o swap
  const swap = await this.swapRepository.findById(swapId);
  if (!swap) throw new NotFoundException('Swap not found');

  // 2. Validacoes pre-lock
  if (swap.status !== SwapStatus.PENDING) {
    throw new BadRequestException('Swap is not pending');
  }
  if (swap.expiresAt < new Date()) {
    throw new BadRequestException('Swap has expired');
  }
  if (swap.acceptorId && swap.acceptorId !== userId) {
    throw new ForbiddenException('This swap is for another user');
  }
  if (swap.creatorId === userId) {
    throw new BadRequestException('Cannot accept your own swap');
  }

  // 3. Ordenacao de locks para prevenir deadlocks
  const [firstUserId, secondUserId] = [userId, swap.creatorId].sort((a, b) =>
    a < b ? -1 : a > b ? 1 : 0,
  );

  // 4. Adquire locks na ordem: swap -> primeiro user -> segundo user
  return this.lockService.withLock(`swap:${swapId}`, async () => {
    return this.lockService.withUserBalanceLock(firstUserId, async () => {
      return this.lockService.withUserBalanceLock(secondUserId, async () => {

        // 5. Re-fetch dentro do lock (double-check)
        const freshSwap = await this.swapRepository.findById(swapId);
        if (!freshSwap || freshSwap.status !== SwapStatus.PENDING) {
          throw new BadRequestException('Swap is no longer available');
        }

        // 6. Valida itens do acceptor
        const items = await Promise.all(
          inventoryItemIds.map((id) => this.inventoryRepository.findById(id)),
        );
        for (const item of items) {
          if (!item) throw new NotFoundException('One or more items not found');
          if (item.userId !== userId) throw new BadRequestException('...');
          if (item.status !== InventoryItemStatus.AVAILABLE) throw new BadRequestException('...');
        }

        // 7. Calcula valor total do acceptor
        const totalValueCents = items.reduce(
          (sum, item) => sum + item.item.valueCentsBrl,
          BigInt(0),
        );

        // 8. Transaction atomica
        return this.prisma.$transaction(async (tx) => {
          // 8a. Define acceptor e valor no swap
          await this.swapRepository.setAcceptor(swapId, userId, totalValueCents);

          // 8b. Marca itens do acceptor como IN_SWAP e cria SwapItems
          for (const item of items) {
            await this.inventoryRepository.updateStatus(item.id, InventoryItemStatus.IN_SWAP);
            await this.swapRepository.addItem({
              swapId,
              inventoryItemId: item.id,
              isCreatorItem: false,
              itemValueCents: item.item.valueCentsBrl,
            });
          }

          // 8c. Transfere itens do creator para o acceptor
          const creatorItems = freshSwap.items.filter((i) => i.isCreatorItem);
          for (const swapItem of creatorItems) {
            await this.inventoryRepository.updateOwner(
              swapItem.inventoryItemId,
              userId,                         // novo dono = acceptor
              InventoryItemStatus.AVAILABLE,
              'Swap',
            );
          }

          // 8d. Transfere itens do acceptor para o creator
          for (const item of items) {
            await this.inventoryRepository.updateOwner(
              item.id,
              freshSwap.creatorId,            // novo dono = creator
              InventoryItemStatus.AVAILABLE,
              'Swap',
            );
          }

          // 8e. Marca swap como ACCEPTED
          await this.swapRepository.updateStatus(swapId, SwapStatus.ACCEPTED, new Date());

          return this.swapRepository.findById(swapId);
        });
      });
    });
  });
}

Response (200 OK)

json
{
  "success": true,
  "swap": {
    "id": "839201847500",
    "creatorId": "738291038270",
    "acceptorId": "738291038271",
    "status": "ACCEPTED",
    "creatorValueCents": "125000",
    "acceptorValueCents": "118000",
    "isPublic": false,
    "message": "Troca justa, aceita ai",
    "expiresAt": "2026-02-12T14:30:00.000Z",
    "createdAt": "2026-02-11T14:30:00.000Z",
    "completedAt": "2026-02-11T15:45:00.000Z",
    "creator": {
      "id": "738291038270",
      "username": "SkinMaster",
      "avatarUrl": "https://..."
    },
    "acceptor": {
      "id": "738291038271",
      "username": "TradeKing",
      "avatarUrl": "https://..."
    },
    "items": [
      {
        "id": "839201847501",
        "swapId": "839201847500",
        "inventoryItemId": "839201847382",
        "isCreatorItem": true,
        "itemValueCents": "75000",
        "inventoryItem": {
          "id": "839201847382",
          "userId": "738291038271",
          "status": "AVAILABLE",
          "item": { "name": "AK-47 | Redline", "rarity": "CLASSIFIED" }
        }
      },
      {
        "id": "839201847502",
        "swapId": "839201847500",
        "inventoryItemId": "839201847383",
        "isCreatorItem": true,
        "itemValueCents": "50000",
        "inventoryItem": {
          "id": "839201847383",
          "userId": "738291038271",
          "status": "AVAILABLE",
          "item": { "name": "USP-S | Kill Confirmed", "rarity": "COVERT" }
        }
      },
      {
        "id": "839201847503",
        "swapId": "839201847500",
        "inventoryItemId": "839201847400",
        "isCreatorItem": false,
        "itemValueCents": "68000",
        "inventoryItem": {
          "id": "839201847400",
          "userId": "738291038270",
          "status": "AVAILABLE",
          "item": { "name": "M4A4 | Howl", "rarity": "CONTRABAND" }
        }
      },
      {
        "id": "839201847504",
        "swapId": "839201847500",
        "inventoryItemId": "839201847401",
        "isCreatorItem": false,
        "itemValueCents": "50000",
        "inventoryItem": {
          "id": "839201847401",
          "userId": "738291038270",
          "status": "AVAILABLE",
          "item": { "name": "Glock-18 | Fade", "rarity": "COVERT" }
        }
      }
    ]
  }
}

Apos o aceite, os itens do creator (isCreatorItem: true) agora pertencem ao acceptor (userId mudou), e vice-versa. Ambos retornam ao status AVAILABLE.

Erros Possiveis

StatusMensagemCausa
400Swap is not pendingSwap ja foi aceito/cancelado/recusado
400Swap has expiredexpiresAt < now
400Cannot accept your own swapCreator tentando aceitar
400Swap is no longer availableOutro usuario aceitou primeiro (re-fetch)
400You do not own all selected itemsItem nao pertence ao acceptor
400One or more items are not availableItem com status diferente de AVAILABLE
403This swap is for another userSwap privado para outro acceptor
404Swap not foundID inexistente
404One or more items not foundID de item inexistente

3. PUT /swap/:id/cancel - Cancelar Swap

Cancela um swap criado pelo usuario. Apenas o creator pode cancelar. Devolve todos os itens (creator e acceptor) para o status AVAILABLE.

Autenticacao: Obrigatoria (AuthGuard)

Use Case: src/application/use-cases/swap/cancel-swap.use-case.ts

Request

http
PUT /swap/839201847500/cancel
Cookie: sessionId=abc123

Nenhum body necessario.

Validacoes

  1. Existencia do swap: O swap deve existir.
  2. Autorizacao: Apenas o creator pode cancelar (swap.creatorId === userId).
  3. Status PENDING: Apenas swaps com status PENDING podem ser cancelados.

Logica de Execucao

typescript
// cancel-swap.use-case.ts (simplificado)

async execute(userId, swapId) {
  const swap = await this.swapRepository.findById(swapId);
  if (!swap) throw new NotFoundException('Swap not found');

  if (swap.creatorId !== userId) {
    throw new ForbiddenException('Only the creator can cancel this swap');
  }
  if (swap.status !== SwapStatus.PENDING) {
    throw new BadRequestException('Can only cancel pending swaps');
  }

  return this.prisma.$transaction(async (tx) => {
    // Devolve itens do creator para AVAILABLE
    const creatorItems = swap.items?.filter((item) => item.isCreatorItem) || [];
    for (const swapItem of creatorItems) {
      await this.inventoryRepository.updateStatus(
        swapItem.inventoryItemId,
        InventoryItemStatus.AVAILABLE,
      );
    }

    // Devolve itens do acceptor para AVAILABLE (se existirem)
    const acceptorItems = swap.items?.filter((item) => !item.isCreatorItem) || [];
    if (acceptorItems.length > 0) {
      for (const swapItem of acceptorItems) {
        await this.inventoryRepository.updateStatus(
          swapItem.inventoryItemId,
          InventoryItemStatus.AVAILABLE,
        );
      }
    }

    await this.swapRepository.updateStatus(swapId, SwapStatus.CANCELLED);

    return { success: true };
  });
}

Response (200 OK)

json
{
  "success": true,
  "message": "Swap cancelled successfully"
}

Erros Possiveis

StatusMensagemCausa
400Can only cancel pending swapsSwap nao esta PENDING
403Only the creator can cancel this swapUsuario nao e o creator
404Swap not foundID inexistente

4. PUT /swap/:id/decline - Recusar Swap

Recusa uma proposta de swap. Em swaps privados, apenas o acceptor designado pode recusar. Em swaps publicos, qualquer usuario pode recusar.

Autenticacao: Obrigatoria (AuthGuard)

Use Case: src/application/use-cases/swap/decline-swap.use-case.ts

Request

http
PUT /swap/839201847500/decline
Cookie: sessionId=def456

Nenhum body necessario.

Validacoes

  1. Existencia do swap: O swap deve existir.
  2. Autorizacao:
    • Swap privado: apenas o acceptorId designado pode recusar.
    • Swap publico (isPublic = true): qualquer usuario pode recusar.
  3. Status PENDING: Apenas swaps com status PENDING podem ser recusados.

Logica de Execucao

typescript
// decline-swap.use-case.ts (simplificado)

async execute(userId, swapId) {
  const swap = await this.swapRepository.findById(swapId);
  if (!swap) throw new NotFoundException('Swap not found');

  // Swap privado: apenas o acceptor designado
  if (!swap.isPublic && swap.acceptorId !== userId) {
    throw new ForbiddenException('You cannot decline this swap');
  }
  if (swap.status !== SwapStatus.PENDING) {
    throw new BadRequestException('Can only decline pending swaps');
  }

  return this.prisma.$transaction(async (tx) => {
    // Devolve APENAS os itens do creator para AVAILABLE
    const creatorItems = swap.items?.filter((item) => item.isCreatorItem) || [];
    for (const swapItem of creatorItems) {
      await this.inventoryRepository.updateStatus(
        swapItem.inventoryItemId,
        InventoryItemStatus.AVAILABLE,
      );
    }

    await this.swapRepository.updateStatus(swapId, SwapStatus.DECLINED);

    return { success: true };
  });
}

Diferenca fundamental entre Decline e Cancel:

  • Decline: Devolve APENAS itens do creator (isCreatorItem = true). O acceptor nunca commitou itens no decline.
  • Cancel: Devolve itens de AMBOS os lados.

Response (200 OK)

json
{
  "success": true,
  "message": "Swap declined successfully"
}

Erros Possiveis

StatusMensagemCausa
400Can only decline pending swapsSwap nao esta PENDING
403You cannot decline this swapSwap privado e usuario nao e o acceptor
404Swap not foundID inexistente

5. GET /swap/my-swaps - Meus Swaps

Retorna os swaps pendentes do usuario, divididos em dois grupos: swaps criados pelo usuario e swaps recebidos (onde o usuario e o acceptor).

Autenticacao: Obrigatoria (AuthGuard)

Use Case: src/application/use-cases/swap/get-my-swaps.use-case.ts

Request

http
GET /swap/my-swaps
Cookie: sessionId=abc123

Nenhum parametro necessario.

Logica de Execucao

typescript
// get-my-swaps.use-case.ts

async execute(userId) {
  const [created, received] = await Promise.all([
    this.swapRepository.findPendingByCreator(userId),
    this.swapRepository.findPendingByAcceptor(userId),
  ]);

  return { created, received };
}

Filtros do Repository

Ambas as queries filtram por:

  • status = PENDING
  • expiresAt > now() (nao expirado)
  • Ordenado por createdAt DESC (mais recentes primeiro)
typescript
// swap.repository.ts - findPendingByCreator
async findPendingByCreator(creatorId: bigint) {
  return this.prisma.swap.findMany({
    where: {
      creatorId,
      status: SwapStatus.PENDING,
      expiresAt: { gt: new Date() },
    },
    include: {
      acceptor: { select: { id: true, username: true, avatarUrl: true } },
      items: { include: { inventoryItem: { include: { item: true } } } },
    },
    orderBy: { createdAt: 'desc' },
  });
}

// swap.repository.ts - findPendingByAcceptor
async findPendingByAcceptor(acceptorId: bigint) {
  return this.prisma.swap.findMany({
    where: {
      acceptorId,
      status: SwapStatus.PENDING,
      expiresAt: { gt: new Date() },
    },
    include: {
      creator: { select: { id: true, username: true, avatarUrl: true } },
      items: { include: { inventoryItem: { include: { item: true } } } },
    },
    orderBy: { createdAt: 'desc' },
  });
}

Response (200 OK)

json
{
  "created": [
    {
      "id": "839201847500",
      "creatorId": "738291038270",
      "acceptorId": "738291038271",
      "status": "PENDING",
      "creatorValueCents": "125000",
      "acceptorValueCents": null,
      "isPublic": false,
      "message": "Troca justa",
      "expiresAt": "2026-02-12T14:30:00.000Z",
      "createdAt": "2026-02-11T14:30:00.000Z",
      "acceptor": {
        "id": "738291038271",
        "username": "TradeKing",
        "avatarUrl": "https://..."
      },
      "items": [
        {
          "id": "839201847501",
          "isCreatorItem": true,
          "itemValueCents": "75000",
          "inventoryItem": {
            "item": { "name": "AK-47 | Redline", "imageUrl": "..." }
          }
        }
      ]
    }
  ],
  "received": [
    {
      "id": "839201847600",
      "creatorId": "738291038272",
      "acceptorId": "738291038270",
      "status": "PENDING",
      "creatorValueCents": "90000",
      "creator": {
        "id": "738291038272",
        "username": "SkinCollector",
        "avatarUrl": "https://..."
      },
      "items": [...]
    }
  ]
}

6. GET /swap/public - Swaps Publicos

Retorna os swaps publicos pendentes disponiveis para qualquer usuario aceitar. Limitado a 50 resultados.

Autenticacao: Obrigatoria (AuthGuard)

Use Case: src/application/use-cases/swap/get-public-swaps.use-case.ts

Request

http
GET /swap/public
Cookie: sessionId=abc123

Nenhum parametro necessario.

Logica de Execucao

typescript
// get-public-swaps.use-case.ts

async execute() {
  return this.swapRepository.findPublicSwaps();
}

Filtros do Repository

typescript
// swap.repository.ts - findPublicSwaps
async findPublicSwaps() {
  return this.prisma.swap.findMany({
    where: {
      isPublic: true,
      status: SwapStatus.PENDING,
      expiresAt: { gt: new Date() },
    },
    include: {
      creator: { select: { id: true, username: true, avatarUrl: true } },
      items: { include: { inventoryItem: { include: { item: true } } } },
    },
    orderBy: { createdAt: 'desc' },
    take: 50,
  });
}

Filtros aplicados:

  • isPublic = true
  • status = PENDING
  • expiresAt > now() (nao expirado)
  • Ordenado por createdAt DESC
  • Limite de 50 resultados

Response (200 OK)

json
[
  {
    "id": "839201847700",
    "creatorId": "738291038273",
    "acceptorId": null,
    "status": "PENDING",
    "creatorValueCents": "200000",
    "acceptorValueCents": null,
    "isPublic": true,
    "message": "Aceito qualquer oferta justa",
    "expiresAt": "2026-02-12T14:30:00.000Z",
    "createdAt": "2026-02-11T14:30:00.000Z",
    "creator": {
      "id": "738291038273",
      "username": "BigTrader",
      "avatarUrl": "https://..."
    },
    "items": [
      {
        "id": "839201847701",
        "isCreatorItem": true,
        "itemValueCents": "200000",
        "inventoryItem": {
          "item": {
            "name": "AWP | Dragon Lore",
            "imageUrl": "...",
            "rarity": "COVERT",
            "valueCentsBrl": "200000"
          }
        }
      }
    ]
  }
]

7. POST /swap/site - Site Swap (Waxpeer)

Troca itens do inventario do usuario por um item do marketplace Waxpeer. O usuario oferece de 1 a 6 itens e recebe um item especifico com preco em tempo real. Inclui margem dinamica (via PriceMarginService.getCurrent(), default 1.5 = 50%) sobre o preco Waxpeer e tolerancia de 10%; a diferenca entre o valor ofertado e o alvo (0-10%) e creditada de volta no saldo do usuario.

Autenticacao: Obrigatoria (AuthGuard)

Use Case: src/application/use-cases/swap/perform-site-swap.use-case.ts

Constantes

typescript
const MAX_ITEMS = 6;            // Maximo de itens oferecidos
const TOLERANCE_PERCENT = 10;   // Tolerancia de 10% para baixo
// Margem obtida em runtime via PriceMarginService.getCurrent() (default 1.5 = 50%)

Request

http
POST /swap/site
Content-Type: application/json
Cookie: sessionId=abc123

{
  "inventoryItemIds": ["839201847382", "839201847383", "839201847384"],
  "targetMarketHashName": "AK-47 | Fire Serpent (Field-Tested)"
}
CampoTipoObrigatorioDescricao
inventoryItemIdsstring[]SimIDs dos itens do inventario (1 a 6 itens)
targetMarketHashNamestringSimNome exato do item no marketplace Waxpeer

Validacoes

  1. Quantidade de itens: Minimo 1, maximo 6.
  2. Target name: Nao pode ser vazio.
  3. Disponibilidade Waxpeer: O item deve existir no marketplace com match exato (isExactMatch).
  4. Estoque Waxpeer: O item deve ter pelo menos 1 unidade disponivel (count >= 1).
  5. Propriedade dos itens: Todos devem pertencer ao userId.
  6. Status dos itens: Todos devem estar AVAILABLE.
  7. Valor do target dentro do range permitido (detalhado abaixo).

Calculo de Preco

O calculo de preco do item alvo segue estas etapas:

1. Preco base Waxpeer (USD centavos)
   Ex: 5000 (= $50.00)

2. Aplica margem (PriceMarginService.getCurrent(), default 1.5 = 50%)
   priceCentsUsdWithMargin = 5000 * 1.5 = 7500 (= $75.00)

3. Converte USD -> BRL
   exchangeRate = 5.25 (exemplo)
   priceCentsBrl = (7500 / 100) * 5.25 * 100 = 39375 (= R$393.75)

Formula completa:

priceCentsBrl = round((priceCentsUsd * priceMargin / 100) * exchangeRate * 100)
  priceMargin = PriceMarginService.getCurrent() (default 1.5 = 50%)

Validacao de Valor

O valor do item alvo deve estar dentro de um range especifico:

typescript
const maxAllowedTargetValue = totalUserValueCents;
const minAllowedTargetValue = (totalUserValueCents * BigInt(100 - TOLERANCE_PERCENT)) / BigInt(100);
// minAllowed = totalUser * 90 / 100

// Regra: minAllowed <= targetValueBrl <= maxAllowed
  • Limite superior: O item alvo NAO pode valer mais do que a soma dos itens oferecidos.
  • Limite inferior: O item alvo NAO pode valer menos que 90% da soma dos itens oferecidos.

Isso significa que o usuario pode receber um item de valor igual ou ate 10% menor que o que ofereceu.

Logica de Execucao

typescript
// perform-site-swap.use-case.ts (simplificado)

async execute(userId, inventoryItemIds, targetMarketHashName) {
  // 1. Validacoes de input
  if (inventoryItemIds.length === 0) throw new BadRequestException('...');
  if (inventoryItemIds.length > MAX_ITEMS) throw new BadRequestException('...');
  if (!targetMarketHashName?.trim()) throw new BadRequestException('...');

  // 2. Busca preco do item no Waxpeer
  const waxpeerPrice = await this.waxpeerService.getItemPrice(targetMarketHashName);
  if (!waxpeerPrice || !waxpeerPrice.isExactMatch) throw new NotFoundException('...');
  if (waxpeerPrice.count < 1) throw new BadRequestException('Item temporarily out of stock');

  // 3. Calcula preco em BRL com margem (dinamica via PriceMarginService)
  const priceMargin = this.priceMarginService.getCurrent();
  const priceCentsUsdWithMargin = Math.round(waxpeerPrice.priceCentsUsd * priceMargin);
  const exchangeRate = await this.exchangeRateService.getUsdToBrlRate();
  const priceCentsBrl = Math.round((priceCentsUsdWithMargin / 100) * exchangeRate * 100);

  // 4. Adquire lock do usuario
  return this.lockService.withUserBalanceLock(userId, async () => {
    return this.prisma.$transaction(async (tx) => {
      // 5. Busca e valida itens dentro da transaction
      const inventoryItems = [];
      for (const invItemId of inventoryItemIds) {
        const invItem = await tx.userInventory.findUnique({
          where: { id: invItemId },
          include: { item: true },
        });
        if (!invItem) throw new NotFoundException('...');
        if (invItem.userId !== userId) throw new BadRequestException('...');
        if (invItem.status !== 'AVAILABLE') throw new BadRequestException('...');
        inventoryItems.push(invItem);
      }

      // 6. Calcula valor total dos itens do usuario
      const totalUserValueCents = inventoryItems.reduce(
        (sum, inv) => sum + inv.item.valueCentsBrl,
        BigInt(0),
      );

      // 7. Valida range de valor
      const targetValueBrl = BigInt(priceCentsBrl);
      const maxAllowedTargetValue = totalUserValueCents;
      const minAllowedTargetValue =
        (totalUserValueCents * BigInt(100 - TOLERANCE_PERCENT)) / BigInt(100);

      if (targetValueBrl > maxAllowedTargetValue) {
        throw new BadRequestException('Target item is more expensive than your items...');
      }
      if (targetValueBrl < minAllowedTargetValue) {
        throw new BadRequestException('Target item is too cheap...');
      }

      // 8. Find or create item no banco de dados
      let targetItem = await tx.item.findUnique({
        where: { marketHashName: targetMarketHashName },
      });
      if (targetItem) {
        targetItem = await tx.item.update({
          where: { id: targetItem.id },
          data: {
            valueCents: BigInt(priceCentsUsdWithMargin),
            valueCentsBrl: BigInt(priceCentsBrl),
            imageUrl: waxpeerPrice.imageUrl,
          },
        });
      } else {
        targetItem = await tx.item.create({
          data: {
            id: this.snowflakeService.generate(),
            name: parsed.skinName || parsed.weaponName,
            marketHashName: targetMarketHashName,
            imageUrl: waxpeerPrice.imageUrl,
            rarity: this.inferRarity(targetMarketHashName),
            valueCents: BigInt(priceCentsUsdWithMargin),
            valueCentsBrl: BigInt(priceCentsBrl),
            weaponCategory: this.inferWeaponCategory(targetMarketHashName),
            exterior: parsed.exterior,
            isStatTrak: parsed.isStatTrak,
          },
        });
      }

      // 9. Marca itens do usuario como IN_SWAP
      for (const invItem of inventoryItems) {
        await tx.userInventory.update({
          where: { id: invItem.id },
          data: { status: 'IN_SWAP' },
        });
      }

      // 10. Cria novo item no inventario do usuario
      const newInventoryItem = await tx.userInventory.create({
        data: {
          id: this.snowflakeService.generate(),
          userId,
          itemId: targetItem.id,
          status: 'AVAILABLE',
          acquiredFrom: 'Swap',
        },
      });

      // 11. Registra SiteSwap e SiteSwapItems para auditoria
      const siteSwap = await tx.siteSwap.create({
        data: {
          id: this.snowflakeService.generate(),
          userId,
          targetItemId: targetItem.id,
          targetItemValueCents: BigInt(priceCentsBrl),
          totalOfferedValueCents: totalUserValueCents,
        },
      });

      for (const invItem of inventoryItems) {
        await tx.siteSwapItem.create({
          data: {
            id: this.snowflakeService.generate(),
            siteSwapId: siteSwap.id,
            userInventoryId: invItem.id,
            itemId: invItem.itemId,
            itemValueCents: invItem.item.valueCentsBrl,
          },
        });
      }

      // 11b. Credita o refund (diferenca ofertado - alvo) no saldo do usuario
      const refundCents = totalUserValueCents - BigInt(priceCentsBrl);
      if (refundCents > BigInt(0)) {
        await this.transactionService.credit(userId, refundCents, 'SWAP_REFUND', undefined, tx);
        const newBalanceCents = await this.transactionService.getUserBalance(userId);
        this.webSocketGateway.emitBalanceUpdate(userId, newBalanceCents);
      }

      // 12. Emite live drop apos 3 segundos
      setTimeout(() => {
        this.webSocketGateway.emitLiveDrop({
          userName: user.username,
          userAvatar: user.avatarUrl,
          itemName: targetItem.name,
          itemImage: targetItem.imageUrl,
          itemValueCents: BigInt(priceCentsBrl),
          rarity: targetItem.rarity,
          source: 'swap',
          timestamp: new Date(),
          dropChance: 100,
        });
      }, 3000);

      return { success: true, itemsSwapped: [...], receivedItem: {...} };
    });
  });
}

Response (200 OK)

json
{
  "success": true,
  "itemsSwapped": [
    {
      "id": "839201847382",
      "name": "P250 | Sand Dune",
      "imageUrl": "https://...",
      "rarity": "CONSUMER",
      "valueCentsBrl": "150",
      "valueFormatted": "R$ 1,50"
    },
    {
      "id": "839201847383",
      "name": "MAG-7 | Wildwood",
      "imageUrl": "https://...",
      "rarity": "CONSUMER",
      "valueCentsBrl": "120",
      "valueFormatted": "R$ 1,20"
    }
  ],
  "totalSwappedValueCents": "270",
  "totalSwappedValueFormatted": "R$ 2,70",
  "receivedItem": {
    "inventoryId": "839201847500",
    "id": "100050",
    "marketHashName": "SG 553 | Pulse (Factory New)",
    "name": "SG 553 | Pulse",
    "imageUrl": "https://...",
    "rarity": "MIL_SPEC",
    "valueCentsBrl": "250",
    "valueFormatted": "R$ 2,50"
  }
}

O controller adiciona os campos valueFormatted e totalSwappedValueFormatted via MoneyService.format().

A diferenca entre o total ofertado (R$ 2,70) e o item recebido (R$ 2,50) — R$ 0,20 — e creditada de volta no saldo do usuario via transactionService.credit, dentro da mesma transaction.

Erros Possiveis

StatusMensagemCausa
400You must select at least 1 itemNenhum item selecionado
400You can select at most 6 itemsMais de 6 itens
400Target item market hash name is requiredNome vazio
400Item temporarily out of stock on marketplaceWaxpeer sem estoque
400Target item is more expensive than your itemsTarget > total oferecido
400Target item is too cheapTarget < 90% do total
400Item does not belong to userItem de outro usuario
400Item {name} is not availableItem com status diferente de AVAILABLE
404Item not available on marketplaceItem nao encontrado no Waxpeer
404Inventory item {id} not foundID de item inexistente
503Unable to fetch item price from marketplaceWaxpeer indisponivel

8. GET /swap/targets - Itens Disponiveis Waxpeer

Retorna a lista de itens disponiveis no Waxpeer para serem usados como alvo em Site Swaps. Suporta filtragem, busca e paginacao. Este e um endpoint publico (nao requer autenticacao).

Autenticacao: Nao requerida (decorador @Public())

Use Case: src/application/use-cases/swap/get-swap-targets.use-case.ts

Request

http
GET /swap/targets?minValueCents=1000&maxValueCents=50000&search=AK-47&sortOrder=desc&limit=20&offset=0
ParametroTipoObrigatorioDefaultDescricao
minValueCentsnumberNao-Valor minimo em centavos BRL
maxValueCentsnumberNao-Valor maximo em centavos BRL
searchstringNao-Busca por nome do item (case insensitive)
sortOrder'asc' | 'desc'Nao'desc'Ordenacao por preco
limitnumberNao20Quantidade de itens por pagina
offsetnumberNao0Offset para paginacao

Logica de Execucao

typescript
// get-swap-targets.use-case.ts (simplificado)

async execute(filters) {
  const { minValueCents, maxValueCents, search, sortOrder = 'desc', limit = 20, offset = 0 } = filters;

  // 1. Busca snapshot de itens do Waxpeer (cache)
  const waxpeerItems = await this.waxpeerSnapshotService.getFilteredItems();

  // 2. Busca taxa de cambio USD -> BRL e margem dinamica
  const exchangeRate = await this.exchangeRateService.getUsdToBrlRate();
  const priceMargin = this.priceMarginService.getCurrent();

  const searchLower = search?.trim()?.toLowerCase();
  const mappedItems = [];

  for (const item of waxpeerItems) {
    const parsed = this.itemNameParser.parse(item.name);

    // 3. Calcula preco com margem e converte para BRL
    const priceCentsWithMargin = Math.round(item.priceCents * priceMargin);
    const priceCentsBrl = Math.round((priceCentsWithMargin / 100) * exchangeRate * 100);

    if (priceCentsBrl <= 0) continue;

    // 4. Aplica filtros
    if (minValueCents !== undefined && priceCentsBrl < minValueCents) continue;
    if (maxValueCents !== undefined && priceCentsBrl > maxValueCents) continue;
    if (searchLower && !item.name.toLowerCase().includes(searchLower)) continue;

    mappedItems.push({
      marketHashName: item.name,
      name: item.name,
      imageUrl: item.imageUrl,
      rarity: item.rarity,
      valueCentsBrl: priceCentsBrl.toString(),
      valueCents: priceCentsWithMargin.toString(),
      weaponCategory: item.weaponCategory,
      exterior: parsed.exterior,
      isStatTrak: parsed.isStatTrak,
    });
  }

  // 5. Ordena por preco
  mappedItems.sort((a, b) => {
    const diff = Number(a.valueCentsBrl) - Number(b.valueCentsBrl);
    return sortOrder === 'asc' ? diff : -diff;
  });

  // 6. Pagina
  const total = mappedItems.length;
  const paginatedItems = mappedItems.slice(offset, offset + limit);
  const hasMore = offset + limit < total;

  return { items: paginatedItems, total, hasMore };
}

Response (200 OK)

json
{
  "items": [
    {
      "marketHashName": "AK-47 | Redline (Field-Tested)",
      "name": "AK-47 | Redline (Field-Tested)",
      "imageUrl": "https://...",
      "rarity": "CLASSIFIED",
      "valueCentsBrl": "4200",
      "valueCents": "800",
      "weaponCategory": "RIFLE",
      "exterior": "Field-Tested",
      "isStatTrak": false
    },
    {
      "marketHashName": "AK-47 | Asiimov (Battle-Scarred)",
      "name": "AK-47 | Asiimov (Battle-Scarred)",
      "imageUrl": "https://...",
      "rarity": "COVERT",
      "valueCentsBrl": "9800",
      "valueCents": "1560",
      "weaponCategory": "RIFLE",
      "exterior": "Battle-Scarred",
      "isStatTrak": false
    }
  ],
  "total": 1523,
  "hasMore": true
}

Campos da Response

CampoTipoDescricao
items[].marketHashNamestringNome completo do item (usado como parametro em POST /swap/site)
items[].namestringNome de exibicao
items[].imageUrlstringURL da imagem do item
items[].raritystringRaridade do item
items[].valueCentsBrlstringPreco em centavos BRL (com margem dinamica via PriceMarginService)
items[].valueCentsstringPreco em centavos USD (com margem dinamica via PriceMarginService)
items[].weaponCategorystringCategoria da arma
items[].exteriorstring?Condicao (Factory New, Minimal Wear, etc)
items[].isStatTrakbooleanSe e StatTrak
totalnumberTotal de itens que passaram pelos filtros
hasMorebooleanSe existem mais paginas

Regras de Negocio

Swap User-to-User

  1. Limite de itens: Minimo 1, maximo 10 itens por lado.
  2. Expiracao: Swaps expiram em 24 horas apos a criacao.
  3. Swap privado: Deve especificar acceptorId. Apenas o acceptor designado pode aceitar ou recusar.
  4. Swap publico: Qualquer usuario pode aceitar. Qualquer usuario pode recusar (decline).
  5. Auto-swap proibido: O creator nao pode criar swap com si mesmo e nao pode aceitar o proprio swap.
  6. Itens bloqueados: Ao criar um swap, os itens do creator sao marcados como IN_SWAP e ficam indisponiveis para outras operacoes.
  7. Valores em BRL: Todos os calculos utilizam valueCentsBrl dos itens, nunca valueCents (USD).
  8. Sem restricao de valor: Nao ha restricao de diferenca de valor entre creator e acceptor no swap user-to-user. O acceptor decide livremente quais itens oferecer.
  9. Atomicidade: A transferencia de itens e atomica via Prisma transaction.
  10. acquiredFrom: Apos transferencia, o campo acquiredFrom do item e atualizado para 'Swap'.

Site Swap (Waxpeer)

  1. Limite de itens: Minimo 1, maximo 6 itens oferecidos.
  2. Margem de preco: O site aplica margem dinamica via PriceMarginService.getCurrent() (default 1.5 = 50%) sobre o preco Waxpeer.
  3. Tolerancia: O item alvo pode valer ate 10% menos que os itens oferecidos (TOLERANCE_PERCENT = 10).
  4. Limite superior: O item alvo nao pode valer mais que a soma dos itens oferecidos.
  5. Conversao de moeda: USD -> BRL usando taxa de cambio em tempo real do ExchangeRateService.
  6. Match exato: O item deve existir no Waxpeer com isExactMatch = true.
  7. Estoque: O item deve ter pelo menos 1 unidade disponivel no Waxpeer.
  8. Find or create: Se o item ja existe no banco (marketHashName), atualiza preco e imagem. Caso contrario, cria novo.
  9. Live drop: Um evento de live drop e emitido via WebSocket apos 3 segundos de delay.
  10. Rarity/Category inferidos: Se o item e criado, rarity e weaponCategory sao inferidos pelo nome.
  11. Refund da diferenca: A diferenca entre o valor ofertado e o valor do alvo (0-10%) e creditada de volta no saldo do usuario via transactionService.credit, dentro da mesma transaction. O usuario sempre recebe 100% do valor ofertado (item alvo + refund).

GGR (Gross Gaming Revenue) do Site Swap

O lucro do site no Site Swap vem da margem de preco aplicada ao item alvo:

  1. Margem dinamica: O preco exibido do item alvo ja inclui a margem retornada por PriceMarginService.getCurrent() (default 1.5 = 50%). O alvo custa ao site precoExibido / margin para ser sourced no Waxpeer.
  2. Tolerancia de 10% NAO e lucro: A diferenca entre o valor ofertado e o valor do alvo (0-10%) e creditada de volta no saldo do usuario via transactionService.credit. Ou seja, o usuario sempre recebe 100% do valor que ofertou (item alvo + refund), e a tolerancia nao fica retida pelo site.

Com a margem default de 50%, o valor exibido do alvo e 1.5x o custo real de sourcing no Waxpeer — essa diferenca e a margem bruta do site sobre a operacao.


Calculo de Precos (Site Swap)

Exemplo Numerico Completo

Cenario: Usuario quer trocar 3 skins por uma AK-47 Fire Serpent.

Itens do usuario (BRL):

ItemvalueCentsBrl
Glock-18 FadeR$ 350,00 (35000)
USP-S Kill ConfirmedR$ 180,00 (18000)
P250 AsiimovR$ 50,00 (5000)
TotalR$ 580,00 (58000)

Preco Waxpeer da AK-47 Fire Serpent:

  • Preco base: $64.00 USD (6400 centavos)
  • Com margem 50%: $96.00 USD (9600 centavos)
  • Taxa de cambio: R$ 5,25
  • Em BRL: (9600 / 100) * 5.25 * 100 = R$ 504,00 (50400 centavos)

Validacao de range:

  • maxAllowedTargetValue = 58000 (R$ 580,00)
  • minAllowedTargetValue = 58000 * 90 / 100 = 52200 (R$ 522,00)
  • targetValueBrl = 50400 (R$ 504,00)

Resultado: REJEITADO. R$ 504,00 < R$ 522,00 (minimo 90% de R$ 580,00).

O usuario precisaria oferecer menos itens ou o preco do Waxpeer precisaria ser maior.

Se o usuario remover o P250 Asiimov:

  • Novo total: R$ 530,00 (53000)
  • minAllowed = 53000 * 90 / 100 = 47700 (R$ 477,00)
  • maxAllowed = 53000 (R$ 530,00)
  • targetValue = 50400 (R$ 504,00)
  • 47700 <= 50400 <= 53000: APROVADO.

Resultado nesse caso:

  • Usuario entregou itens: R$ 530,00
  • Recebeu: item alvo (valor exibido R$ 504,00) + refund de R$ 26,00 no saldo = R$ 530,00 (100% do ofertado)
  • Custo real de sourcing do alvo no Waxpeer: $64.00 * 5.25 = R$ 336,00
  • Margem do site embutida no alvo: R$ 504,00 exibido vs R$ 336,00 de custo (50% via PriceMarginService)

Formula Resumida

Preco exibido (BRL) = round((precoWaxpeerUsdCents * margin / 100) * taxaCambio * 100)
  margin = PriceMarginService.getCurrent() (default 1.5 = 50%)

Range valido:
  min = totalUserBrl * 90%
  max = totalUserBrl * 100%
  
  min <= precoExibidoBrl <= max

Refund creditado no saldo = totalUserBrl - precoExibidoBrl  (0 a 10% do ofertado)

Prevencao de Deadlocks

O Problema

Quando dois usuarios tentam aceitar swaps um do outro ao mesmo tempo, pode ocorrer deadlock se os locks nao forem adquiridos em ordem consistente.

A Solucao

O AcceptSwapUseCase ordena os userIds em ordem crescente antes de adquirir os locks:

typescript
// accept-swap.use-case.ts
const [firstUserId, secondUserId] = [userId, swap.creatorId].sort((a, b) =>
  a < b ? -1 : a > b ? 1 : 0,
);

Hierarquia de Locks

A aquisicao segue uma hierarquia estrita de 3 niveis:

Nivel 1: lock:swap:{swapId}         (TTL 5s)  - Garante exclusividade no swap
Nivel 2: lock:user:balance:{minId}  (TTL 10s) - Lock do usuario com menor ID
Nivel 3: lock:user:balance:{maxId}  (TTL 10s) - Lock do usuario com maior ID

Essa hierarquia garante que:

  • Apenas um processo pode operar em um swap especifico.
  • Locks de usuario sao sempre adquiridos na mesma ordem, independente de quem e o caller.
  • Se o lock nao puder ser adquirido apos 10 tentativas (retryCount), a operacao falha.

Cenario Detalhado

Usuarios: Alice (id=100), Bob (id=200)

Swap #1: Alice criou, Bob aceita
Swap #2: Bob criou, Alice aceita

Thread 1 (Bob aceita Swap #1):
  1. Lock swap:1
  2. Lock user:balance:100 (menor ID)  <-- Alice
  3. Lock user:balance:200 (maior ID)  <-- Bob

Thread 2 (Alice aceita Swap #2):
  1. Lock swap:2
  2. Lock user:balance:100 (menor ID)  <-- Alice [ESPERA Thread 1]
  3. Lock user:balance:200 (maior ID)  <-- Bob   [ESPERA Thread 1]

Resultado: Thread 2 espera Thread 1 finalizar. SEM DEADLOCK.

Edge Cases e Cenarios Criticos

1. Multiplos usuarios tentam aceitar swap publico

Cenario: Um swap publico tem 3 usuarios tentando aceitar ao mesmo tempo.

Protecao: O lock swap:{swapId} garante exclusividade. O primeiro a adquirir o lock processa normalmente. Os demais, ao conseguirem o lock, encontram o swap com status ACCEPTED no re-fetch e recebem o erro Swap is no longer available.

typescript
// Dentro do lock, re-fetch garante consistencia
const freshSwap = await this.swapRepository.findById(swapId);
if (!freshSwap || freshSwap.status !== SwapStatus.PENDING) {
  throw new BadRequestException('Swap is no longer available');
}

2. Swap expira durante processamento

Cenario: Um swap com expiresAt = 14:30:00 e aceito as 14:29:59. O processamento leva 2 segundos.

Protecao: A validacao de expiracao e feita ANTES de adquirir os locks. Se o swap expirou durante a espera pelo lock, o re-fetch dentro da transaction detecta. O deleteExpiredSwaps() do repository atualiza o status para EXPIRED via cron job.

3. Item fica indisponivel entre a validacao e a transaction

Cenario: O usuario seleciona itens, mas outro processo (venda, upgrade, batalha) marca o item como indisponivel entre a validacao pre-lock e a validacao intra-transaction.

Protecao no Create: O withUserBalanceLock impede operacoes concorrentes no inventario do mesmo usuario.

Protecao no Accept: O lock triplo (swap + dois users) garante exclusividade total. A validacao dentro do lock verifica novamente o status dos itens.

Protecao no Site Swap: O withUserBalanceLock + busca dentro da transaction (tx.userInventory.findUnique) garante consistencia.

4. Preco do Waxpeer muda entre consulta e execucao

Cenario: O usuario consulta o preco via GET /swap/targets, mas quando faz POST /swap/site, o preco mudou.

Protecao: O POST /swap/site busca o preco FRESH do Waxpeer (waxpeerService.getItemPrice) a cada chamada, nao usa cache. O calculo e a validacao sao feitos com o preco atualizado.

5. Item do Waxpeer nao existe no banco de dados

Cenario: O usuario tenta fazer Site Swap com um item que nunca foi visto pelo sistema.

Protecao: O use case faz find-or-create. Se o item nao existe (marketHashName), cria um novo registro na tabela Item com os dados do Waxpeer, inferindo rarity e weaponCategory pelo nome.

6. Itens orfaos com status IN_SWAP

Cenario: Um swap expira ou e cancelado, mas o cron job de limpeza falha.

Deteccao: Itens com status IN_SWAP que nao pertencem a nenhum swap PENDING sao orfaos.

Resolucao: O deleteExpiredSwaps() do repository atualiza swaps expirados para EXPIRED, mas NAO atualiza o status dos itens automaticamente. Os itens precisam ser devolvidos manualmente ou por um job de reconciliacao.

7. Race condition entre Create e Accept no mesmo item

Cenario: Usuario A cria swap com item X. Antes de A completar, outro processo tenta usar o item X.

Protecao: O withUserBalanceLock no Create garante que o item e marcado como IN_SWAP atomicamente. Qualquer outro processo que tente usar o item encontra status IN_SWAP e recebe erro.


Exemplos Numericos

Exemplo 1: Swap User-to-User Completo

CRIACAO:
  Creator: Usuario A (id=100)
  Itens do Creator:
    - AK-47 Redline FT: R$ 42,00 (4200 centavos BRL)
    - M4A1-S Hyper Beast FT: R$ 38,00 (3800 centavos BRL)
  Total creator: R$ 80,00 (8000 centavos)
  Acceptor: Usuario B (id=200) [swap privado]
  Expiracao: +24 horas

  Resultado:
    - Swap PENDING criado
    - 2 itens marcados IN_SWAP
    - 2 SwapItems criados (isCreatorItem=true)
    - creatorValueCents = 8000


ACEITE:
  Acceptor: Usuario B (id=200)
  Itens do Acceptor:
    - AWP Asiimov FT: R$ 75,00 (7500 centavos BRL)
  Total acceptor: R$ 75,00 (7500 centavos)

  Lock ordering:
    firstUserId = 100 (menor)
    secondUserId = 200 (maior)
    Locks: swap:X -> user:balance:100 -> user:balance:200

  Resultado:
    - acceptorValueCents = 7500
    - 1 item do acceptor marcado IN_SWAP, SwapItem criado (isCreatorItem=false)
    - AK-47 Redline -> userId = 200 (aceitor), status = AVAILABLE
    - M4A1-S Hyper Beast -> userId = 200 (acceptor), status = AVAILABLE
    - AWP Asiimov -> userId = 100 (creator), status = AVAILABLE
    - Swap status = ACCEPTED, completedAt = agora

Exemplo 2: Site Swap com Calculo Detalhado

DADOS:
  Usuario tem 4 itens:
    - Glock Water Elemental FT: R$ 12,00 (1200 centavos)
    - P250 Muertos FT: R$ 8,50 (850 centavos)
    - Five-SeveN Monkey Business MW: R$ 5,00 (500 centavos)
    - Nova Hyper Beast FT: R$ 4,50 (450 centavos)
  Total oferecido: R$ 30,00 (3000 centavos)

  Item alvo: Deagle Kumicho Dragon FT
  Preco Waxpeer: $3.36 USD (336 centavos USD)
  Margem 50%: 336 * 1.5 = 504 centavos USD
  Taxa cambio: 5.25
  Preco BRL: (504 / 100) * 5.25 * 100 = 2646 centavos = R$ 26,46

VALIDACAO DE RANGE:
  maxAllowed = 3000 (R$ 30,00)
  minAllowed = 3000 * 90 / 100 = 2700 (R$ 27,00)
  targetValue = 2646 (R$ 26,46)

  2646 < 2700 --> REJEITADO (abaixo do minimo de 90%)

AJUSTE: Se o usuario remover a Nova Hyper Beast:
  Total oferecido: R$ 25,50 (2550 centavos)
  minAllowed = 2550 * 90 / 100 = 2295 (R$ 22,95)
  maxAllowed = 2550 (R$ 25,50)
  targetValue = 2646 (R$ 26,46)

  2646 > 2550 --> REJEITADO (acima do maximo)

AJUSTE 2: Se mantiver todos os 4 itens e o preco Waxpeer for $3.84:
  Margem: 384 * 1.5 = 576 centavos USD
  BRL: (576 / 100) * 5.25 * 100 = 3024 centavos = R$ 30,24

  minAllowed = 3000 * 90 / 100 = 2700
  maxAllowed = 3000
  targetValue = 3024

  3024 > 3000 --> REJEITADO (acima do maximo)

CENARIO QUE FUNCIONA:
  Preco Waxpeer: $3.60 USD (360 centavos USD)
  Margem: 360 * 1.5 = 540 centavos USD
  BRL: (540 / 100) * 5.25 * 100 = 2835 centavos = R$ 28,35

  minAllowed = 2700 (R$ 27,00)
  maxAllowed = 3000 (R$ 30,00)
  targetValue = 2835 (R$ 28,35)

  2700 <= 2835 <= 3000 --> APROVADO

  Resultado:
    User entregou itens: R$ 30,00
    Recebeu: alvo (valor exibido R$ 28,35) + refund R$ 1,65 no saldo = R$ 30,00 (100%)
    Custo real de sourcing (sem margem): (360/100) * 5.25 = R$ 18,90
    Margem do site embutida no alvo: R$ 28,35 exibido vs R$ 18,90 custo (50% via PriceMarginService)

Exemplo 3: Decline vs Cancel

CENARIO:
  Creator: Usuario A
  Acceptor (privado): Usuario B
  Itens do creator: AK-47 Redline (IN_SWAP), M4A1-S (IN_SWAP)
  Status: PENDING (nenhum item do acceptor commitado)

DECLINE (por Usuario B):
  - AK-47 Redline -> AVAILABLE (devolvido ao Creator)
  - M4A1-S -> AVAILABLE (devolvido ao Creator)
  - Status -> DECLINED
  - Itens do acceptor: nenhum afetado (nunca foram adicionados)

CANCEL (por Usuario A, em cenario hipotetico onde acceptor ja commitou):
  - AK-47 Redline -> AVAILABLE (devolvido ao Creator)
  - M4A1-S -> AVAILABLE (devolvido ao Creator)
  - Itens do acceptor (se existirem) -> AVAILABLE (devolvidos ao Acceptor)
  - Status -> CANCELLED

Arquivos do Sistema

Camada de Dominio

ArquivoDescricao
prisma/schema.prisma (linhas 697-778)Modelos Swap, SwapItem, SiteSwap, SiteSwapItem, enum SwapStatus
src/domain/repositories/swap.repository.interface.tsInterface ISwapRepository com 8 metodos

Camada de Aplicacao (Use Cases)

ArquivoDescricao
src/application/use-cases/swap/create-swap.use-case.tsCriacao de swap user-to-user
src/application/use-cases/swap/accept-swap.use-case.tsAceite de swap com lock ordering
src/application/use-cases/swap/cancel-swap.use-case.tsCancelamento pelo creator
src/application/use-cases/swap/decline-swap.use-case.tsRecusa pelo acceptor
src/application/use-cases/swap/get-my-swaps.use-case.tsListagem de swaps do usuario
src/application/use-cases/swap/get-public-swaps.use-case.tsListagem de swaps publicos
src/application/use-cases/swap/perform-site-swap.use-case.tsSite swap via Waxpeer
src/application/use-cases/swap/get-swap-targets.use-case.tsListagem de itens Waxpeer

Camada de Infraestrutura

ArquivoDescricao
src/infrastructure/database/repositories/swap.repository.tsImplementacao do ISwapRepository
src/infrastructure/locks/lock.service.tsRedlock distributed locks
src/infrastructure/external-apis/waxpeer.service.tsIntegracao com API Waxpeer
src/infrastructure/external-apis/waxpeer-snapshot.service.tsCache/snapshot de precos Waxpeer
src/infrastructure/external-apis/exchange-rate.service.tsTaxa de cambio USD/BRL
src/infrastructure/external-apis/item-name-parser.service.tsParser de nomes de itens CS2

Camada de Apresentacao

ArquivoDescricao
src/presentation/controllers/swap.controller.tsController com 8 endpoints
src/presentation/modules/swap.module.tsModulo NestJS do swap

Interface do Repository

typescript
// src/domain/repositories/swap.repository.interface.ts

export interface ISwapRepository {
  create(data: {
    creatorId: bigint;
    acceptorId?: bigint;
    creatorValueCents: bigint;
    isPublic: boolean;
    message?: string;
    expiresAt: Date;
  }): Promise<any>;

  findById(id: bigint): Promise<any>;

  findPendingByCreator(creatorId: bigint): Promise<any[]>;

  findPendingByAcceptor(acceptorId: bigint): Promise<any[]>;

  findPublicSwaps(): Promise<any[]>;

  updateStatus(id: bigint, status: SwapStatus, completedAt?: Date): Promise<any>;

  setAcceptor(id: bigint, acceptorId: bigint, acceptorValueCents: bigint): Promise<any>;

  addItem(data: {
    swapId: bigint;
    inventoryItemId: bigint;
    isCreatorItem: boolean;
    itemValueCents: bigint;
  }): Promise<any>;

  getSwapItems(swapId: bigint): Promise<any[]>;

  deleteExpiredSwaps(): Promise<number>;
}

Modulo NestJS

typescript
// src/presentation/modules/swap.module.ts

@Module({
  imports: [DatabaseModule, RedisModule, LockModule, AuthModule, WebSocketModule, HttpModule],
  controllers: [SwapController],
  providers: [
    PrismaClient,
    MoneyService,
    WaxpeerService,
    WaxpeerSnapshotService,
    ExchangeRateService,
    ItemNameParserService,
    PriceMarginService,
    TransactionService,
    CreateSwapUseCase,
    AcceptSwapUseCase,
    CancelSwapUseCase,
    DeclineSwapUseCase,
    GetMySwapsUseCase,
    GetPublicSwapsUseCase,
    PerformSiteSwapUseCase,
    GetSwapTargetsUseCase,
    {
      provide: 'ISwapRepository',
      useClass: SwapRepository,
    },
    {
      provide: 'IUserInventoryRepository',
      useClass: UserInventoryRepository,
    },
    {
      provide: 'IUserRepository',
      useClass: UserRepository,
    },
  ],
})
export class SwapModule {}

Debugging e Troubleshooting

Logs do Site Swap

O PerformSiteSwapUseCase emite logs estruturados:

[PerformSiteSwapUseCase] [Swap] Target: AK-47 | Fire Serpent (Field-Tested),
  Waxpeer: 6400c USD, With margin: 9600c USD, BRL: 50400c (rate: 5.25)

Verificar Item com Status IN_SWAP Orfao

Se um item esta preso com status IN_SWAP sem um swap PENDING associado:

sql
-- Buscar itens orfaos
SELECT ui.id, ui.user_id, ui.item_id, ui.status, i.name
FROM user_inventory ui
JOIN items i ON ui.item_id = i.id
WHERE ui.status = 'IN_SWAP'
AND NOT EXISTS (
  SELECT 1 FROM swap_items si
  JOIN swaps s ON si.swap_id = s.id
  WHERE si.inventory_item_id = ui.id
  AND s.status = 'PENDING'
);

-- Corrigir (usar com cautela)
UPDATE user_inventory
SET status = 'AVAILABLE'
WHERE id IN (/* IDs dos itens orfaos */);

Verificar Locks Ativos

typescript
// Verificar se ha locks travados no Redis
const client = redisService.getClient();
const keys = await client.keys('lock:*');
console.log('Active locks:', keys);

Verificar Swap Expirado que Nao Foi Limpo

sql
SELECT id, creator_id, status, expires_at
FROM swaps
WHERE status = 'PENDING'
AND expires_at < NOW();

Validar Integridade de um Swap Aceito

sql
-- Verificar se todos os itens foram transferidos corretamente
SELECT
  s.id AS swap_id,
  s.status,
  si.inventory_item_id,
  si.is_creator_item,
  ui.user_id AS current_owner,
  ui.status AS item_status,
  CASE
    WHEN si.is_creator_item AND ui.user_id = s.acceptor_id THEN 'OK'
    WHEN NOT si.is_creator_item AND ui.user_id = s.creator_id THEN 'OK'
    ELSE 'INCONSISTENTE'
  END AS integrity_check
FROM swaps s
JOIN swap_items si ON si.swap_id = s.id
JOIN user_inventory ui ON ui.id = si.inventory_item_id
WHERE s.id = 839201847500
AND s.status = 'ACCEPTED';

Se algum registro retornar INCONSISTENTE, a transferencia de itens falhou parcialmente.

Verificar Historico de Site Swaps

sql
SELECT
  ss.id,
  ss.user_id,
  i.name AS target_item,
  ss.target_item_value_cents,
  ss.total_offered_value_cents,
  ss.created_at,
  ROUND((ss.total_offered_value_cents - ss.target_item_value_cents)::numeric /
        ss.total_offered_value_cents * 100, 2) AS spread_percent
FROM site_swaps ss
JOIN items i ON ss.target_item_id = i.id
ORDER BY ss.created_at DESC
LIMIT 20;

Diagnostico de Erro "Swap is no longer available"

Esse erro ocorre quando o usuario tenta aceitar um swap que ja foi aceito por outro usuario (race condition resolvida pelo lock):

  1. Verificar o status atual do swap no banco.
  2. Verificar quem aceitou (campo acceptor_id).
  3. Verificar o timestamp de completed_at vs o timestamp da tentativa do usuario.
  4. O lock swap:{swapId} garante que apenas um aceite e processado.

Diagnostico de Erro "Target item is more expensive"

Target item (R$ X) is more expensive than your items (R$ Y).
You cannot receive more than you offer.
  1. Verificar os valores valueCentsBrl dos itens do usuario no inventario.
  2. Verificar o preco atual do item no Waxpeer (waxpeerService.getItemPrice).
  3. Calcular: precoWaxpeer * margin / 100 * taxaCambio * 100 = targetValueBrl (margin = PriceMarginService.getCurrent(), default 1.5).
  4. Se targetValueBrl > totalUserValueCents, o erro esta correto.
  5. Solucao: o usuario precisa oferecer mais itens ou escolher um item mais barato.

Diagnostico de Erro "Target item is too cheap"

Target item (R$ X) is too cheap.
Minimum allowed is R$ Y (90% of your R$ Z).
  1. O item alvo vale menos que 90% do total oferecido.
  2. Solucao: o usuario deve remover itens da oferta ou escolher um item mais caro.
  3. Range valido: [totalUser * 90%, totalUser * 100%].

Indices de Performance do Banco

A tabela swaps possui 5 indices para otimizar as queries mais comuns:

prisma
@@index([creatorId, status])     // findPendingByCreator
@@index([acceptorId, status])    // findPendingByAcceptor
@@index([status, createdAt])     // queries de listagem com ordenacao
@@index([isPublic, status])      // findPublicSwaps
@@index([expiresAt])             // deleteExpiredSwaps / cron de limpeza

A tabela swap_items possui 2 indices:

prisma
@@index([swapId])                // buscar itens de um swap
@@index([inventoryItemId])       // verificar se item esta em algum swap

A tabela site_swaps possui 3 indices:

prisma
@@index([userId])                // buscar site swaps de um usuario
@@index([createdAt])             // listagem cronologica
@@index([targetItemValueCents])  // buscar por faixa de valor

A tabela site_swap_items possui 2 indices:

prisma
@@index([siteSwapId])            // buscar itens de um site swap
@@index([userInventoryId])       // verificar se item foi usado em site swap

Resumo de Endpoints

MetodoRotaAuthDescricao
POST/swapSimCriar proposta de swap
PUT/swap/:id/acceptSimAceitar swap com itens
PUT/swap/:id/cancelSimCancelar swap (creator)
PUT/swap/:id/declineSimRecusar swap (acceptor)
GET/swap/my-swapsSimListar meus swaps pendentes
GET/swap/publicSimListar swaps publicos (max 50)
POST/swap/siteSimSwap com Waxpeer
GET/swap/targetsNaoListar itens Waxpeer com filtros

Documentação Técnica - ProCases