Skip to content

AI Prompt: Forma3D.Connect — Phase 5: Shipping Integration ✅

Purpose: This prompt instructs an AI to implement Phase 5 of Forma3D.Connect
Estimated Effort: 22 hours (~2 weeks)
Prerequisites: Phase 4 completed (Dashboard MVP - order management, product mappings UI, real-time updates)
Output: Sendcloud integration for shipping labels, tracking sync to Shopify, shipping management UI
Status:COMPLETED (January 16, 2026)


🎯 Mission

You are continuing development of Forma3D.Connect, building on the Phase 4 foundation. Your task is to implement Phase 5: Shipping Integration — completing the end-to-end automation by integrating Sendcloud for shipping label generation, syncing tracking information to Shopify, and adding shipping management to the dashboard.

Phase 5 delivers:

  • Sendcloud API client for parcel creation and label generation
  • Automated shipping label generation when orders are ready to ship
  • Tracking information sync to Shopify fulfillments
  • Shipping management UI in the dashboard (labels, tracking, carrier selection)

Phase 5 completes the full automation flow:

Order → Print Job → Print Complete → Shipping Label → Fulfillment → Customer Notified

📋 Phase 5 Context

What Was Built in Previous Phases

The foundation is already in place:

  • Phase 0: Foundation
  • Nx monorepo with apps/api, apps/web, and shared libs
  • PostgreSQL database with Prisma schema (Order, LineItem, PrintJob, ProductMapping, EventLog, RetryQueue)
  • NestJS backend structure with modules, services, repositories
  • Azure DevOps CI/CD pipeline

  • Phase 1: Shopify Inbound

  • Shopify webhooks receiver with HMAC verification
  • Order storage and status management
  • Product mapping CRUD operations
  • Event logging service
  • OpenAPI/Swagger documentation at /api/docs
  • Aikido Security Platform integration

  • Phase 1b: Observability

  • Sentry error tracking and performance monitoring
  • OpenTelemetry-first architecture
  • Structured JSON logging with Pino and correlation IDs
  • React error boundaries

  • Phase 1c: Staging Deployment

  • Docker images with multi-stage builds
  • Traefik reverse proxy with Let's Encrypt TLS
  • Zero-downtime deployments via Docker Compose
  • Staging environment: https://staging-connect.forma3d.be

  • Phase 1d: Acceptance Testing

  • Playwright + Gherkin acceptance tests
  • Given/When/Then scenarios for deployment verification
  • Azure DevOps pipeline integration

  • Phase 2: SimplyPrint Core

  • SimplyPrint API client with HTTP Basic Auth
  • Automated print job creation from orders
  • Print job status monitoring (webhook + polling)
  • Order-job orchestration with order.ready-for-fulfillment event

  • Phase 3: Fulfillment Loop

  • Automated Shopify fulfillment creation
  • Order cancellation handling
  • Retry queue with exponential backoff
  • Email notifications for critical failures
  • API key authentication for admin endpoints

  • Phase 4: Dashboard MVP

  • React 19 dashboard with TanStack Query
  • Order management UI (list, detail, actions)
  • Product mapping configuration UI
  • Real-time updates via Socket.IO
  • Activity logs with filtering and export

What Phase 5 Builds

Feature Description Effort
F5.1: Sendcloud API Client Typed client for Sendcloud API interactions 8 hours
F5.2: Shipping Label Generation Automated label creation on order completion 8 hours
F5.3: Shipping Dashboard UI View and manage shipping labels in dashboard 6 hours

🛠️ Tech Stack Reference

All technologies from Phase 4 remain. No additional packages required for Phase 5 (using standard HTTP client for Sendcloud API).

Package Purpose
axios HTTP client (already installed)
@nestjs/event-emitter Internal event system (already installed)

🏗️ Architecture Reference

Detailed Architecture Diagrams

📐 For detailed architecture, refer to the existing PlantUML diagrams:

Diagram Path Description
Fulfillment Flow docs/03-architecture/sequences/C4_Seq_05_Fulfillment.puml Fulfillment sequence diagram
Container View docs/03-architecture/c4-model/2-container/C4_Container.puml System containers and interactions
Component View docs/03-architecture/c4-model/3-component/C4_Component.puml Backend component architecture
Order State docs/03-architecture/state-machines/C4_Code_State_Order.puml Order status state machine
Domain Model docs/03-architecture/c4-model/4-code/C4_Code_DomainModel.puml Entity relationships

These PlantUML diagrams should be updated as part of Phase 5 completion.

Current Database Schema

The Prisma schema needs extension for shipping:

// Existing Order model (to be updated)
model Order {
  id                   String        @id @default(uuid())
  shopifyOrderId       String        @unique
  shopifyOrderNumber   String
  status               OrderStatus   @default(PENDING)
  customerName         String
  customerEmail        String?
  shippingAddress      Json
  totalPrice           Decimal       @db.Decimal(10, 2)
  currency             String        @default("EUR")
  shopifyFulfillmentId String?
  trackingNumber       String?
  trackingUrl          String?
  createdAt            DateTime      @default(now())
  updatedAt            DateTime      @updatedAt
  completedAt          DateTime?
  lineItems            LineItem[]
  shipment             Shipment?     // NEW: Add relation to Shipment
}

// NEW: Shipment model for Sendcloud integration
model Shipment {
  id                  String          @id @default(uuid())
  orderId             String          @unique
  order               Order           @relation(fields: [orderId], references: [id])
  sendcloudParcelId   Int?            @unique
  status              ShipmentStatus  @default(PENDING)
  shippingMethodId    Int?
  shippingMethodName  String?
  carrierName         String?
  trackingNumber      String?
  trackingUrl         String?
  labelUrl            String?
  weight              Decimal?        @db.Decimal(10, 3)
  dimensions          Json?           // { length, width, height }
  errorMessage        String?
  createdAt           DateTime        @default(now())
  updatedAt           DateTime        @updatedAt
  shippedAt           DateTime?
}

enum ShipmentStatus {
  PENDING
  LABEL_CREATED
  ANNOUNCED
  IN_TRANSIT
  DELIVERED
  FAILED
  CANCELLED
}

Phase 5 Event Flow

                                 ┌─────────────────────────────────┐
                                 │                                 │
                                 ▼                                 │
┌──────────────┐     ┌──────────────────┐     ┌──────────────┐    │
│  All Print   │────▶│    Shipping      │────▶│  Sendcloud   │    │
│ Jobs Done    │     │    Service       │     │ Create Parcel│    │
└──────────────┘     └──────────────────┘     └──────────────┘    │
                              │                       │            │
                              │                       ▼            │
                              │              ┌──────────────┐      │
                              │              │  Get Label   │      │
                              │              │     PDF      │      │
                              │              └──────────────┘      │
                              │                       │            │
                              ▼                       ▼            │
                     ┌──────────────────┐     ┌──────────────┐    │
                     │   Fulfillment    │────▶│   Shopify    │    │
                     │    Service       │     │  + Tracking  │    │
                     └──────────────────┘     └──────────────┘    │
                              │                                    │
                              │ (on failure)                       │
                              ▼                                    │
                     ┌──────────────────┐                          │
                     │   Retry Queue    │──────────────────────────┘
                     └──────────────────┘

Sendcloud Integration Flow

┌──────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│  Order Ready     │────▶│   Get Shipping   │────▶│   Create Parcel  │
│  to Ship         │     │   Method         │     │   (Sendcloud)    │
└──────────────────┘     └──────────────────┘     └──────────────────┘
                                                           │
         ┌─────────────────────────────────────────────────┤
         │                                                 │
         ▼                                                 ▼
┌──────────────────┐                              ┌──────────────────┐
│  Store Tracking  │                              │  Download Label  │
│  Information     │                              │  PDF             │
└──────────────────┘                              └──────────────────┘
         │                                                 │
         └─────────────────────┬───────────────────────────┘
                               │
                               ▼
                      ┌──────────────────┐
                      │  Create Shopify  │
                      │  Fulfillment     │
                      │  + Tracking      │
                      └──────────────────┘

📁 Files to Create/Modify

Backend (apps/api)

apps/api/src/
├── sendcloud/
│   ├── sendcloud.module.ts              # Module definition
│   ├── sendcloud-api.client.ts          # Sendcloud API client
│   ├── sendcloud.service.ts             # Shipping business logic
│   ├── sendcloud.controller.ts          # REST endpoints
│   ├── dto/
│   │   ├── create-parcel.dto.ts         # Create parcel DTO
│   │   ├── parcel-response.dto.ts       # Parcel response DTO
│   │   ├── shipping-method.dto.ts       # Shipping method DTO
│   │   └── shipment.dto.ts              # Shipment response DTO
│   ├── events/
│   │   └── shipment.events.ts           # Shipment event definitions
│   └── __tests__/
│       ├── sendcloud-api.client.spec.ts
│       └── sendcloud.service.spec.ts
│
├── shipments/
│   ├── shipments.module.ts              # Module definition
│   ├── shipments.repository.ts          # Shipment database operations
│   ├── shipments.service.ts             # Shipment management logic
│   ├── shipments.controller.ts          # REST endpoints
│   ├── dto/
│   │   ├── shipment-query.dto.ts        # Query params DTO
│   │   └── update-shipment.dto.ts       # Update shipment DTO
│   └── __tests__/
│       └── shipments.service.spec.ts
│
├── fulfillment/
│   └── fulfillment.service.ts           # UPDATE: Integrate shipping

prisma/
└── schema.prisma                         # UPDATE: Add Shipment model

prisma/migrations/
└── YYYYMMDD_add_shipment/               # Migration for shipment table

Frontend (apps/web)

apps/web/src/
├── hooks/
│   └── use-shipments.ts                 # Shipment data hooks
│
├── pages/
│   └── orders/
│       └── [id].tsx                     # UPDATE: Add shipping section
│
├── components/
│   └── orders/
│       ├── shipping-info.tsx            # Shipping info display
│       ├── shipping-label-button.tsx    # Download label button
│       └── shipping-method-selector.tsx # Shipping method selector
│
├── lib/
│   └── api-client.ts                    # UPDATE: Add shipping endpoints

Shared Library Updates

libs/api-client/src/
├── sendcloud/
│   ├── sendcloud.types.ts               # Sendcloud API types
│   └── sendcloud.client.ts              # Typed sendcloud client
├── shipments/
│   └── shipments.client.ts              # Typed shipments API client

🔧 Feature F5.1: Sendcloud API Client

Requirements Reference

  • FR-SC-001: Generate Shipping Labels via Sendcloud
  • FR-SC-002: Tracking Information Sync

Implementation

1. Environment Variables

Add to .env.example:

# Sendcloud
SENDCLOUD_PUBLIC_KEY=your-sendcloud-public-key
SENDCLOUD_SECRET_KEY=your-sendcloud-secret-key
SENDCLOUD_API_URL=https://panel.sendcloud.sc/api/v2

# Shipping defaults
DEFAULT_SHIPPING_METHOD_ID=8       # PostNL Standard
DEFAULT_SENDER_ADDRESS_ID=12345    # Your sender address ID
SHIPPING_ENABLED=true

2. Sendcloud Types

Create libs/api-client/src/sendcloud/sendcloud.types.ts:

/**
 * Sendcloud API Types
 * @see https://api.sendcloud.dev/docs/sendcloud-public-api
 */

export interface SendcloudAddress {
  name: string;
  company_name?: string;
  address: string;
  address_2?: string;
  house_number?: string;
  city: string;
  postal_code: string;
  country: string; // ISO 3166-1 alpha-2 (e.g., "NL", "BE")
  telephone?: string;
  email?: string;
}

export interface SendcloudParcelInput {
  name: string;
  company_name?: string;
  address: string;
  address_2?: string;
  house_number?: string;
  city: string;
  postal_code: string;
  country: string;
  telephone?: string;
  email?: string;
  order_number?: string;
  external_reference?: string;
  shipment?: {
    id: number; // Shipping method ID
  };
  weight?: string; // Weight in kg (e.g., "1.5")
  length?: number;
  width?: number;
  height?: number;
  request_label?: boolean;
  apply_shipping_rules?: boolean;
  sender_address?: number; // Sender address ID
}

export interface SendcloudParcel {
  id: number;
  name: string;
  company_name: string | null;
  address: string;
  address_2: string | null;
  house_number: string | null;
  city: string;
  postal_code: string;
  country: {
    iso_2: string;
    iso_3: string;
    name: string;
  };
  email: string | null;
  telephone: string | null;
  status: {
    id: number;
    message: string;
  };
  tracking_number: string | null;
  tracking_url: string | null;
  carrier: {
    code: string;
  } | null;
  shipment: {
    id: number;
    name: string;
  } | null;
  label: {
    normal_printer: string[];
    label_printer: string | null;
  } | null;
  order_number: string | null;
  external_reference: string | null;
  weight: string;
  created_at: string;
  updated_at: string;
}

export interface SendcloudShippingMethod {
  id: number;
  name: string;
  carrier: string;
  min_weight: number;
  max_weight: number;
  service_point_input: string;
  price: number;
  countries: Array<{
    id: number;
    iso_2: string;
    iso_3: string;
    name: string;
    price: number;
  }>;
}

export interface SendcloudLabelFormat {
  normal_printer: string[]; // Array of PDF URLs
  label_printer: string | null;
}

export interface SendcloudError {
  error: {
    code: number;
    message: string;
    request?: string;
  };
}

export interface CreateParcelResponse {
  parcel: SendcloudParcel;
}

export interface GetParcelResponse {
  parcel: SendcloudParcel;
}

export interface GetShippingMethodsResponse {
  shipping_methods: SendcloudShippingMethod[];
}

export interface GetLabelResponse {
  label: SendcloudLabelFormat;
}

3. Sendcloud API Client

Create apps/api/src/sendcloud/sendcloud-api.client.ts:

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance, AxiosError } from 'axios';
import * as Sentry from '@sentry/nestjs';
import {
  SendcloudParcelInput,
  SendcloudParcel,
  SendcloudShippingMethod,
  CreateParcelResponse,
  GetParcelResponse,
  GetShippingMethodsResponse,
  SendcloudError,
} from '@forma3d/api-client';

interface SendcloudApiClientConfig {
  publicKey: string;
  secretKey: string;
  apiUrl: string;
}

@Injectable()
export class SendcloudApiClient implements OnModuleInit {
  private readonly logger = new Logger(SendcloudApiClient.name);
  private client: AxiosInstance;
  private config: SendcloudApiClientConfig;
  private isEnabled: boolean;

  constructor(private readonly configService: ConfigService) {}

  async onModuleInit(): Promise<void> {
    this.isEnabled = this.configService.get<boolean>('SHIPPING_ENABLED', false);

    if (!this.isEnabled) {
      this.logger.warn('Sendcloud integration is disabled');
      return;
    }

    this.config = {
      publicKey: this.configService.getOrThrow<string>('SENDCLOUD_PUBLIC_KEY'),
      secretKey: this.configService.getOrThrow<string>('SENDCLOUD_SECRET_KEY'),
      apiUrl: this.configService.get<string>(
        'SENDCLOUD_API_URL',
        'https://panel.sendcloud.sc/api/v2'
      ),
    };

    // Create HTTP client with Basic Auth
    const auth = Buffer.from(`${this.config.publicKey}:${this.config.secretKey}`).toString(
      'base64'
    );

    this.client = axios.create({
      baseURL: this.config.apiUrl,
      headers: {
        Authorization: `Basic ${auth}`,
        'Content-Type': 'application/json',
      },
      timeout: 30000,
    });

    // Add response interceptor for error handling
    this.client.interceptors.response.use(
      (response) => response,
      (error: AxiosError<SendcloudError>) => {
        this.handleApiError(error);
        throw error;
      }
    );

    this.logger.log('Sendcloud API client initialized');
  }

  /**
   * Check if Sendcloud is enabled
   */
  isShippingEnabled(): boolean {
    return this.isEnabled;
  }

  /**
   * Create a parcel and request label
   */
  async createParcel(input: SendcloudParcelInput): Promise<SendcloudParcel> {
    this.ensureEnabled();

    this.logger.log(`Creating parcel for order: ${input.order_number}`);

    try {
      const response = await this.client.post<CreateParcelResponse>('/parcels', {
        parcel: {
          ...input,
          request_label: true,
        },
      });

      this.logger.log(`Parcel created: ${response.data.parcel.id}`);
      return response.data.parcel;
    } catch (error) {
      this.logger.error(`Failed to create parcel: ${this.getErrorMessage(error)}`);
      throw error;
    }
  }

  /**
   * Get parcel by ID
   */
  async getParcel(parcelId: number): Promise<SendcloudParcel> {
    this.ensureEnabled();

    const response = await this.client.get<GetParcelResponse>(`/parcels/${parcelId}`);
    return response.data.parcel;
  }

  /**
   * Get parcel label URLs
   */
  async getLabel(parcelId: number): Promise<string[]> {
    this.ensureEnabled();

    const parcel = await this.getParcel(parcelId);

    if (!parcel.label?.normal_printer?.length) {
      throw new Error(`No label available for parcel ${parcelId}`);
    }

    return parcel.label.normal_printer;
  }

  /**
   * Cancel a parcel
   */
  async cancelParcel(parcelId: number): Promise<void> {
    this.ensureEnabled();

    this.logger.log(`Cancelling parcel: ${parcelId}`);

    try {
      await this.client.post(`/parcels/${parcelId}/cancel`);
      this.logger.log(`Parcel cancelled: ${parcelId}`);
    } catch (error) {
      this.logger.error(`Failed to cancel parcel: ${this.getErrorMessage(error)}`);
      throw error;
    }
  }

  /**
   * Get available shipping methods
   */
  async getShippingMethods(
    senderAddressId?: number,
    toCountry?: string
  ): Promise<SendcloudShippingMethod[]> {
    this.ensureEnabled();

    const params: Record<string, unknown> = {};
    if (senderAddressId) params.sender_address = senderAddressId;
    if (toCountry) params.to_country = toCountry;

    const response = await this.client.get<GetShippingMethodsResponse>('/shipping_methods', {
      params,
    });

    return response.data.shipping_methods;
  }

  /**
   * Get tracking information for a parcel
   */
  async getTracking(parcelId: number): Promise<{
    trackingNumber: string | null;
    trackingUrl: string | null;
    status: { id: number; message: string };
  }> {
    this.ensureEnabled();

    const parcel = await this.getParcel(parcelId);

    return {
      trackingNumber: parcel.tracking_number,
      trackingUrl: parcel.tracking_url,
      status: parcel.status,
    };
  }

  /**
   * Ensure Sendcloud is enabled
   */
  private ensureEnabled(): void {
    if (!this.isEnabled) {
      throw new Error('Sendcloud integration is disabled');
    }
  }

  /**
   * Handle API errors
   */
  private handleApiError(error: AxiosError<SendcloudError>): void {
    const errorData = error.response?.data?.error;

    if (errorData) {
      this.logger.error(`Sendcloud API error: [${errorData.code}] ${errorData.message}`);

      Sentry.captureException(error, {
        tags: { service: 'sendcloud', action: 'api-call' },
        extra: {
          code: errorData.code,
          message: errorData.message,
          request: errorData.request,
        },
      });
    }
  }

  /**
   * Get error message from error
   */
  private getErrorMessage(error: unknown): string {
    if (axios.isAxiosError(error)) {
      return error.response?.data?.error?.message || error.message;
    }
    return error instanceof Error ? error.message : 'Unknown error';
  }
}

4. Sendcloud Module

Create apps/api/src/sendcloud/sendcloud.module.ts:

import { Module } from '@nestjs/common';
import { SendcloudApiClient } from './sendcloud-api.client';
import { SendcloudService } from './sendcloud.service';
import { SendcloudController } from './sendcloud.controller';
import { ShipmentsModule } from '../shipments/shipments.module';
import { OrdersModule } from '../orders/orders.module';
import { EventLogModule } from '../event-log/event-log.module';
import { RetryQueueModule } from '../retry-queue/retry-queue.module';

@Module({
  imports: [ShipmentsModule, OrdersModule, EventLogModule, RetryQueueModule],
  controllers: [SendcloudController],
  providers: [SendcloudApiClient, SendcloudService],
  exports: [SendcloudApiClient, SendcloudService],
})
export class SendcloudModule {}

🔧 Feature F5.2: Shipping Label Generation

Requirements Reference

  • FR-SC-001: Generate Shipping Labels via Sendcloud
  • NFR-PE-002: Fulfillment Latency (< 60 seconds)

Implementation

1. Update Prisma Schema

Update prisma/schema.prisma to add Shipment model:

model Order {
  id                   String        @id @default(uuid())
  shopifyOrderId       String        @unique
  shopifyOrderNumber   String
  status               OrderStatus   @default(PENDING)
  customerName         String
  customerEmail        String?
  shippingAddress      Json
  totalPrice           Decimal       @db.Decimal(10, 2)
  currency             String        @default("EUR")
  shopifyFulfillmentId String?
  trackingNumber       String?
  trackingUrl          String?
  createdAt            DateTime      @default(now())
  updatedAt            DateTime      @updatedAt
  completedAt          DateTime?
  lineItems            LineItem[]
  shipment             Shipment?
}

model Shipment {
  id                  String          @id @default(uuid())
  orderId             String          @unique
  order               Order           @relation(fields: [orderId], references: [id], onDelete: Cascade)
  sendcloudParcelId   Int?            @unique
  status              ShipmentStatus  @default(PENDING)
  shippingMethodId    Int?
  shippingMethodName  String?
  carrierName         String?
  trackingNumber      String?
  trackingUrl         String?
  labelUrl            String?
  weight              Decimal?        @db.Decimal(10, 3)
  dimensions          Json?
  errorMessage        String?
  createdAt           DateTime        @default(now())
  updatedAt           DateTime        @updatedAt
  shippedAt           DateTime?

  @@index([status])
  @@index([createdAt])
}

enum ShipmentStatus {
  PENDING
  LABEL_CREATED
  ANNOUNCED
  IN_TRANSIT
  DELIVERED
  FAILED
  CANCELLED
}

Run migration:

pnpm prisma migrate dev --name add_shipment

2. Shipment Events

Create apps/api/src/sendcloud/events/shipment.events.ts:

import { Order, Shipment } from '@prisma/client';

export const SHIPMENT_EVENTS = {
  CREATED: 'shipment.created',
  LABEL_READY: 'shipment.label-ready',
  FAILED: 'shipment.failed',
  UPDATED: 'shipment.updated',
} as const;

export class ShipmentCreatedEvent {
  constructor(
    public readonly shipment: Shipment,
    public readonly order: Order
  ) {}
}

export class ShipmentLabelReadyEvent {
  constructor(
    public readonly shipment: Shipment,
    public readonly labelUrl: string
  ) {}
}

export class ShipmentFailedEvent {
  constructor(
    public readonly orderId: string,
    public readonly error: string,
    public readonly willRetry: boolean
  ) {}
}

3. Shipments Repository

Create apps/api/src/shipments/shipments.repository.ts:

import { Injectable, Logger } from '@nestjs/common';
import { Prisma, Shipment, ShipmentStatus } from '@prisma/client';
import { PrismaService } from '../database/prisma.service';

@Injectable()
export class ShipmentsRepository {
  private readonly logger = new Logger(ShipmentsRepository.name);

  constructor(private readonly prisma: PrismaService) {}

  async create(data: Prisma.ShipmentCreateInput): Promise<Shipment> {
    return this.prisma.shipment.create({ data });
  }

  async findById(id: string): Promise<Shipment | null> {
    return this.prisma.shipment.findUnique({ where: { id } });
  }

  async findByOrderId(orderId: string): Promise<Shipment | null> {
    return this.prisma.shipment.findUnique({ where: { orderId } });
  }

  async findBySendcloudParcelId(sendcloudParcelId: number): Promise<Shipment | null> {
    return this.prisma.shipment.findUnique({ where: { sendcloudParcelId } });
  }

  async update(id: string, data: Prisma.ShipmentUpdateInput): Promise<Shipment> {
    return this.prisma.shipment.update({ where: { id }, data });
  }

  async updateByOrderId(orderId: string, data: Prisma.ShipmentUpdateInput): Promise<Shipment> {
    return this.prisma.shipment.update({ where: { orderId }, data });
  }

  async updateStatus(id: string, status: ShipmentStatus): Promise<Shipment> {
    return this.update(id, { status });
  }

  async findMany(params: {
    status?: ShipmentStatus;
    page?: number;
    pageSize?: number;
  }): Promise<{ shipments: Shipment[]; total: number }> {
    const { status, page = 1, pageSize = 50 } = params;

    const where: Prisma.ShipmentWhereInput = {};
    if (status) where.status = status;

    const [shipments, total] = await Promise.all([
      this.prisma.shipment.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * pageSize,
        take: pageSize,
        include: {
          order: { select: { shopifyOrderNumber: true, customerName: true } },
        },
      }),
      this.prisma.shipment.count({ where }),
    ]);

    return { shipments, total };
  }
}

4. Sendcloud Service

Create apps/api/src/sendcloud/sendcloud.service.ts:

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ConfigService } from '@nestjs/config';
import { Order, Shipment, ShipmentStatus, OrderStatus } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
import { SendcloudApiClient } from './sendcloud-api.client';
import { ShipmentsRepository } from '../shipments/shipments.repository';
import { OrdersRepository } from '../orders/orders.repository';
import { EventLogService } from '../event-log/event-log.service';
import { RetryQueueService } from '../retry-queue/retry-queue.service';
import { ORDER_EVENTS, OrderReadyForFulfillmentEvent } from '../orders/events/order.events';
import {
  SHIPMENT_EVENTS,
  ShipmentCreatedEvent,
  ShipmentLabelReadyEvent,
  ShipmentFailedEvent,
} from './events/shipment.events';
import { SendcloudParcelInput } from '@forma3d/api-client';

interface ShippingAddress {
  first_name?: string;
  last_name?: string;
  name?: string;
  company?: string;
  address1: string;
  address2?: string;
  city: string;
  zip: string;
  country_code: string;
  phone?: string;
}

@Injectable()
export class SendcloudService {
  private readonly logger = new Logger(SendcloudService.name);
  private readonly defaultShippingMethodId: number;
  private readonly defaultSenderAddressId: number;

  constructor(
    private readonly sendcloudClient: SendcloudApiClient,
    private readonly shipmentsRepository: ShipmentsRepository,
    private readonly ordersRepository: OrdersRepository,
    private readonly eventLogService: EventLogService,
    private readonly retryQueueService: RetryQueueService,
    private readonly eventEmitter: EventEmitter2,
    private readonly configService: ConfigService
  ) {
    this.defaultShippingMethodId = this.configService.get<number>('DEFAULT_SHIPPING_METHOD_ID', 8);
    this.defaultSenderAddressId = this.configService.get<number>('DEFAULT_SENDER_ADDRESS_ID', 0);
  }

  /**
   * Listen for order.ready-for-fulfillment events
   * Create shipping label before fulfillment
   */
  @OnEvent(ORDER_EVENTS.READY_FOR_FULFILLMENT, { async: true })
  async handleOrderReadyForFulfillment(event: OrderReadyForFulfillmentEvent): Promise<void> {
    if (!this.sendcloudClient.isShippingEnabled()) {
      this.logger.debug('Shipping disabled, skipping label generation');
      return;
    }

    this.logger.log(`Creating shipping label for order: ${event.order.id}`);
    await this.createShipment(event.order.id);
  }

  /**
   * Create shipment and generate label
   */
  async createShipment(orderId: string, shippingMethodId?: number): Promise<Shipment> {
    const order = await this.ordersRepository.findById(orderId);
    if (!order) {
      throw new NotFoundException(`Order not found: ${orderId}`);
    }

    // Check if shipment already exists
    const existingShipment = await this.shipmentsRepository.findByOrderId(orderId);
    if (existingShipment?.sendcloudParcelId) {
      this.logger.warn(`Shipment already exists for order ${orderId}`);
      return existingShipment;
    }

    try {
      // Parse shipping address
      const shippingAddress = order.shippingAddress as ShippingAddress;

      // Build parcel input
      const parcelInput: SendcloudParcelInput = {
        name: this.buildRecipientName(shippingAddress),
        company_name: shippingAddress.company || undefined,
        address: shippingAddress.address1,
        address_2: shippingAddress.address2 || undefined,
        city: shippingAddress.city,
        postal_code: shippingAddress.zip,
        country: shippingAddress.country_code,
        telephone: shippingAddress.phone || undefined,
        email: order.customerEmail || undefined,
        order_number: order.shopifyOrderNumber,
        external_reference: order.id,
        shipment: {
          id: shippingMethodId || this.defaultShippingMethodId,
        },
        weight: '1.0', // Default weight, can be enhanced later
        request_label: true,
        sender_address: this.defaultSenderAddressId || undefined,
      };

      // Create parcel in Sendcloud
      const parcel = await this.sendcloudClient.createParcel(parcelInput);

      // Create or update shipment record
      const shipment = existingShipment
        ? await this.shipmentsRepository.update(existingShipment.id, {
            sendcloudParcelId: parcel.id,
            status: ShipmentStatus.LABEL_CREATED,
            shippingMethodId: parcel.shipment?.id,
            shippingMethodName: parcel.shipment?.name,
            carrierName: parcel.carrier?.code,
            trackingNumber: parcel.tracking_number,
            trackingUrl: parcel.tracking_url,
            labelUrl: parcel.label?.normal_printer?.[0],
          })
        : await this.shipmentsRepository.create({
            order: { connect: { id: orderId } },
            sendcloudParcelId: parcel.id,
            status: ShipmentStatus.LABEL_CREATED,
            shippingMethodId: parcel.shipment?.id,
            shippingMethodName: parcel.shipment?.name,
            carrierName: parcel.carrier?.code,
            trackingNumber: parcel.tracking_number,
            trackingUrl: parcel.tracking_url,
            labelUrl: parcel.label?.normal_printer?.[0],
          });

      // Update order with tracking info
      await this.ordersRepository.update(orderId, {
        trackingNumber: parcel.tracking_number,
        trackingUrl: parcel.tracking_url,
      });

      // Log success
      await this.eventLogService.log({
        orderId,
        eventType: 'SHIPPING_LABEL_CREATED',
        severity: 'INFO',
        message: `Shipping label created via Sendcloud`,
        metadata: {
          sendcloudParcelId: parcel.id,
          trackingNumber: parcel.tracking_number,
          carrier: parcel.carrier?.code,
        },
      });

      // Emit events
      this.eventEmitter.emit(SHIPMENT_EVENTS.CREATED, new ShipmentCreatedEvent(shipment, order));

      if (shipment.labelUrl) {
        this.eventEmitter.emit(
          SHIPMENT_EVENTS.LABEL_READY,
          new ShipmentLabelReadyEvent(shipment, shipment.labelUrl)
        );
      }

      this.logger.log(
        `Shipping label created for order ${order.shopifyOrderNumber}: ${parcel.tracking_number}`
      );

      return shipment;
    } catch (error) {
      await this.handleShipmentError(order, error);
      throw error;
    }
  }

  /**
   * Get shipping label URL
   */
  async getLabelUrl(orderId: string): Promise<string> {
    const shipment = await this.shipmentsRepository.findByOrderId(orderId);
    if (!shipment) {
      throw new NotFoundException(`Shipment not found for order: ${orderId}`);
    }

    if (shipment.labelUrl) {
      return shipment.labelUrl;
    }

    if (!shipment.sendcloudParcelId) {
      throw new Error('No Sendcloud parcel ID found');
    }

    // Fetch label from Sendcloud
    const labels = await this.sendcloudClient.getLabel(shipment.sendcloudParcelId);
    const labelUrl = labels[0];

    // Update shipment with label URL
    await this.shipmentsRepository.update(shipment.id, { labelUrl });

    return labelUrl;
  }

  /**
   * Cancel shipment
   */
  async cancelShipment(orderId: string): Promise<void> {
    const shipment = await this.shipmentsRepository.findByOrderId(orderId);
    if (!shipment) {
      throw new NotFoundException(`Shipment not found for order: ${orderId}`);
    }

    if (shipment.sendcloudParcelId) {
      try {
        await this.sendcloudClient.cancelParcel(shipment.sendcloudParcelId);
      } catch (error) {
        this.logger.error(`Failed to cancel parcel in Sendcloud: ${error.message}`);
        // Continue to update local status even if Sendcloud fails
      }
    }

    await this.shipmentsRepository.updateStatus(shipment.id, ShipmentStatus.CANCELLED);

    await this.eventLogService.log({
      orderId,
      eventType: 'SHIPMENT_CANCELLED',
      severity: 'INFO',
      message: 'Shipment cancelled',
    });
  }

  /**
   * Get available shipping methods for a destination
   */
  async getShippingMethods(
    toCountry: string
  ): Promise<Array<{ id: number; name: string; carrier: string; price: number }>> {
    const methods = await this.sendcloudClient.getShippingMethods(
      this.defaultSenderAddressId,
      toCountry
    );

    return methods.map((method) => ({
      id: method.id,
      name: method.name,
      carrier: method.carrier,
      price: method.price,
    }));
  }

  /**
   * Regenerate label for shipment
   */
  async regenerateLabel(orderId: string, shippingMethodId?: number): Promise<Shipment> {
    const existingShipment = await this.shipmentsRepository.findByOrderId(orderId);

    // Cancel existing parcel if it exists
    if (existingShipment?.sendcloudParcelId) {
      try {
        await this.sendcloudClient.cancelParcel(existingShipment.sendcloudParcelId);
      } catch (error) {
        this.logger.warn(`Could not cancel existing parcel: ${error.message}`);
      }
    }

    // Create new shipment
    return this.createShipment(orderId, shippingMethodId);
  }

  /**
   * Build recipient name from shipping address
   */
  private buildRecipientName(address: ShippingAddress): string {
    if (address.name) return address.name;
    const parts = [address.first_name, address.last_name].filter(Boolean);
    return parts.join(' ') || 'Customer';
  }

  /**
   * Handle shipment creation errors
   */
  private async handleShipmentError(order: Order, error: unknown): Promise<void> {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error';

    this.logger.error(`Shipment creation failed for order ${order.id}: ${errorMessage}`);

    // Check if error is retryable
    const isRetryable = this.isRetryableError(error);

    if (isRetryable) {
      await this.retryQueueService.enqueue({
        jobType: 'NOTIFICATION', // Using existing type, ideally add SHIPMENT type
        payload: { orderId: order.id, action: 'create_shipment' },
        maxAttempts: 3,
      });

      await this.eventLogService.log({
        orderId: order.id,
        eventType: 'SHIPMENT_RETRY_SCHEDULED',
        severity: 'WARNING',
        message: `Shipment failed, scheduled for retry: ${errorMessage}`,
      });

      this.eventEmitter.emit(
        SHIPMENT_EVENTS.FAILED,
        new ShipmentFailedEvent(order.id, errorMessage, true)
      );
    } else {
      await this.eventLogService.log({
        orderId: order.id,
        eventType: 'SHIPMENT_FAILED_PERMANENT',
        severity: 'ERROR',
        message: `Shipment permanently failed: ${errorMessage}`,
        metadata: { requiresAttention: true },
      });

      this.eventEmitter.emit(
        SHIPMENT_EVENTS.FAILED,
        new ShipmentFailedEvent(order.id, errorMessage, false)
      );

      Sentry.captureException(error, {
        tags: { service: 'sendcloud', action: 'create-shipment' },
        extra: { orderId: order.id },
      });
    }
  }

  /**
   * Determine if error is retryable
   */
  private isRetryableError(error: unknown): boolean {
    if (error instanceof Error) {
      const message = error.message.toLowerCase();
      return ['timeout', 'rate limit', '503', '429', 'econnreset'].some((pattern) =>
        message.includes(pattern)
      );
    }
    return false;
  }
}

5. Sendcloud Controller

Create apps/api/src/sendcloud/sendcloud.controller.ts:

import {
  Controller,
  Get,
  Post,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  Logger,
  UseGuards,
} from '@nestjs/common';
import {
  ApiTags,
  ApiOperation,
  ApiResponse,
  ApiParam,
  ApiQuery,
  ApiBearerAuth,
} from '@nestjs/swagger';
import { SendcloudService } from './sendcloud.service';
import { SendcloudApiClient } from './sendcloud-api.client';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';

@ApiTags('Shipping')
@Controller('api/v1/shipping')
export class SendcloudController {
  private readonly logger = new Logger(SendcloudController.name);

  constructor(
    private readonly sendcloudService: SendcloudService,
    private readonly sendcloudClient: SendcloudApiClient
  ) {}

  @Get('methods')
  @ApiOperation({ summary: 'Get available shipping methods' })
  @ApiQuery({
    name: 'country',
    required: false,
    description: 'Destination country ISO code',
  })
  @ApiResponse({ status: 200, description: 'Shipping methods retrieved' })
  async getShippingMethods(@Query('country') country?: string) {
    return this.sendcloudService.getShippingMethods(country || 'BE');
  }

  @Post('order/:orderId/label')
  @HttpCode(HttpStatus.OK)
  @UseGuards(ApiKeyGuard)
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Create shipping label for order' })
  @ApiParam({ name: 'orderId', description: 'Order ID' })
  @ApiResponse({ status: 200, description: 'Shipping label created' })
  @ApiResponse({ status: 404, description: 'Order not found' })
  async createLabel(
    @Param('orderId') orderId: string,
    @Query('shippingMethodId') shippingMethodId?: string
  ) {
    const shipment = await this.sendcloudService.createShipment(
      orderId,
      shippingMethodId ? parseInt(shippingMethodId, 10) : undefined
    );

    return {
      success: true,
      shipment: {
        id: shipment.id,
        trackingNumber: shipment.trackingNumber,
        trackingUrl: shipment.trackingUrl,
        labelUrl: shipment.labelUrl,
        carrier: shipment.carrierName,
      },
    };
  }

  @Get('order/:orderId/label')
  @ApiOperation({ summary: 'Get shipping label URL for order' })
  @ApiParam({ name: 'orderId', description: 'Order ID' })
  @ApiResponse({ status: 200, description: 'Label URL retrieved' })
  @ApiResponse({ status: 404, description: 'Shipment not found' })
  async getLabelUrl(@Param('orderId') orderId: string) {
    const labelUrl = await this.sendcloudService.getLabelUrl(orderId);
    return { labelUrl };
  }

  @Post('order/:orderId/regenerate')
  @HttpCode(HttpStatus.OK)
  @UseGuards(ApiKeyGuard)
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Regenerate shipping label' })
  @ApiParam({ name: 'orderId', description: 'Order ID' })
  @ApiResponse({ status: 200, description: 'Label regenerated' })
  async regenerateLabel(
    @Param('orderId') orderId: string,
    @Query('shippingMethodId') shippingMethodId?: string
  ) {
    const shipment = await this.sendcloudService.regenerateLabel(
      orderId,
      shippingMethodId ? parseInt(shippingMethodId, 10) : undefined
    );

    return {
      success: true,
      shipment: {
        id: shipment.id,
        trackingNumber: shipment.trackingNumber,
        trackingUrl: shipment.trackingUrl,
        labelUrl: shipment.labelUrl,
      },
    };
  }

  @Post('order/:orderId/cancel')
  @HttpCode(HttpStatus.OK)
  @UseGuards(ApiKeyGuard)
  @ApiBearerAuth()
  @ApiOperation({ summary: 'Cancel shipment' })
  @ApiParam({ name: 'orderId', description: 'Order ID' })
  @ApiResponse({ status: 200, description: 'Shipment cancelled' })
  async cancelShipment(@Param('orderId') orderId: string) {
    await this.sendcloudService.cancelShipment(orderId);
    return { success: true, message: 'Shipment cancelled' };
  }

  @Get('status')
  @ApiOperation({ summary: 'Check Sendcloud integration status' })
  @ApiResponse({ status: 200, description: 'Integration status' })
  async getStatus() {
    return {
      enabled: this.sendcloudClient.isShippingEnabled(),
    };
  }
}

🔧 Feature F5.3: Shipping Dashboard UI

Requirements Reference

  • FR-AD-002: Order Detail View (with shipping)
  • UI-001: Dashboard Layout

Implementation

1. Shipments Hook

Create apps/web/src/hooks/use-shipments.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/api-client';
import { useAuth } from '../contexts/auth-context';

export interface Shipment {
  id: string;
  orderId: string;
  sendcloudParcelId: number | null;
  status: string;
  shippingMethodId: number | null;
  shippingMethodName: string | null;
  carrierName: string | null;
  trackingNumber: string | null;
  trackingUrl: string | null;
  labelUrl: string | null;
  createdAt: string;
  shippedAt: string | null;
}

export interface ShippingMethod {
  id: number;
  name: string;
  carrier: string;
  price: number;
}

export function useShipment(orderId: string) {
  return useQuery<Shipment | null>({
    queryKey: ['shipments', orderId],
    queryFn: () => apiClient.getShipment(orderId),
    enabled: !!orderId,
  });
}

export function useShippingMethods(country = 'BE') {
  return useQuery<ShippingMethod[]>({
    queryKey: ['shipping-methods', country],
    queryFn: () => apiClient.getShippingMethods(country),
  });
}

export function useCreateLabel() {
  const queryClient = useQueryClient();
  const { apiKey } = useAuth();

  return useMutation({
    mutationFn: ({ orderId, shippingMethodId }: { orderId: string; shippingMethodId?: number }) =>
      apiClient.createShippingLabel(orderId, shippingMethodId, apiKey),
    onSuccess: (_, { orderId }) => {
      queryClient.invalidateQueries({ queryKey: ['shipments', orderId] });
      queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
    },
  });
}

export function useRegenerateLabel() {
  const queryClient = useQueryClient();
  const { apiKey } = useAuth();

  return useMutation({
    mutationFn: ({ orderId, shippingMethodId }: { orderId: string; shippingMethodId?: number }) =>
      apiClient.regenerateShippingLabel(orderId, shippingMethodId, apiKey),
    onSuccess: (_, { orderId }) => {
      queryClient.invalidateQueries({ queryKey: ['shipments', orderId] });
      queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
    },
  });
}

export function useCancelShipment() {
  const queryClient = useQueryClient();
  const { apiKey } = useAuth();

  return useMutation({
    mutationFn: (orderId: string) => apiClient.cancelShipment(orderId, apiKey),
    onSuccess: (_, orderId) => {
      queryClient.invalidateQueries({ queryKey: ['shipments', orderId] });
      queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
    },
  });
}

2. Shipping Info Component

Create apps/web/src/components/orders/shipping-info.tsx:

import { useState } from "react";
import {
  TruckIcon,
  ArrowDownTrayIcon,
  ArrowPathIcon,
  XMarkIcon,
} from "@heroicons/react/24/outline";
import toast from "react-hot-toast";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import {
  useShipment,
  useShippingMethods,
  useCreateLabel,
  useRegenerateLabel,
  useCancelShipment,
} from "../../hooks/use-shipments";

interface ShippingInfoProps {
  orderId: string;
  orderStatus: string;
  shippingAddress: Record<string, unknown>;
}

const SHIPMENT_STATUS_COLORS: Record<
  string,
  "default" | "success" | "warning" | "danger" | "info"
> = {
  PENDING: "default",
  LABEL_CREATED: "info",
  ANNOUNCED: "info",
  IN_TRANSIT: "warning",
  DELIVERED: "success",
  FAILED: "danger",
  CANCELLED: "default",
};

export function ShippingInfo({
  orderId,
  orderStatus,
  shippingAddress,
}: ShippingInfoProps) {
  const [selectedMethod, setSelectedMethod] = useState<number | undefined>();

  const { data: shipment, isLoading } = useShipment(orderId);
  const { data: methods } = useShippingMethods(
    (shippingAddress as { country_code?: string })?.country_code || "BE"
  );

  const createLabelMutation = useCreateLabel();
  const regenerateMutation = useRegenerateLabel();
  const cancelMutation = useCancelShipment();

  const handleCreateLabel = async () => {
    try {
      await createLabelMutation.mutateAsync({
        orderId,
        shippingMethodId: selectedMethod,
      });
      toast.success("Shipping label created");
    } catch (error) {
      toast.error("Failed to create shipping label");
    }
  };

  const handleRegenerateLabel = async () => {
    if (
      !window.confirm(
        "Regenerate shipping label? The existing label will be cancelled."
      )
    ) {
      return;
    }

    try {
      await regenerateMutation.mutateAsync({
        orderId,
        shippingMethodId: selectedMethod,
      });
      toast.success("Shipping label regenerated");
    } catch (error) {
      toast.error("Failed to regenerate label");
    }
  };

  const handleCancelShipment = async () => {
    if (!window.confirm("Cancel this shipment?")) return;

    try {
      await cancelMutation.mutateAsync(orderId);
      toast.success("Shipment cancelled");
    } catch (error) {
      toast.error("Failed to cancel shipment");
    }
  };

  const handleDownloadLabel = () => {
    if (shipment?.labelUrl) {
      window.open(shipment.labelUrl, "_blank");
    }
  };

  if (isLoading) {
    return (
      <div className="bg-white rounded-lg shadow p-6">
        <h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
          <TruckIcon className="w-5 h-5" />
          Shipping
        </h2>
        <p className="text-gray-500">Loading shipping information...</p>
      </div>
    );
  }

  return (
    <div className="bg-white rounded-lg shadow p-6">
      <h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
        <TruckIcon className="w-5 h-5" />
        Shipping
      </h2>

      {shipment ? (
        <div className="space-y-4">
          {/* Status */}
          <div className="flex items-center justify-between">
            <span className="text-sm text-gray-500">Status</span>
            <Badge
              variant={SHIPMENT_STATUS_COLORS[shipment.status] || "default"}
            >
              {shipment.status.replace(/_/g, " ")}
            </Badge>
          </div>

          {/* Carrier */}
          {shipment.carrierName && (
            <div className="flex items-center justify-between">
              <span className="text-sm text-gray-500">Carrier</span>
              <span className="text-sm font-medium">
                {shipment.carrierName}
              </span>
            </div>
          )}

          {/* Shipping Method */}
          {shipment.shippingMethodName && (
            <div className="flex items-center justify-between">
              <span className="text-sm text-gray-500">Method</span>
              <span className="text-sm font-medium">
                {shipment.shippingMethodName}
              </span>
            </div>
          )}

          {/* Tracking */}
          {shipment.trackingNumber && (
            <div className="flex items-center justify-between">
              <span className="text-sm text-gray-500">Tracking</span>
              {shipment.trackingUrl ? (
                <a
                  href={shipment.trackingUrl}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="text-sm font-medium text-blue-600 hover:underline"
                >
                  {shipment.trackingNumber}
                </a>
              ) : (
                <span className="text-sm font-medium">
                  {shipment.trackingNumber}
                </span>
              )}
            </div>
          )}

          {/* Actions */}
          <div className="flex flex-wrap gap-2 pt-4 border-t">
            {shipment.labelUrl && (
              <Button
                variant="secondary"
                size="sm"
                onClick={handleDownloadLabel}
              >
                <ArrowDownTrayIcon className="w-4 h-4 mr-1" />
                Download Label
              </Button>
            )}

            {shipment.status !== "CANCELLED" &&
              shipment.status !== "DELIVERED" && (
                <>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={handleRegenerateLabel}
                    loading={regenerateMutation.isPending}
                  >
                    <ArrowPathIcon className="w-4 h-4 mr-1" />
                    Regenerate
                  </Button>

                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={handleCancelShipment}
                    loading={cancelMutation.isPending}
                  >
                    <XMarkIcon className="w-4 h-4 mr-1" />
                    Cancel
                  </Button>
                </>
              )}
          </div>
        </div>
      ) : (
        <div className="space-y-4">
          <p className="text-sm text-gray-500">
            No shipping label created yet.
          </p>

          {/* Shipping Method Selection */}
          {methods && methods.length > 0 && (
            <div>
              <label className="block text-sm font-medium text-gray-700 mb-1">
                Shipping Method
              </label>
              <select
                value={selectedMethod || ""}
                onChange={(e) =>
                  setSelectedMethod(
                    e.target.value ? parseInt(e.target.value, 10) : undefined
                  )
                }
                className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
              >
                <option value="">Default method</option>
                {methods.map((method) => (
                  <option key={method.id} value={method.id}>
                    {method.carrier} - {method.name} ({method.price.toFixed(2)}
                    )
                  </option>
                ))}
              </select>
            </div>
          )}

          {/* Only show create button if order is ready */}
          {(orderStatus === "COMPLETED" || orderStatus === "PROCESSING") && (
            <Button
              onClick={handleCreateLabel}
              loading={createLabelMutation.isPending}
              className="w-full"
            >
              <TruckIcon className="w-4 h-4 mr-2" />
              Create Shipping Label
            </Button>
          )}
        </div>
      )}
    </div>
  );
}

3. Update Order Detail Page

Update apps/web/src/pages/orders/[id].tsx to include shipping section:

// Add import at the top
import { ShippingInfo } from "../../components/orders/shipping-info";

// In the JSX, add after the Actions Sidebar section:
{
  /* Shipping Info */
}
<ShippingInfo
  orderId={order.id}
  orderStatus={order.status}
  shippingAddress={order.shippingAddress as Record<string, unknown>}
/>;

4. Update API Client

Update apps/web/src/lib/api-client.ts to add shipping endpoints:

// Add to existing apiClient object:

// Shipping
getShipment: async (orderId: string) => {
  const response = await fetch(`${API_BASE}/api/v1/shipments/order/${orderId}`);
  if (response.status === 404) return null;
  if (!response.ok) throw new Error('Failed to fetch shipment');
  return response.json();
},

getShippingMethods: async (country: string) => {
  const response = await fetch(`${API_BASE}/api/v1/shipping/methods?country=${country}`);
  if (!response.ok) throw new Error('Failed to fetch shipping methods');
  return response.json();
},

createShippingLabel: async (orderId: string, shippingMethodId?: number, apiKey?: string | null) => {
  const url = new URL(`${API_BASE}/api/v1/shipping/order/${orderId}/label`);
  if (shippingMethodId) url.searchParams.set('shippingMethodId', String(shippingMethodId));

  const response = await fetch(url.toString(), {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(apiKey ? { 'X-API-Key': apiKey } : {}),
    },
  });
  if (!response.ok) throw new Error('Failed to create shipping label');
  return response.json();
},

regenerateShippingLabel: async (orderId: string, shippingMethodId?: number, apiKey?: string | null) => {
  const url = new URL(`${API_BASE}/api/v1/shipping/order/${orderId}/regenerate`);
  if (shippingMethodId) url.searchParams.set('shippingMethodId', String(shippingMethodId));

  const response = await fetch(url.toString(), {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(apiKey ? { 'X-API-Key': apiKey } : {}),
    },
  });
  if (!response.ok) throw new Error('Failed to regenerate label');
  return response.json();
},

cancelShipment: async (orderId: string, apiKey?: string | null) => {
  const response = await fetch(`${API_BASE}/api/v1/shipping/order/${orderId}/cancel`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(apiKey ? { 'X-API-Key': apiKey } : {}),
    },
  });
  if (!response.ok) throw new Error('Failed to cancel shipment');
  return response.json();
},

🧪 Testing Requirements

Test Coverage Requirements

Per requirements.md (NFR-MA-002):

  • Unit Tests: > 80% coverage for all new services
  • Integration Tests: All API integrations tested
  • E2E Tests: Critical paths covered
  • Acceptance Tests: New Gherkin scenarios for Phase 5 functionality

Unit Test Scenarios Required

Category Scenario Priority
Sendcloud Client Create parcel with valid data Critical
Sendcloud Client Handle API authentication Critical
Sendcloud Client Handle rate limiting High
Sendcloud Client Get shipping methods High
Sendcloud Service Create shipment on order ready event Critical
Sendcloud Service Skip if shipping disabled High
Sendcloud Service Handle shipment creation failure Critical
Sendcloud Service Regenerate label cancels existing Medium
Shipments Repo Create and retrieve shipment High
Shipments Repo Find by order ID High
Shipping UI Display shipment info High
Shipping UI Download label button works Medium

Acceptance Test Requirements (Playwright + Gherkin)

New Feature Files to Create

Create apps/acceptance-tests/src/features/shipping.feature:

@smoke @api
Feature: Shipping Integration
  As an operator
  I want orders to have shipping labels generated
  So that packages can be shipped to customers

  Background:
    Given the staging API is available

  @critical
  Scenario: Shipping endpoint is accessible
    When I request the shipping methods endpoint
    Then the response status should be 200
    And the response should contain shipping methods

  Scenario: Check shipping integration status
    When I request the shipping status endpoint
    Then the response should contain "enabled" field

  @web
  Scenario: View shipping info in order detail
    Given I am logged in to the dashboard
    And an order exists with a shipment
    When I navigate to the order detail page
    Then I should see the shipping information section
    And I should see a "Download Label" button if label exists

✅ Validation Checklist

Infrastructure

  • All new modules compile without errors
  • pnpm nx build api succeeds
  • pnpm nx build web succeeds
  • pnpm lint passes on all new files
  • Prisma migration runs successfully

Sendcloud API Client (F5.1)

  • API client authenticates correctly
  • Create parcel works with valid data
  • Get parcel retrieves parcel info
  • Get label returns PDF URLs
  • Cancel parcel works
  • Get shipping methods works
  • Error handling captures issues
  • Unit tests passing

Shipping Label Generation (F5.2)

  • Listens for order.ready-for-fulfillment event
  • Creates shipment record in database
  • Creates parcel in Sendcloud
  • Stores tracking information
  • Updates order with tracking
  • Handles API errors with retry
  • Regenerate label works
  • Cancel shipment works
  • Unit tests passing

Shipping Dashboard UI (F5.3)

  • Shipment info displays in order detail
  • Tracking number with link shown
  • Download label button works
  • Regenerate label button works
  • Cancel shipment button works
  • Shipping method selector shows options
  • Create label button works
  • Real-time updates work

Integration Tests

  • End-to-end: Order complete → Shipment created
  • End-to-end: Label regeneration
  • End-to-end: Shipment cancellation

Acceptance Tests (Playwright + Gherkin)

  • Feature files created for shipping
  • Step definitions implemented
  • Tests pass against staging

🚫 Constraints and Rules

MUST DO

  • Use existing retry queue for failed shipment creation
  • Log all shipping operations via EventLogService
  • Verify order status before creating shipment
  • Store tracking info in both Shipment and Order tables
  • Handle disabled shipping gracefully
  • Use Sendcloud API v2
  • Write unit tests for all new services (> 80% coverage)
  • Add acceptance tests (Playwright + Gherkin)
  • Update ALL documentation (see Documentation Updates section)

MUST NOT

  • Create labels for cancelled orders
  • Skip error handling on Sendcloud API calls
  • Store Sendcloud credentials in code
  • Create duplicate parcels for same order
  • Block fulfillment on shipping failures (use async events)
  • Skip writing unit tests
  • Leave documentation incomplete

🎬 Execution Order

Implementation

  1. Update Prisma schema with Shipment model
  2. Run migration for shipment table
  3. Create Sendcloud types in libs/api-client
  4. Create Sendcloud API client with authentication
  5. Create Shipments repository for database operations
  6. Create Sendcloud service with event listener
  7. Create Sendcloud controller with REST endpoints
  8. Create Sendcloud module and register in app
  9. Update Fulfillment service to work with shipping
  10. Create shipping hooks for frontend
  11. Create ShippingInfo component for order detail
  12. Update Order Detail page with shipping section
  13. Update API client with shipping endpoints

Testing

  1. Write unit tests for all new services (> 80% coverage)
  2. Write integration tests for shipping flow
  3. Add acceptance tests for shipping features
  4. Test with staging Sendcloud account

Documentation

  1. Update Swagger documentation — Add @Api* decorators to all new endpoints
  2. Update README.md — Add shipping integration section
  3. Update docs/04-development/implementation-plan.md — Mark Phase 5 features as complete
  4. Update docs/01-business/requirements.md — Mark FR-SC-001, FR-SC-002 as implemented
  5. Update docs/03-architecture/adr/ADR.md — Add ADR for Sendcloud integration
  6. Update C4 diagrams — Add Sendcloud component to architecture diagrams

Validation

  1. Run full validation checklist
  2. Verify acceptance tests pass in pipeline
  3. Confirm all documentation is complete

📊 Expected Output

When Phase 5 is complete:

Verification Commands

# Build all projects
pnpm nx build api
pnpm nx build web

# Run tests
pnpm nx test api
pnpm nx test web

# Start development servers
pnpm dev

# Create shipping label via API
curl -X POST http://localhost:3000/api/v1/shipping/order/{orderId}/label \
  -H "X-API-Key: your-api-key"

# Get shipping methods
curl http://localhost:3000/api/v1/shipping/methods?country=BE

Complete Automation Flow

1. Shopify Order Created (webhook)
   ↓
2. Order Stored in Database
   ↓
3. Print Jobs Created in SimplyPrint
   ↓
4. Print Job Status Updates (webhook/polling)
   ↓
5. All Print Jobs Complete
   ↓
6. order.ready-for-fulfillment Event
   ↓
7. Shipping Label Created (Sendcloud)  ← NEW
   ↓
8. Fulfillment Created in Shopify (with tracking)
   ↓
9. Customer Receives Shipping Notification with Tracking

📝 Documentation Updates

CRITICAL: All documentation must be updated to reflect Phase 5 completion.

README.md Updates Required

Add sections for:

  1. Shipping Integration — How Sendcloud integration works
  2. Shipping Label Flow — Automatic and manual label creation
  3. Configuration — Sendcloud environment variables
  4. Shipping Methods — How shipping methods are selected

docs/04-development/implementation-plan.md Updates Required

Update the implementation plan to mark Phase 5 as complete:

  • Mark F5.1 (Sendcloud API Client) as ✅ Completed
  • Mark F5.2 (Shipping Label Generation) as ✅ Completed
  • Mark F5.3 (Shipping Dashboard UI) as ✅ Completed
  • Update Phase 5 Exit Criteria with checkmarks
  • Add implementation notes and component paths
  • Update revision history with completion date

docs/01-business/requirements.md Updates Required

Update requirements document to mark Phase 5 requirements as implemented:

  • Mark FR-SC-001 (Generate Shipping Labels) as ✅ Implemented
  • Mark FR-SC-002 (Tracking Information Sync) as ✅ Implemented
  • Update revision history

docs/03-architecture/adr/ADR.md Updates Required

Add new ADR:

  • ADR-030: Sendcloud Integration for Shipping

Example ADR template:

## ADR-030: Sendcloud Integration for Shipping

| Attribute   | Value                                                       |
| ----------- | ----------------------------------------------------------- |
| **ID**      | ADR-030                                                     |
| **Status**  | Accepted                                                    |
| **Date**    | 2026-01-XX                                                  |
| **Context** | Need to generate shipping labels and track package delivery |

### Decision

Use **Sendcloud** as the shipping integration platform.

### Rationale

- Multi-carrier support (PostNL, DPD, DHL, etc.)
- European focus matching Forma3D market
- Simple REST API with label generation
- Automatic tracking updates
- Webhook support for status changes
- Reasonable pricing for small businesses

### Consequences

- ✅ Single integration for multiple carriers
- ✅ Automatic label PDF generation
- ✅ Tracking information synced to Shopify
- ⚠️ Dependent on Sendcloud uptime
- ⚠️ Limited to carriers supported by Sendcloud

docs/03-architecture/c4-model Diagram Updates

Update the following PlantUML diagrams:

Diagram Path Updates Required
Container docs/03-architecture/c4-model/2-container/C4_Container.puml Add Sendcloud as external system
Component docs/03-architecture/c4-model/3-component/C4_Component.puml Add SendcloudModule, ShipmentsModule components
Domain Model docs/03-architecture/c4-model/4-code/C4_Code_DomainModel.puml Add Shipment entity

docs/03-architecture/sequences Updates

Consider adding:

  • C4_Seq_08_ShippingLabel.puml — Shipping label creation sequence

🔗 Phase 5 Exit Criteria

From implementation-plan.md:

  • Shipping labels generated automatically
  • Tracking synced to Shopify
  • Labels downloadable from dashboard
  • Complete flow: Order → Print → Label → Fulfill

Additional Exit Criteria

  • Unit tests > 80% coverage for all new code
  • Integration tests passing
  • Acceptance tests added for Phase 5 functionality
  • All acceptance tests passing against staging
  • README.md updated with shipping section
  • docs/04-development/implementation-plan.md updated — Phase 5 marked as complete
  • docs/01-business/requirements.md updated — Phase 5 requirements marked as implemented
  • docs/03-architecture/adr/ADR.md updated — ADR-030 added for Sendcloud
  • C4 diagrams updated — Sendcloud and Shipment added
  • Swagger documentation complete for all new endpoints

🔮 Phase 6 Preview

Phase 6 (Hardening) will focus on production readiness:

  • Comprehensive testing (80%+ coverage)
  • Performance optimization
  • Security hardening
  • Documentation completion
  • Load testing (500+ orders/day)
  • Monitoring and alerting setup

The shipping integration established in Phase 5 will be tested under load and optimized for production reliability.


END OF PROMPT


This prompt builds on the Phase 4 foundation. The AI should implement all Phase 5 shipping integration features while maintaining the established code style, architectural patterns, and testing standards. Phase 5 completes the full automation loop from order to delivery.