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:
- Initial login - Full JWT authentication
- User logs in with existing credentials (email/password)
- Requires
POS_AGENTrole (new role to add) - Returns JWT token, stored in secure keychain
-
Sets up device for quick unlock
-
Biometric unlock - FaceID/TouchID (Primary quick unlock)
- Uses
react-native-biometricsor similar library - JWT token retrieved from secure keychain after biometric success
-
Falls back to PIN if biometrics unavailable
-
PIN unlock - Fallback for devices without biometrics
- 4-6 digit PIN set during initial setup
- PIN hash stored locally
- Used when biometrics fail or unavailable
- 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):
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:
Phase 1: Database Models & Role¶
New Domain Models¶
common/src/main/java/uk/co/kedos/badger/settbuilder/pos/
-
PosAgent.java - Users authorized for POS
-
PosTransaction.java - Transaction records
-
PosSession.java - Agent work sessions
Role Update¶
common/src/main/java/uk/co/kedos/badger/settbuilder/users/Role.java
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:
- Apple Business Manager (Recommended)
- Distribute via MDM (Mobile Device Management)
- Requires managed Apple IDs for agents
- No App Store review process
-
OTA updates via MDM push
-
Ad-Hoc Distribution
- Register each device UDID (max 100 devices)
- Build signed IPA for specific devices
-
Manual install via TestFlight or direct download
-
Enterprise Program (if eligible)
- For organizations with 100+ employees
- Build and distribute internally without App Store
- In-house apps only for employees
Android Enterprise Options:
- Google Play Managed Distribution
- Private app channel for organization
- Distribute via work profile or fully managed device
-
No public Play Store listing
-
Direct APK Distribution
- Build signed APK
- Distribute via internal website/MDM
- 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¶
- On login: call
/api/v1/pos/connection-token - Initialize SDK with token
- Discover local mobile reader (Tap to Pay)
- Connect to reader with location ID
- 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¶
- Create
feature/mobile-posbranch - Create
docs/mobile-pos/folder and save this plan asREADME.md
Stage 1: Backend Foundation¶
- Add
POS_AGENTtoRole.java - Create
PosAgentandPosTransactiondomain models + repositories - Create
PosServiceinterface and implementation - Create
StripeTerminalServicefor connection tokens and card_present intents
Stage 2: REST API¶
- Update OpenAPI spec on SwaggerHub with POS endpoints
- Run
mvn clean generate-sources -pl restto generatePosApi - Create
PosControllerimplementingPosApi - Create converters and register in
ConverterConfig
Stage 3: Admin Web UI¶
- Create
PosAdminController(web-admin, extendsBaseAdminController) - Create admin templates for dashboard, agents, transactions
- Add navigation entry to
navLeft.html - Add config keys and messages
Stage 4: React Native App (separate repo)¶
- Project setup with Stripe Terminal SDK
- Authentication flow using existing JWT auth
- Quick payment screen + order building
- Tap to Pay integration
- 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¶
- Use Stripe test API keys
- Test with simulated Terminal reader
- Use test card numbers (4242...)
Admin UI Testing¶
- Start dev environment:
./dev-scripts/devEnv.sh - Navigate to https://localhost:9443/admin/pos
- Enable a user as POS agent
- Verify agent appears in list
- Process test transaction via API
- Verify transaction appears in history
End-to-End Flow¶
- Admin enables user as POS agent with permissions
- Agent authenticates in mobile app (JWT)
- App gets connection token from API
- Agent enters amount or builds order
- App creates payment intent via API
- Agent taps customer card (NFC)
- Payment captured, transaction recorded
- Transaction visible in admin dashboard