Skip to content

Mobile POS Feature Plan

Overview

Add a Mobile Point of Sale feature to Badger Commerce using Stripe Tap to Pay on iPhone/Android. This allows charity supporters (like Derby Mountain Rescue Team) to take card payments using their personal phones at events, replacing the need for dedicated SumUp terminals.

Key capabilities: - Quick payments: Enter amount and tap to collect - Order building: Search products, build cart, checkout - Agent management: Admin controls who can process payments - Transaction history: Full audit trail

Project Structure

Component Location Notes
Backend (models, services, APIs, admin) badger-commerce repo, feature/mobile-pos branch Integrates with existing codebase
React Native mobile app New repo: badger-pos-app Separate repo, different tech stack

Authentication Approach

Three-tier authentication system:

  1. Initial login - Full JWT authentication
  2. User logs in with existing credentials (email/password)
  3. Requires POS_AGENT role (new role to add)
  4. Returns JWT token, stored in secure keychain
  5. Sets up device for quick unlock

  6. Biometric unlock - FaceID/TouchID (Primary quick unlock)

  7. Uses react-native-biometrics or similar library
  8. JWT token retrieved from secure keychain after biometric success
  9. Falls back to PIN if biometrics unavailable

  10. PIN unlock - Fallback for devices without biometrics

  11. 4-6 digit PIN set during initial setup
  12. PIN hash stored locally
  13. Used when biometrics fail or unavailable
  14. Lockout after 5 failed attempts

Security considerations: - JWT stored in iOS Keychain / Android Keystore (hardware-backed) - Token refresh handled automatically before expiry - Force re-login after 24 hours or on suspicious activity - Clear credentials on logout

Architecture

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  React Native   │────▶│   REST API      │────▶│     Stripe      │
│  POS App        │     │  /v1/pos/*      │     │    Terminal     │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        │                     │
        │  FaceID/PIN         │
        │  Quick Unlock       ▼
        │               ┌─────────────────┐
        └──────────────▶│   Admin Web     │
                        │   /admin/pos/*  │
                        └─────────────────┘

Existing APIs That Can Be Reused

Based on API review, these existing endpoints can be reused directly by the mobile app:

Endpoint Purpose Auth Notes
GET /v1/product Search products Public Query: upc, seoName, pagination
GET /v1/product/{skuCode} Get product by SKU Public Full product details with variants
GET /v1/collection/{seoName}/products Products in collection Public For category browsing
POST /v1/basket Create order/basket Bearer Existing checkout flow
PUT /v1/basket/{id} Add items to basket Bearer LineItem management
POST /v1/basket/{id}/paymentIntent Create payment intent Bearer Existing payment flow
PUT /v1/basket/{id}/submit Submit order Bearer Triggers fulfillment

Existing POS pattern (defined but not implemented):

GET /v1/pos/{locationId}/paymentToken
Tagged as "POS" - we'll extend this pattern.


Phase 0: Update OpenAPI Spec (SwaggerHub)

The REST API uses OpenAPI spec-first development: - Spec location: https://api.swaggerhub.com/apis/Kedos-Consulting-Ltd/badger-commerce-api/ - Generation: Maven plugin generates interfaces from spec - Pattern: Controllers implement generated *Api interfaces

New endpoints to add (following existing /v1/pos/ pattern):

Extend existing tag: POS

New paths (location-scoped, following existing pattern):

# Implement the existing spec endpoint
/v1/pos/{locationId}/paymentToken:
  get:
    operationId: generatePOSPaymentToken
    tags: [POS]
    summary: Returns auth token for POS payment system (already in spec)

# New: Stripe Terminal connection token
/v1/pos/{locationId}/connectionToken:
  post:
    operationId: createPosConnectionToken
    tags: [POS]
    summary: Create Stripe Terminal connection token for mobile SDK
    parameters:
      - name: locationId
        in: path
        required: true
        schema: { type: string }
    responses:
      '200':
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PosConnectionToken'

# New: Card-present payment intents (for Tap to Pay)
/v1/pos/{locationId}/paymentIntents:
  post:
    operationId: createPosPaymentIntent
    tags: [POS]
    summary: Create card-present payment intent for NFC tap
    requestBody:
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/CreatePosPaymentIntent'
    responses:
      '201':
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PosPaymentIntent'

/v1/pos/{locationId}/paymentIntents/{paymentIntentId}/capture:
  post:
    operationId: capturePosPaymentIntent
    tags: [POS]

/v1/pos/{locationId}/paymentIntents/{paymentIntentId}/cancel:
  post:
    operationId: cancelPosPaymentIntent
    tags: [POS]

# Agent/staff management
/v1/pos/agents/me:
  get:
    operationId: getCurrentPosAgent
    tags: [POS]
    summary: Get current authenticated agent's profile

/v1/pos/{locationId}/transactions:
  get:
    operationId: listPosTransactions
    tags: [POS]
    summary: List transactions for this location
    parameters:
      - name: startDate, endDate, pageSize, pageNumber

New schemas:

PosConnectionToken:
  type: object
  properties:
    secret: { type: string }
    stripeAccountId: { type: string }

PosLocation:
  type: object
  properties:
    id: { type: string }
    displayName: { type: string }
    address: { $ref: '#/components/schemas/Address' }

CreatePosPaymentIntent:
  type: object
  required: [amountInPence, currency]
  properties:
    amountInPence: { type: integer }
    currency: { type: string, default: 'gbp' }
    description: { type: string }
    orderId: { type: string }
    metadata: { type: object, additionalProperties: { type: string } }

PosPaymentIntent:
  type: object
  properties:
    id: { type: string }
    clientSecret: { type: string }
    amount: { type: integer }
    currency: { type: string }
    status: { type: string }

PosTransaction:
  type: object
  properties:
    id: { type: string }
    agentId: { type: string }
    amountInPence: { type: integer }
    currency: { type: string }
    status: { type: string }
    transactionType: { type: string, enum: [QUICK_PAYMENT, ORDER_PAYMENT] }
    orderId: { type: string }
    orderNumber: { type: string }
    cardBrand: { type: string }
    cardLast4: { type: string }
    created: { type: string, format: date-time }

PosAgent:
  type: object
  properties:
    id: { type: string }
    userId: { type: string }
    displayName: { type: string }
    enabled: { type: boolean }
    canProcessQuickPayments: { type: boolean }
    canBuildOrders: { type: boolean }
    maxTransactionAmount: { type: integer }

After updating SwaggerHub, regenerate code:

mvn clean generate-sources -pl rest


Phase 1: Database Models & Role

New Domain Models

common/src/main/java/uk/co/kedos/badger/settbuilder/pos/

  1. PosAgent.java - Users authorized for POS

    - id, siteId, userId
    - enabled, displayName, pin (hashed)
    - permissions: canProcessQuickPayments, canBuildOrders, canIssueRefunds
    - maxTransactionAmount (pence)
    - stripeLocationId
    - created, lastModified, lastActiveAt
    

  2. PosTransaction.java - Transaction records

    - id, siteId, agentId, sessionId
    - paymentIntentId, amountInPence, currency, status
    - orderId (optional), orderNumber
    - transactionType: QUICK_PAYMENT | ORDER_PAYMENT
    - description, notes
    - cardBrand, cardLast4
    - deviceId, created, livemode
    

  3. PosSession.java - Agent work sessions

    - id, siteId, agentId, deviceId
    - startedAt, endedAt, status
    - totalTransactions, totalAmountInPence
    

Role Update

common/src/main/java/uk/co/kedos/badger/settbuilder/users/Role.java

public enum Role {
    USER, ADMIN, SUPERADMIN, BANNED, POS_AGENT;
}


Phase 2: Stripe Terminal Service

common/src/main/java/uk/co/kedos/badger/settbuilder/payment/stripe/StripeTerminalService.java

New service (separate from StripePaymentService) for Terminal-specific operations:

public interface StripeTerminalService {
    // Connection tokens for SDK initialization
    String createConnectionToken(SiteContext siteContext);

    // Location management (required for Tap to Pay)
    String createLocation(SiteContext siteContext, String displayName, Address address);
    List<Location> listLocations(SiteContext siteContext);

    // Card-present payment intents
    PaymentIntent createCardPresentPaymentIntent(
        SiteContext siteContext, int amountInPence, String currency,
        Map<String, String> metadata);

    PaymentIntent capturePaymentIntent(SiteContext siteContext, String paymentIntentId);
    PaymentIntent cancelPaymentIntent(SiteContext siteContext, String paymentIntentId);
}

Implementation notes: - Stripe Java SDK already includes Terminal classes (no new deps) - Payment method type: card_present - Capture method: automatic (immediate capture after tap) - Uses existing constructRequestOptions() pattern for Stripe Connect


Phase 3: REST API Controller

rest/src/main/java/uk/co/kedos/badger/settbuilder/api/pos/PosController.java

Implements generated PosApi interface following existing patterns:

@RestController
@PreAuthorize("hasRole('POS_AGENT') or hasRole('ADMIN') or hasRole('SUPERADMIN')")
@Slf4j
@RequiredArgsConstructor
public class PosController implements PosApi {

    private final PosService posService;
    private final StripeTerminalService stripeTerminalService;
    private final RestContextHolder restContextHolder;
    private final ConversionService conversionService;

    @Override
    public ResponseEntity<PosConnectionToken> createPosConnectionToken(String locationId) {
        var siteContext = restContextHolder.getSiteContext();
        // Verify user has access to this location
        posService.verifyLocationAccess(locationId, restContextHolder.getUser(), siteContext);

        String token = stripeTerminalService.createConnectionToken(siteContext, locationId);
        return ResponseEntity.ok(new PosConnectionToken().secret(token));
    }

    @Override
    public ResponseEntity<PosPaymentIntent> createPosPaymentIntent(
            String locationId, CreatePosPaymentIntent request) {
        var siteContext = restContextHolder.getSiteContext();
        var user = restContextHolder.getUser();

        PaymentIntent intent = stripeTerminalService.createCardPresentPaymentIntent(
            siteContext,
            request.getAmountInPence(),
            request.getCurrency(),
            Map.of("agentId", user.getId(), "locationId", locationId)
        );

        // Record transaction
        posService.recordTransaction(siteContext, user, locationId, intent, request);

        return ResponseEntity.status(HttpStatus.CREATED)
            .body(conversionService.convert(intent, PosPaymentIntent.class));
    }

    // ... other endpoints follow same pattern
}

Converters (add to ConverterConfig.java): - RestPosTransactionConverter - Domain → REST - RestPosAgentConverter - Domain → REST

Endpoint Generated Method Description
GET /v1/pos/{locationId}/paymentToken generatePOSPaymentToken() Existing spec endpoint
POST /v1/pos/{locationId}/connectionToken createPosConnectionToken() Stripe Terminal SDK token
POST /v1/pos/{locationId}/paymentIntents createPosPaymentIntent() Create card_present intent
POST /v1/pos/{locationId}/paymentIntents/{id}/capture capturePosPaymentIntent() Capture after NFC tap
POST /v1/pos/{locationId}/paymentIntents/{id}/cancel cancelPosPaymentIntent() Cancel pending intent
GET /v1/pos/{locationId}/transactions listPosTransactions() Transaction history
GET /v1/pos/agents/me getCurrentPosAgent() Current agent profile

Note: Product search uses existing public /v1/product API - no new endpoint needed.


Phase 4: Admin Interface

web-admin/src/main/java/uk/co/kedos/badger/settbuilder/render/admin/PosAdminController.java

Route Description
/admin/pos Dashboard - today's stats, active agents
/admin/pos/agents List/manage POS agents
/admin/pos/agents/{id} Agent details + transaction history
/admin/pos/transactions All POS transactions
/admin/pos/settings Configure POS (enable/disable, limits)

Templates: web-admin/src/main/resources/templates/admin/pos/ - dashboard.html - agents.html, agentDetails.html - transactions.html, transactionDetails.html - settings.html

Navigation: Add to navLeft.html after Subscriptions:

<li th:class="${#strings.startsWith(requestURI,'/admin/pos')} ? active">
    <a href="/admin/pos"><i class="fa fa-mobile-alt"></i>
        <span class="nav-label">Mobile POS</span></a>
    ...
</li>

Config Keys (add to ConfigKeyDefaults.java): - posEnabled - Boolean, default false - posStripeLocationId - String - posAllowQuickPayments - Boolean, default true - posMaxTransactionAmount - Integer (pence), default 100000


Phase 5: React Native App

New repository: badger-pos-app/

Distribution Strategy

Enterprise Distribution - For internal team/charity use without public app store:

Platform Method Requirements
iOS Apple Business Manager / MDM Apple Developer Enterprise Program ($299/year) OR managed devices via MDM
Android Enterprise distribution / MDM Google Play Managed Distribution OR direct APK via MDM

iOS Enterprise Options:

  1. Apple Business Manager (Recommended)
  2. Distribute via MDM (Mobile Device Management)
  3. Requires managed Apple IDs for agents
  4. No App Store review process
  5. OTA updates via MDM push

  6. Ad-Hoc Distribution

  7. Register each device UDID (max 100 devices)
  8. Build signed IPA for specific devices
  9. Manual install via TestFlight or direct download

  10. Enterprise Program (if eligible)

  11. For organizations with 100+ employees
  12. Build and distribute internally without App Store
  13. In-house apps only for employees

Android Enterprise Options:

  1. Google Play Managed Distribution
  2. Private app channel for organization
  3. Distribute via work profile or fully managed device
  4. No public Play Store listing

  5. Direct APK Distribution

  6. Build signed APK
  7. Distribute via internal website/MDM
  8. Users enable "Unknown sources" to install

Recommended Approach for Small Charity (5-20 agents):

Phase iOS Android
Development TestFlight (free, up to 10k) Internal Testing track
Production TestFlight OR Ad-Hoc Direct APK OR Play Internal

Why this works: - TestFlight is free and supports up to 10,000 testers - No need for expensive Enterprise Program - Android APKs can be distributed directly - OTA updates via Expo EAS for both platforms - No public app store review required

Structure

src/
├── api/           # REST client (axios with JWT interceptor)
├── auth/
│   ├── AuthContext.tsx       # Auth state management
│   ├── useBiometrics.ts      # FaceID/TouchID hook
│   └── useSecureStorage.ts   # Keychain wrapper
├── screens/
│   ├── LoginScreen.tsx       # Full login
│   ├── BiometricUnlockScreen.tsx  # FaceID/TouchID prompt
│   ├── PinEntryScreen.tsx    # PIN fallback
│   ├── HomeScreen.tsx        # "Quick Pay" | "Build Order"
│   ├── QuickPaymentScreen.tsx # Numeric keypad
│   ├── ProductSearchScreen.tsx
│   ├── CartScreen.tsx
│   ├── TapToPayScreen.tsx    # NFC payment
│   └── TransactionHistoryScreen.tsx
├── hooks/
│   └── useStripeTerminal.ts  # SDK wrapper
└── store/         # Redux for cart state

Key Dependencies

// Expo framework
"expo": "~50.x"
"expo-local-authentication": "~13.x"      // FaceID/TouchID (Expo native)
"expo-secure-store": "~12.x"              // Secure credential storage (Expo native)

// Stripe Terminal (requires Expo dev client, not Expo Go)
"@stripe/stripe-terminal-react-native": "latest"

// Navigation & State
"@react-navigation/native": "^6.x"
"@react-navigation/stack": "^6.x"
"@reduxjs/toolkit": "^1.x"
"react-redux": "^8.x"

// API
"axios": "^1.x"

Note: Stripe Terminal requires native code, so will need Expo Dev Client (not Expo Go) for testing.

Stripe Terminal Flow

  1. On login: call /api/v1/pos/connection-token
  2. Initialize SDK with token
  3. Discover local mobile reader (Tap to Pay)
  4. Connect to reader with location ID
  5. On payment: create intent via API → collect with SDK → capture

Critical Files to Modify

File Change
common/src/main/java/uk/co/kedos/badger/settbuilder/users/Role.java Add POS_AGENT to enum
rest/src/main/java/uk/co/kedos/badger/settbuilder/api/converters/ConverterConfig.java Register POS converters
web-admin/src/main/resources/templates/admin/navLeft.html Add POS nav section
commerce-core/src/main/java/uk/co/kedos/badger/settbuilder/config/ConfigKeyDefaults.java Add POS config keys
commerce-core/src/main/resources/messages.properties Add config display names

New Files to Create

Domain Models & Services (common/): | Path | Purpose | |------|---------| | .../pos/PosAgent.java | Agent model with permissions | | .../pos/PosAgentRepository.java | MongoDB repository | | .../pos/PosTransaction.java | Transaction record model | | .../pos/PosTransactionRepository.java | Repository | | .../pos/PosLocation.java | Location/register model | | .../pos/PosService.java | Business logic interface | | .../pos/PosServiceImpl.java | Implementation | | .../payment/stripe/StripeTerminalService.java | Terminal interface | | .../payment/stripe/StripeTerminalServiceImpl.java | Implementation |

REST API (rest/): | Path | Purpose | |------|---------| | .../api/pos/PosController.java | REST endpoints (implements generated PosApi) | | .../api/converters/RestPosTransactionConverter.java | Domain→REST | | .../api/converters/RestPosAgentConverter.java | Domain→REST |

Admin Web UI (web-admin/): | Path | Purpose | |------|---------| | .../render/admin/PosAdminController.java | Admin web controller | | .../templates/admin/pos/dashboard.html | POS dashboard | | .../templates/admin/pos/agents.html | Agent list | | .../templates/admin/pos/transactions.html | Transaction history |


Implementation Order

Stage 0: Setup

  1. Create feature/mobile-pos branch
  2. Create docs/mobile-pos/ folder and save this plan as README.md

Stage 1: Backend Foundation

  1. Add POS_AGENT to Role.java
  2. Create PosAgent and PosTransaction domain models + repositories
  3. Create PosService interface and implementation
  4. Create StripeTerminalService for connection tokens and card_present intents

Stage 2: REST API

  1. Update OpenAPI spec on SwaggerHub with POS endpoints
  2. Run mvn clean generate-sources -pl rest to generate PosApi
  3. Create PosController implementing PosApi
  4. Create converters and register in ConverterConfig

Stage 3: Admin Web UI

  1. Create PosAdminController (web-admin, extends BaseAdminController)
  2. Create admin templates for dashboard, agents, transactions
  3. Add navigation entry to navLeft.html
  4. Add config keys and messages

Stage 4: React Native App (separate repo)

  1. Project setup with Stripe Terminal SDK
  2. Authentication flow using existing JWT auth
  3. Quick payment screen + order building
  4. Tap to Pay integration
  5. Transaction history

Verification Plan

Backend Testing

# Run unit tests
./dev-scripts/runTests.sh unit

# Run integration tests (if added)
./dev-scripts/runTests.sh integration

Manual API Testing

# Get auth token (requires valid user with POS_AGENT role)
curl -X POST https://localhost:9453/v1/auth/login -d '...'

# Test connection token endpoint
curl -H "Authorization: Bearer $TOKEN" \
  -X POST https://localhost:9453/v1/pos/{locationId}/connectionToken

# Test payment intent creation
curl -H "Authorization: Bearer $TOKEN" \
  -X POST https://localhost:9453/v1/pos/{locationId}/paymentIntents \
  -d '{"amountInPence": 1000, "currency": "gbp"}'

Stripe Test Mode

  1. Use Stripe test API keys
  2. Test with simulated Terminal reader
  3. Use test card numbers (4242...)

Admin UI Testing

  1. Start dev environment: ./dev-scripts/devEnv.sh
  2. Navigate to https://localhost:9443/admin/pos
  3. Enable a user as POS agent
  4. Verify agent appears in list
  5. Process test transaction via API
  6. Verify transaction appears in history

End-to-End Flow

  1. Admin enables user as POS agent with permissions
  2. Agent authenticates in mobile app (JWT)
  3. App gets connection token from API
  4. Agent enters amount or builds order
  5. App creates payment intent via API
  6. Agent taps customer card (NFC)
  7. Payment captured, transaction recorded
  8. Transaction visible in admin dashboard