DEV Community

Cover image for Building a Professional Services Platform for Government Tender Compliance
mobius-crypt
mobius-crypt

Posted on

Building a Professional Services Platform for Government Tender Compliance

Building a Professional Services Platform for Government Tender Compliance

When we set out to build Tenders SA, we knew that matching businesses with government tenders was only half the battle. The real barrier preventing capable companies from accessing billions in procurement opportunities wasn't finding tenders—it was compliance documentation.

This is the story of how we built a full-stack professional services platform that handles B-BBEE registrations, CSD applications, company formations, and CIDB grading—all while maintaining a seamless user experience and reliable payment processing.

The Problem: Compliance is Complicated

In South Africa, bidding on government tenders requires a maze of registrations:

  • B-BBEE (Broad-Based Black Economic Empowerment) certification
  • CSD (Central Supplier Database) registration with National Treasury
  • CIPC company registration
  • CIDB grading for construction businesses
  • Tax compliance certificates
  • Insurance documentation
  • And more...

Each registration has its own portal, requirements, and quirks. DIY registration typically takes 6-12 weeks and involves multiple submission attempts. We wanted to compress this to 7-10 days with professional handling.

Tech Stack: Why We Chose What We Chose

// Our core stack
const techStack = {
  framework: "Next.js 14 (App Router)",
  language: "TypeScript",
  database: "PostgreSQL + Prisma ORM",
  auth: "Custom JWT + Session management",
  payments: "PayPal REST API",
  storage: "AWS S3 for document uploads",
  deployment: "Vercel",
};
Enter fullscreen mode Exit fullscreen mode

Why Next.js App Router?

The App Router's server components gave us some huge wins:

  1. Server-side data fetching - No loading spinners for service listings
  2. Streaming - Progressive rendering for document-heavy pages
  3. Route handlers - Clean API routes that live alongside components
  4. Type safety - Full TypeScript support from DB to UI

Architecture: Service Request System

The core of our platform is a flexible service request system. Here's the Prisma schema:

model ServiceType {
  id          String   @id @default(cuid())
  name        String
  slug        String   @unique
  description String
  price       Decimal  @db.Decimal(10, 2)
  currency    String   @default("ZAR")
  isActive    Boolean  @default(true)

  requests    ServiceRequest[]

  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

model ServiceRequest {
  id              String        @id @default(cuid())
  userId          String
  serviceTypeId   String

  // Flexible JSON storage for form data
  formData        Json

  // Document storage keys
  uploadedFiles   Json?

  // State management
  status          RequestStatus @default(PENDING)
  paymentStatus   PaymentStatus @default(PENDING)

  // PayPal integration
  paypalOrderId   String?       @unique

  // Relationships
  user            User          @relation(fields: [userId], references: [id])
  serviceType     ServiceType   @relation(fields: [serviceTypeId], references: [id])

  createdAt       DateTime      @default(now())
  updatedAt       DateTime      @updatedAt

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

enum RequestStatus {
  DRAFT
  PENDING
  PROCESSING
  COMPLETED
  REJECTED
}

enum PaymentStatus {
  PENDING
  PAID
  FAILED
  REFUNDED
}
Enter fullscreen mode Exit fullscreen mode

The key insight here was using JSON fields for form data. Each service has different requirements:

  • B-BBEE needs turnover figures and ownership percentages
  • CSD needs CIPC numbers and tax references
  • Business registration needs director details and company names

Rather than creating separate tables for each service type, we use a polymorphic approach with type-safe validation.

Form Handling: Dynamic, Type-Safe Forms

Each service has its own form component with Zod validation. Here's the B-BBEE registration schema:

// lib/schemas/professional-services.ts
import { z } from "zod";

export const beeRegistrationSchema = z.object({
  companyName: z.string().min(2, "Company name required"),
  registrationNumber: z.string().min(10, "Valid registration number required"),

  // Turnover determines if you need affidavit or certificate
  annualTurnover: z.coerce.number().min(0),

  // Ownership structure
  blackOwnership: z.coerce.number().min(0).max(100),
  blackWomenOwnership: z.coerce.number().min(0).max(100),

  // Supporting documents
  cipcDocumentKey: z.string().min(1, "CIPC document required"),
  financialStatementsKey: z.string().optional(),
});

export type BeeRegistrationInput = z.infer<typeof beeRegistrationSchema>;
Enter fullscreen mode Exit fullscreen mode

The form component uses React Hook Form for optimal UX:

// components/services/forms/BeeRegistrationForm.tsx
'use client';

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

export function BeeRegistrationForm() {
  const form = useForm<BeeRegistrationInput>({
    resolver: zodResolver(beeRegistrationSchema),
    defaultValues: {
      blackOwnership: 0,
      blackWomenOwnership: 0,
    },
  });

  async function onSubmit(data: BeeRegistrationInput) {
    const result = await submitBeeRegistration(data);

    if (result.success) {
      // Redirect to payment
      window.location.href = `/services/bee-registration/review/${result.data.requestId}`;
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        {/* Form fields */}
      </form>
    </Form>
  );
}
Enter fullscreen mode Exit fullscreen mode

File Uploads: S3 with Presigned URLs

Handling documents securely was critical. We use presigned S3 URLs for direct client-to-S3 uploads:

// app/api/v1/upload/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

export async function POST(request: Request) {
  const { filename, contentType } = await request.json();

  const key = `uploads/${Date.now()}-${filename}`;

  const command = new PutObjectCommand({
    Bucket: process.env.AWS_S3_BUCKET!,
    Key: key,
    ContentType: contentType,
  });

  // Generate presigned URL valid for 5 minutes
  const uploadUrl = await getSignedUrl(s3Client, command, {
    expiresIn: 300,
  });

  return Response.json({ uploadUrl, key });
}
Enter fullscreen mode Exit fullscreen mode

This approach:

  • ✅ Keeps file uploads off our server
  • ✅ Provides better upload speeds (direct to S3)
  • ✅ Handles large files gracefully
  • ✅ Maintains security with expiring URLs

Payment Integration: PayPal REST API

Processing payments was tricky. South African businesses needed local payment options but also international flexibility. PayPal's REST API gave us both.

// lib/paypal.ts
interface PayPalOrder {
  id: string;
  status: string;
  purchase_units: Array<{
    amount: {
      currency_code: string;
      value: string;
    };
  }>;
}

export async function createPayPalOrder(
  amount: number,
  currency: string,
  requestId: string
): Promise<PayPalOrder> {
  const accessToken = await getPayPalAccessToken();

  const response = await fetch(`${PAYPAL_API}/v2/checkout/orders`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      intent: "CAPTURE",
      purchase_units: [{
        amount: {
          currency_code: currency,
          value: amount.toFixed(2),
        },
        reference_id: requestId,
      }],
      application_context: {
        brand_name: "Tenders SA",
        landing_page: "NO_PREFERENCE",
        user_action: "PAY_NOW",
        return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/services/payment/success`,
        cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/services/payment/cancel`,
      },
    }),
  });

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

The payment flow:

  1. Create Order - Generate PayPal order with service request ID
  2. User Redirects - PayPal handles the payment UI
  3. Capture Payment - On return, we capture and verify
  4. Update Status - Mark request as PAID and trigger processing
// app/actions/payment.ts
'use server';

export async function captureServicePayment(token: string) {
  const accessToken = await getPayPalAccessToken();

  // Capture the payment
  const response = await fetch(
    `${PAYPAL_API}/v2/checkout/orders/${token}/capture`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${accessToken}`,
      },
    }
  );

  const data = await response.json();

  if (data.status === "COMPLETED") {
    // Extract reference_id (our request ID)
    const requestId = data.purchase_units[0].reference_id;

    // Update database
    await db.serviceRequest.update({
      where: { id: requestId },
      data: {
        paymentStatus: "PAID",
        status: "PROCESSING",
        paypalOrderId: token,
      },
    });

    // Trigger notification to our team
    await notifyTeamOfNewRequest(requestId);

    return { success: true };
  }

  return { success: false, message: "Payment failed" };
}
Enter fullscreen mode Exit fullscreen mode

Review Page: Pre-Payment Confirmation

We added a crucial step between form submission and payment—a review page:

// app/services/business-registration/review/[requestId]/page.tsx
export default async function ReviewRegistrationPage({ 
  params 
}: { 
  params: Promise<{ requestId: string }> 
}) {
  const user = await getCurrentUser();
  const { requestId } = await params;

  const request = await db.serviceRequest.findUnique({
    where: { id: requestId },
    include: { serviceType: true },
  });

  // Parse stored form data
  const formData = JSON.parse(request.formData as string);
  const servicePrice = request.serviceType.price.toNumber();

  return (
    <div className="container max-w-3xl py-10">
      <h1>Review Application</h1>

      {/* Display company names, directors, etc */}
      <CompanyDetailsCard data={formData} />
      <DirectorsCard directors={formData.directors} />

      {/* Payment section */}
      <PaymentCard
        requestId={requestId}
        amount={servicePrice}
        currency="ZAR"
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This reduced payment drop-off by 42% because users could verify their details before committing to payment.

Server Actions: The Secret Sauce

Next.js Server Actions made our forms feel instant while maintaining security:

// app/actions/professional-services.ts
'use server';

import { getCurrentUser } from "@/lib/session";
import { db } from "@/lib/db";

export async function submitBusinessRegistration(
  data: BusinessRegistrationInput
) {
  // Server-side auth check
  const user = await getCurrentUser();

  if (!user) {
    return {
      success: false,
      message: "Unauthorized. Please log in.",
    };
  }

  try {
    // Validate data server-side (double validation)
    const validated = businessRegistrationSchema.parse(data);

    // Create service request
    const request = await db.serviceRequest.create({
      data: {
        userId: user.id,
        serviceTypeId: "business-registration-id",
        formData: validated,
        status: "DRAFT",
        paymentStatus: "PENDING",
      },
    });

    return {
      success: true,
      data: { requestId: request.id },
    };
  } catch (error) {
    return {
      success: false,
      message: "Failed to submit application",
      errors: error instanceof z.ZodError ? error.errors : [],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Server Actions give us:

  • ✅ No API route boilerplate
  • ✅ Automatic revalidation
  • ✅ Progressive enhancement
  • ✅ Type-safe RPC-like calls

Dashboard: Service Request Management

Users needed visibility into their applications. We built a dashboard with real-time status updates:

// app/dashboard/services/page.tsx
export default async function ServicesPage() {
  const user = await getCurrentUser();

  const requests = await db.serviceRequest.findMany({
    where: { userId: user.id },
    include: { serviceType: true },
    orderBy: { createdAt: 'desc' },
  });

  return (
    <div className="space-y-6">
      <h1>My Service Requests</h1>

      <div className="grid gap-4">
        {requests.map(request => (
          <ServiceRequestCard
            key={request.id}
            request={request}
          />
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each request card shows:

  • Service type and price
  • Submission date
  • Current status (with color-coded badges)
  • Payment status
  • Action buttons (view details, pay now, download documents)

Lessons Learned

1. JSON Schema Validation is Your Friend

Using Zod for both client and server validation caught so many issues:

// This caught bugs we'd have missed
const schema = z.object({
  directors: z.array(
    z.object({
      idNumber: z.string()
        .length(13, "SA ID must be 13 digits")
        .regex(/^\d+$/, "ID must be numeric"),
    })
  ).min(1, "At least one director required"),
});
Enter fullscreen mode Exit fullscreen mode

2. Type-Safe Form Data with Discriminated Unions

Different services need different forms, but we wanted type safety:

type ServiceFormData = 
  | { type: 'bee'; data: BeeRegistrationInput }
  | { type: 'csd'; data: CsdRegistrationInput }
  | { type: 'cidb'; data: CidbRegistrationInput };

function getFormComponent(serviceType: string) {
  switch (serviceType) {
    case 'bee-registration':
      return BeeRegistrationForm;
    case 'csd-registration':
      return CsdRegistrationForm;
    default:
      return GeneralServiceForm;
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Idempotent Payment Capture is Critical

We learned this the hard way when a user refreshed the payment success page:

export async function captureServicePayment(token: string) {
  // Check if already captured
  const existing = await db.serviceRequest.findUnique({
    where: { paypalOrderId: token },
  });

  if (existing?.paymentStatus === "PAID") {
    return { success: true, message: "Already processed" };
  }

  // Proceed with capture...
}
Enter fullscreen mode Exit fullscreen mode

4. Progressive Enhancement with Server Components

Our forms work without JavaScript! Well, mostly:

export function ServiceForm({ serviceSlug }: { serviceSlug: string }) {
  // This renders on the server
  const FormComponent = getFormComponent(serviceSlug);

  return (
    <Suspense fallback={<FormSkeleton />}>
      <FormComponent />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Metrics

After 6 months of operation:

  • Average submission time: 3.2 minutes (down from 45+ minutes DIY)
  • Payment completion rate: 87% (industry average is 60%)
  • Processing time: 7-9 days (down from 21+ days DIY)
  • Error rate: <2% (mostly user-uploaded document issues)
  • P95 page load: 1.8s

What's Next?

We're working on:

  1. Automatic document generation - Generate MOIs, shareholder agreements, etc.
  2. Smart document validation - AI checks for common errors before submission
  3. Status webhooks - Real-time updates via email/SMS
  4. Bulk services - Handle multiple registrations at once
  5. API access - Let accounting firms integrate our services

Open Source Opportunities

We're considering open-sourcing:

  • Our Zod schema collection for SA compliance documents
  • The PayPal integration wrapper (with better TypeScript types)
  • Document upload component with S3 presigned URLs

Would this be useful to you? Let me know in the comments!

Conclusion

Building this platform taught us that compliance doesn't have to be painful. With the right architecture—type-safe schemas, flexible data models, reliable payments—you can turn a 3-month bureaucratic nightmare into a 10-day professional service.

The South African government procurement market is worth R500+ billion annually. By removing the compliance barrier, we're helping thousands of capable businesses access opportunities they previously couldn't pursue.

If you're building similar document-heavy, payment-processing platforms, I hope this breakdown helps. Feel free to ask questions in the comments!


Tech Stack Summary:

  • Next.js 14 (App Router)
  • TypeScript
  • Prisma + PostgreSQL
  • React Hook Form + Zod
  • PayPal REST API
  • AWS S3
  • Vercel

GitHub: (Coming soon)
Website: tenders-sa.org

Read Next: Building a Tender Tools Suite with AI-Powered Calculators


Have you built similar compliance or document management platforms? What challenges did you face? Drop a comment below!

Top comments (0)