BISO Sites
@repo/payment

Payment Package Overview

Multi-provider payment processing package for BISO Sites, currently supporting Vipps MobilePay.

Payment Package Overview

The @repo/payment package provides payment processing capabilities for BISO Sites. Currently supports Vipps MobilePay with a clean, framework-agnostic architecture.

When to use this package

  • Triggering checkout flows from the web app or admin dashboards.
  • Handling Vipps callbacks/webhooks and updating Appwrite orders.
  • Building new payment providers using the same abstraction points.
⚠️

Always run through the Operations → Environment Variables checklist before touching payments—Vipps credentials and redirect URLs must match the environment.

Installation

bun add @repo/payment

Where it's used

SurfaceUsage
Web App → E-commerceInitiates checkout and handles order confirmation
Admin App (shop backlog)Views and reconciles @repo/payment order records
API Routes /api/payment/*Receive Vipps webhooks via handleVippsCallback

Key Features

  • Vipps MobilePay Integration - Full checkout flow
  • Framework-Agnostic Core - Uses dependency injection
  • Server Actions - Convenient Next.js wrappers
  • Webhook Handling - Automatic order status updates
  • Type-Safe - Full TypeScript support
  • Race Condition Handling - Between webhooks and redirects
  • Error Handling - Comprehensive error management

Package Structure

packages/payment/
├── vipps.ts          # Core Vipps logic (framework-agnostic)
├── actions.ts        # Server actions for Next.js
├── package.json
├── README.md
└── tsconfig.json

Quick Start

Install & Configure

# Environment variables
VIPPS_CLIENT_ID=your_client_id
VIPPS_CLIENT_SECRET=your_secret
VIPPS_MERCHANT_SERIAL_NUMBER=your_msn
VIPPS_SUBSCRIPTION_KEY=your_sub_key
VIPPS_TEST_MODE=true
NEXT_PUBLIC_BASE_URL=http://localhost:3000

Initiate Checkout

'use server';

import { initiateVippsCheckout } from '@repo/payment/actions';

export async function handleCheckout() {
  await initiateVippsCheckout({
    userId: 'user_123',
    items: [
      {
        productId: 'prod_1',
        name: 'T-Shirt',
        price: 299,
        quantity: 2
      }
    ],
    subtotal: 598,
    total: 598,
    currency: 'NOK',
  });
  // User is redirected to Vipps
}

Handle Callback

// app/api/payment/vipps/callback/route.ts
import { createAdminClient } from '@repo/api/server';
import { handleVippsCallback } from '@repo/payment/vipps';

export async function POST(request: NextRequest) {
  const authToken = request.headers.get('authorization');
  const sessionId = request.nextUrl.searchParams.get('sessionId');
  
  const { db } = await createAdminClient();
  await handleVippsCallback(authToken, sessionId, db);
  
  return NextResponse.json({ success: true });
}

Display Order Confirmation

// app/checkout/confirmation/[orderId]/page.tsx
import { getOrder } from '@repo/payment/actions';

export default async function OrderConfirmation({ 
  params 
}: { 
  params: { orderId: string } 
}) {
  const order = await getOrder(params.orderId);
  
  if (!order) return <div>Order not found</div>;
  
  return (
    <div>
      <h1>Order Confirmed!</h1>
      <p>Status: {order.status}</p>
      <p>Total: {order.total} {order.currency}</p>
    </div>
  );
}

Architecture

The payment package follows clean architecture with dependency injection:

Benefits:

  • Core logic is framework-agnostic
  • Easy to test (mock database)
  • Can be used in any Node.js environment
  • Clear separation of concerns

Complete Flow

Order States

StateDescription
PENDINGOrder created, awaiting payment
AUTHORIZEDPayment authorized, money reserved
PAIDPayment captured, money transferred
CANCELLEDUser cancelled or payment expired
FAILEDTechnical failure
REFUNDEDOrder refunded

API Reference

initiateVippsCheckout()

Initiates checkout and redirects to Vipps.

async function initiateVippsCheckout(
  params: CheckoutSessionParams
): Promise<never>

Parameters:

interface CheckoutSessionParams {
  userId: string;
  items: Array<{
    productId: string;
    name: string;
    price: number;
    quantity: number;
  }>;
  subtotal: number;
  discountTotal?: number;
  shippingCost?: number;
  total: number;
  currency: 'NOK';
  membershipApplied?: boolean;
  memberDiscountPercent?: number;
  campusId?: string;
  customerInfo?: {
    firstName?: string;
    lastName?: string;
    email?: string;
    phone?: string;
    streetAddress?: string;
    city?: string;
    postalCode?: string;
    country?: string;
  };
}

Example:

'use server';

import { initiateVippsCheckout } from '@repo/payment/actions';

export async function checkout(cartItems: CartItem[]) {
  await initiateVippsCheckout({
    userId: 'user_123',
    items: cartItems.map(item => ({
      productId: item.id,
      name: item.name,
      price: item.price,
      quantity: item.quantity
    })),
    subtotal: calculateSubtotal(cartItems),
    total: calculateTotal(cartItems),
    currency: 'NOK',
    customerInfo: {
      firstName: 'John',
      lastName: 'Doe',
      email: 'john@example.com',
      phone: '+4712345678'
    }
  });
}

getOrder()

Retrieves order from database.

async function getOrder(orderId: string): Promise<Orders | null>

Example:

const order = await getOrder('order_123');
if (order) {
  console.log(`Order status: ${order.status}`);
}

verifyOrder()

Verifies order status with Vipps and updates database.

async function verifyOrder(orderId: string): Promise<Orders | null>

Example:

// After user returns from Vipps
const order = await verifyOrder('order_123');
if (order?.status === 'PAID') {
  // Show success message
}

Core Functions (Advanced)

For direct usage or non-Next.js environments:

createCheckoutSession()

async function createCheckoutSession(
  params: CheckoutSessionParams,
  db: Database
): Promise<VippsCheckoutResponse>

handleVippsCallback()

async function handleVippsCallback(
  authToken: string | null,
  sessionId: string | null,
  db: Database
): Promise<void>

getOrderStatus()

async function getOrderStatus(
  orderId: string,
  db: Database
): Promise<Orders | null>

verifyOrderStatus()

async function verifyOrderStatus(
  orderId: string,
  db: Database
): Promise<Orders | null>

Environment Variables

# Vipps Configuration
VIPPS_CLIENT_ID=your_client_id
VIPPS_CLIENT_SECRET=your_client_secret
VIPPS_MERCHANT_SERIAL_NUMBER=your_msn
VIPPS_SUBSCRIPTION_KEY=your_subscription_key

# Test mode (true/false)
VIPPS_TEST_MODE=true

# Your site URL (for callbacks)
NEXT_PUBLIC_BASE_URL=http://localhost:3000

# Appwrite (for order storage)
APPWRITE_DATABASE_ID=your_db_id
APPWRITE_ORDERS_COLLECTION_ID=your_orders_collection_id

Testing

Test Mode

Set VIPPS_TEST_MODE=true to use Vipps test environment.

Test Card Numbers:

Card: 4925 0000 0000 0004
Expiry: Any future date
CVC: Any 3 digits

Manual Testing

  1. Start checkout flow
  2. Complete payment in Vipps test environment
  3. Verify order status updates correctly
  4. Test both webhook and return URL paths
  5. Test cancellation flow

Error Handling

'use server';

import { initiateVippsCheckout } from '@repo/payment/actions';

export async function handleCheckout(params) {
  try {
    await initiateVippsCheckout(params);
  } catch (error) {
    console.error('Checkout failed:', error);
    
    // Handle specific errors
    if (error.message.includes('insufficient stock')) {
      return { error: 'Some items are out of stock' };
    }
    
    return { error: 'Checkout failed. Please try again.' };
  }
}

Common Patterns

Cart Checkout Button

'use client';

import { Button } from '@repo/ui/components/ui/button';
import { checkout } from './actions';

export function CheckoutButton({ items }: { items: CartItem[] }) {
  return (
    <form action={() => checkout(items)}>
      <Button type="submit" size="lg">
        Go to Checkout
      </Button>
    </form>
  );
}

Order Status Display

import { getOrder } from '@repo/payment/actions';
import { Badge } from '@repo/ui/components/ui/badge';

export async function OrderStatus({ orderId }: { orderId: string }) {
  const order = await getOrder(orderId);
  
  if (!order) return null;
  
  const statusColor = {
    PENDING: 'warning',
    AUTHORIZED: 'info',
    PAID: 'success',
    CANCELLED: 'default',
    FAILED: 'destructive'
  }[order.status];
  
  return <Badge variant={statusColor}>{order.status}</Badge>;
}

Return URL Handler

// app/checkout/return/page.tsx
import { verifyOrder } from '@repo/payment/actions';
import { redirect } from 'next/navigation';

export default async function CheckoutReturn({
  searchParams
}: {
  searchParams: { orderId?: string }
}) {
  const orderId = searchParams.orderId;
  
  if (!orderId) {
    redirect('/shop/cart');
  }
  
  // Verify with Vipps (handles race condition)
  const order = await verifyOrder(orderId);
  
  if (!order) {
    redirect('/shop/cart?error=order_not_found');
  }
  
  // Redirect to confirmation
  redirect(`/checkout/confirmation/${orderId}`);
}

Security

⚠️
Important Security Notes
  • Never expose VIPPS_CLIENT_SECRET to the client
  • Always verify webhook signatures
  • Use admin client for webhook handlers (no user session)
  • Validate all input parameters
  • Check order ownership before displaying details

Webhook Verification

// Verify Vipps webhook signature
function verifyWebhookSignature(
  authToken: string,
  expectedToken: string
): boolean {
  // Implement signature verification
  return authToken === expectedToken;
}

Best Practices

  1. Always use Server Actions for initiating checkout
  2. Implement proper error handling for failed payments
  3. Show loading states during checkout initiation
  4. Verify order ownership before displaying details
  5. Handle webhooks and return URLs (race condition safe)
  6. Log all payment operations for debugging
  7. Test thoroughly in test mode before going live

Troubleshooting

Order Stuck in PENDING

  • Check webhook is receiving callbacks
  • Verify webhook URL is accessible
  • Check Vipps dashboard for webhook logs
  • Manually verify with verifyOrder()

Webhook Not Received

  • Check firewall/proxy settings
  • Verify webhook URL in Vipps dashboard
  • Check server logs for incoming requests
  • Test with Vipps webhook testing tool

Payment Authorized but Not Captured

  • Vipps has two-phase payments: authorize then capture
  • Implement capture logic if needed
  • Or use direct charge (single-phase)

Next Steps

ℹ️