Saltar al contenido
VertS background

Full Stack · International Logistics · Multi-tenant SaaS

Control Tower for 3PL Operations

From Excel and WhatsApp to a system that processed 400+ packages and 120+ international shipments in the first 90 days of production, under an authorized operational validation pilot

Design: Nov 2025 — Production: Feb 2026

400+
Packages processed
120+
International shipments
90
Days in production
~20min
Per outbound instruction

#The problem nobody wanted to name

When I joined a 3PL company with operations from Ireland and the United States to the entire American continent, operations were managed like this:

Excel spreadsheets to log reception tickets
Email to coordinate between warehouse, clients, and internal team
WhatsApp for urgent issues
Desktop folders to store photos downloaded from third-party warehouse systems
A whiteboard with markers in front of the operations director's desk

A single outbound instruction — consolidating ready tickets, verifying cargo, preparing documentation — took 2 to 3 hours of manual work. Errors were frequent and costly: cargo sent to the wrong country, packages lost in the flow, miscalculated invoices.

#The decision

There was an ERP in use. Nobody used it. It was too complex for an operation that didn't need a generic ERP — it needed a tool built to measure, one that followed the company's exact flow: its nomenclature, its stages, its business rules. Not adapting the operation to the software, but the software to the operation.

I didn't look for another software. I decided to build it. What the operation needed was a tool that followed its exact workflow, simple enough for the whole team to adopt, and that put all information in one place in real time. I started designing it in November 2025. It went into production in February 2026.

#The complete flow at a glance

VertS manages the complete lifecycle of an international logistics operation: from when the client notifies incoming cargo, to when that cargo reaches the consignee and the supplier cost is recorded.

📋

01 / 10

Client creates Pre-alert (PO)

#Core Modules: The Ecosystem

The application is structured into secure, role-based modules accessible from the main dashboard. Each module resolves a specific operational friction:

📦
Warehouse
Receptions

Pre-alerts, reception tickets with photos and documents, automatic PO consolidation.

✈️
Dispatch
Outbound Instructions

Export manifests, AWB/BL, Loading Guide, ETA vs. actual delivery tracking.

⚖️
Finance
Quotations

Automatic engine: actual vs. volumetric weight, 11 charge concepts, PDF generation.

💼
Suppliers
Invoices

Warehouse invoices linked to specific shipments. Real cost traceability per operation.

📊
Data
Reports

XLSX, CSV, and PDF exports. Real-time operational KPI dashboard.

🔐
Access
Multi-tenant

Differentiated roles: STAFF_3PL, CLIENTE_ADMIN, CLIENTE_VISOR. Full isolation per company.

#The technical decisions that matter

1. Database first — and done right

The biggest technical challenge wasn't the frontend. It was designing the correct data model from the start. A package can arrive in multiple partial receptions. Several packages from different clients can be consolidated into the same shipment. A shipment can be deconsolidated to send parts to different destinations. Supplier invoices can cover one or multiple shipments. Modeling that wrong at the beginning meant rewriting everything later. The result: correct relationships, 13 strategic indexes for the most frequent queries, and constraints that make it impossible to register inconsistent data.

2. Automation that eliminates human error

The most costly error in the previous system was manual intervention at every state change. In VertS, that doesn't exist. Five PostgreSQL triggers handle consistency without anyone activating them: when a ticket arrives, the trigger sums the items and updates the PO; when the PO is complete, all its tickets change to READY_TO_SHIP in an atomic operation; when deconsolidating, the trigger validates that child items don't exceed the parent. Every mutation is recorded in audit_logs with the before and after state.

3. Multi-tenant security that lives in the database

In a platform where multiple clients see their own cargo, the security question isn't 'what does the frontend show?' but 'what can the database return?'. Row Level Security on every table: an authenticated client physically cannot obtain another client's data even if they manipulate the URL, token, or HTTP request.

Profiles & Roles

Auth Users → profiles table

Integration on top of native auth. Three roles: STAFF_3PL, CLIENTE_ADMIN, CLIENTE_VISOR.

Secure

Row Level Security

Policy: auth.uid() = user_id

All PostgreSQL queries filter rows natively. The frontend doesn't filter — the DB does.

Unbreakable

Storage Buckets

File-level policies

Reception photos, invoices, and quotation PDFs protected in RLS buckets.

Private

By leveraging native PostgreSQL capabilities, the application bypasses common backend-layer vulnerabilities and scales without rewriting authorization logic every time a new module is added.

Server Action: multi-tenant isolation

The empresa_id never comes from the client — it's inferred from the authenticated profile. This makes it impossible for one tenant to insert data in another's context, even if the request is manipulated.

src/app/actions/reception.ts
"text-slate-500">// src/app/actions/reception.ts
"text-purple-400">import { createServerClient } "text-purple-400">from '@supabase/ssr'
"text-purple-400">import { cookies } "text-purple-400">from 'next/headers'

"text-purple-400">export "text-purple-400">async "text-purple-400">function createReception(data: ReceptionData) {
  "text-purple-400">const cookieStore = cookies()
  "text-purple-400">const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: { get(name: string) { "text-purple-400">return cookieStore.get(name)?.value } } }
  )

  "text-slate-500">// 1. Verificar autenticación
  "text-purple-400">const { data: { user }, error: authError } = "text-purple-400">await supabase.auth.getUser()
  "text-purple-400">if (authError || !user) "text-purple-400">throw "text-purple-400">new Error('Unauthorized')

  "text-slate-500">// 2. Obtener empresa del perfil (nunca del cliente)
  "text-purple-400">const { data: profile } = "text-purple-400">await supabase
    ."text-purple-400">from('perfiles')
    .select('empresa_id')
    .eq('id', user.id)
    .single()

  "text-slate-500">// 3. Insertar con aislamiento multi-tenant garantizado por RLS
  "text-purple-400">const { data: newReception, error } = "text-purple-400">await supabase
    ."text-purple-400">from('tickets_recepcion')
    .insert([{
      ...data,
      estatus: 'RECIBIDO',
      creado_por: user.id,
      empresa_id: profile.empresa_id "text-slate-500">// Inferido del perfil, nunca del cliente
    }])
    .select()
    .single()

  "text-purple-400">if (error) "text-purple-400">throw "text-purple-400">new Error('Failed to create reception')
  "text-purple-400">return { success: true, data: newReception }
}

PostgreSQL Triggers: automatic consistency

These two triggers are the heart of the automation. update_cantidad_recibidos keeps the PO count in sync in real time. auto_sync_po_status fires the atomic consolidation when the PO is complete — without human intervention.

supabase/triggers.sql
-- Trigger 1: Recalcula ítems recibidos en el PO
-- Se ejecuta en INSERT, UPDATE y DELETE sobre tickets_recepcion
CREATE OR REPLACE FUNCTION update_cantidad_recibidos()
RETURNS TRIGGER AS $$
BEGIN
  IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
    UPDATE paquetes_cliente
    SET cantidad_items_recibidos = (
      SELECT COALESCE(SUM(items_parciales_recibidos), 0)
      FROM tickets_recepcion
      WHERE paquete_cliente_id = NEW.paquete_cliente_id
        AND estatus NOT IN ('CANCELADO')
    )
    WHERE id = NEW.paquete_cliente_id;
    RETURN NEW;
  END IF;

  IF (TG_OP = 'DELETE') THEN
    UPDATE paquetes_cliente
    SET cantidad_items_recibidos = (
      SELECT COALESCE(SUM(items_parciales_recibidos), 0)
      FROM tickets_recepcion
      WHERE paquete_cliente_id = OLD.paquete_cliente_id
        AND estatus NOT IN ('CANCELADO')
    )
    WHERE id = OLD.paquete_cliente_id;
    RETURN OLD;
  END IF;
END;
$$ LANGUAGE plpgsql;

-- Trigger 2: Sincronización automática del estado del PO
-- Cuando el PO está completo, marca todos sus tickets como LISTO_PARA_ENVIAR
CREATE OR REPLACE FUNCTION auto_sync_po_status()
RETURNS TRIGGER AS $$
BEGIN
  IF NEW.ref_cliente_po IS NOT NULL THEN
    PERFORM sync_po_status(NEW.ref_cliente_po, NEW.cliente_id);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

#The impact in numbers

Before vs. after: the same team, the same operation volume, completely different results.

Before
After
2–3 hours per outbound instruction
~20 minutes
Information fragmented across Excel, email and WhatsApp
One system, real time
Cargo statuses updated manually
Automation via DB triggers
Photos and documents in local folders
Centralized in Storage with immediate access
Manual quotation calculation (frequent errors)
Automatic engine with 11 charge concepts
No traceability of who did what
Automatic audit log on every mutation
400+
Packages processed
120+
Outbound instructions
~85%
Reduction in dispatch time
0
Tenant isolation errors

#Tech Stack

Layer
Technology
Why
Framework
Next.js 15 (App Router)
SSR for fast initial load. Server Actions for mutations without an additional API layer.
UI
React 19 + Shadcn/UI + Radix
Accessible components, no opinionated styles. Fast iteration.
Database
Supabase (PostgreSQL 15+)
Auth + DB + Storage + RLS on one platform. Native triggers.
Validation
Zod
Centralized schemas. Server-side validation before touching the DB.
PDFs
jsPDF + autotable
Client-side generation for quotations and export manifests.
Deploy
Vercel
Continuous CI/CD, zero-config on push.

#What's next

🌐

Client portal

Direct access for clients to create pre-alerts and track their cargo in real time — no more emails.

📈

Cost analytics dashboard

Quotation vs. actual invoice comparison, expiry alerts, and financial metrics by period.

📁

Documents & costs per shipment

Unified view of all documentation and supplier costs linked to each dispatch.

#Reflection

This project solved a real problem that the company's team faced every day. Before writing a single line of code, I studied the operation: I mapped the current state using the AS-IS / TO-BE methodology, modeled the processes in BPMN, and identified the exact friction points the system needed to eliminate. Only then did I start building.

I learned more designing VertS's data model than in any tutorial. And I confirmed what industrial engineering already teaches: understanding the process is the work — the tool comes after.

— Jaime Arias · Design, architecture and full implementation · Nov 2025 → Production Feb 2026 → Ongoing evolution

Interested in the technical architecture or a similar solution?

Contact Me