Reciprocity Agreements
Overview
A system for managing inter-club reciprocity arrangements, allowing members of one club to play at partner clubs with special rates and benefits.
Status: Implemented (tracking: #10371 open for closure/rollout)
The reciprocity feature is fully implemented with database schema, services, and pricing integration.
This guide focuses on agreement structure and operational details for clubs and administrators.
Features
Agreement Types
- Bilateral: Two clubs agree to honor each other's members
- Network Membership: All-to-all agreements (e.g., SAGA-like networks)
- Corporate Sponsorship: Company-funded access across multiple clubs
- Association Tier: Handicap-based access (e.g., low-handicap reciprocity)
Rate Configurations
- Percentage Discount: X% off standard rate
- Fixed Amount: Flat discount (e.g., R200 off)
- Fixed Rate: Set price regardless of standard rate
- Rate Tier: Map to specific club rate tier (member, affiliate, etc.)
Restrictions
- Handicap Ranges: Min/max handicap index requirements
- Time Windows: Specific days/times when reciprocity applies
- Blackout Dates: Excluded dates (tournaments, peak days)
- Booking Limits: Max bookings per month/year
Priority System
- Multiple agreements evaluated in priority order
- First matching agreement wins
- Higher priority = lower number
Data Model
ReciprocityAgreement
├── id, tenantId
├── homeClubId (granting club)
├── partnerClubId (receiving club, nullable for networks)
├── agreementType (BILATERAL, NETWORK, CORPORATE, ASSOCIATION)
├── name, description
├── status (DRAFT, ACTIVE, SUSPENDED, EXPIRED)
├── startDate, endDate
├── priority
└── settings (JSON)
ReciprocityRateConfig
├── agreementId
├── discountType (PERCENTAGE, FIXED_AMOUNT, FIXED_RATE, RATE_TIER)
├── discountValue
├── rateTierId (for RATE_TIER type)
└── applicableDays (bitmap: Sun-Sat)
ReciprocityRestrictions
├── agreementId
├── handicapMin, handicapMax
├── timeWindowStart, timeWindowEnd
├── blackoutDates (JSON array)
├── maxBookingsPerMonth
└── maxBookingsPerYear
BenefitMembership
├── playerId
├── benefitProviderId (club or association)
├── membershipTier
├── homeClubId
├── status (ACTIVE, EXPIRED, SUSPENDED)
├── validFrom, validTo
└── verificationData (JSON)
API Endpoints
Admin Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /admin/reciprocity | List agreements |
| POST | /admin/reciprocity | Create agreement |
| GET | /admin/reciprocity/:id | Get agreement |
| PATCH | /admin/reciprocity/:id | Update agreement |
| DELETE | /admin/reciprocity/:id | Delete agreement |
| POST | /admin/reciprocity/:id/activate | Activate |
| POST | /admin/reciprocity/:id/suspend | Suspend |
Player Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /v1/players/me/benefits | My benefits |
| GET | /v1/clubs/:id/reciprocity | Club's reciprocity info |
Services
ReciprocityService
listAgreements(clubId)- All agreements for a clubcreateAgreement(data)- New agreementupdateAgreement(id, data)- Modify agreementactivateAgreement(id)- Set to ACTIVEsuspendAgreement(id)- Temporarily disablecheckEligibility(playerId, clubId)- Check if player qualifies
EligibilityService
getApplicableAgreements(playerId, clubId, date)- Matching agreementscalculateRate(playerId, clubId, slotId)- Best available ratevalidateRestrictions(agreementId, playerId, date)- Check limits
ReciprocityConflictService
detectConflicts(agreementId)- Find overlapping agreementsresolveByPriority(agreements)- Order by priority
AgreementExpirySchedulerService
expireAgreements()- Scheduled job for expirynotifyExpiringAgreements(daysAhead)- Warning notifications
Pricing Integration
// Price resolution flow
1. Get slot base price
2. Check player benefits/memberships
3. Find applicable reciprocity agreements
4. Apply best rate from:
- Player's home club rate (if member)
- Reciprocity agreement rate
- Visitor rate (fallback)
5. Apply any additional discounts (specials, promotions)
Example Configurations
Bilateral Agreement
{
"agreementType": "BILATERAL",
"homeClubId": "club-a-uuid",
"partnerClubId": "club-b-uuid",
"name": "Pine Valley ↔ Royal Links",
"rateConfig": {
"discountType": "PERCENTAGE",
"discountValue": 50
},
"restrictions": {
"handicapMax": 18,
"maxBookingsPerMonth": 4
}
}
Network Membership
{
"agreementType": "NETWORK",
"homeClubId": "network-provider-uuid",
"partnerClubId": null,
"name": "Premier Golf Network",
"rateConfig": {
"discountType": "RATE_TIER",
"rateTierId": "affiliate-tier-uuid"
},
"restrictions": {
"blackoutDates": ["2025-12-25", "2025-01-01"]
}
}
Integration Points
- Pricing Engine: Rate resolution with reciprocity discounts
- Booking Service: Validate eligibility at booking time
- Benefits System: Membership verification
- Association Providers: External handicap/membership lookup
Admin UI (Complete)
The admin UI is implemented in libs/ui/benefits-admin/ and integrated into teetime-admin at /benefits:
| Component | Features |
|---|---|
| AgreementsTable | List, search, filter, bulk toggle/delete, import, clone, history |
| AgreementFormDrawer | Create/edit with all fields (type, discount, restrictions, days, time, blackout, handicap) |
| NetworksManager | Add/remove/toggle network memberships |
| HomeClubMapEditor | Provider-specific home club mappings |
| EligibilityPreview | Test eligibility calculations |
| DiagnosticsView | Run diagnostic checks |
Feature Flag: VITE_ENABLE_BENEFITS_ADMIN (enabled by default)
Mobile UX Integration (Complete)
The mobile app displays reciprocity benefits throughout the booking flow:
Components
| Component | Location | Features |
|---|---|---|
| SlotPrice | libs/mobile-booking/src/lib/components/slot/SlotPrice.tsx | Member/Reciprocal badges, crossed-out original price, green member rate, "Save R{amount}" indicator |
| SlotCard | libs/mobile-booking/src/lib/components/slot/SlotCard.tsx | Eligibility enforcement via canBook, deny reason display with formatted message |
| SlotSelectionModal | libs/mobile-booking/src/lib/components/slot/SlotSelectionModal.tsx | Toast notifications for booking denials |
| VenueCard | libs/mobile-booking/src/lib/components/venue/VenueCard.tsx | Currency and eligibility fields propagation |
Type Definitions
Eligibility fields defined in libs/mobile-booking/src/lib/types/slot.types.ts:54-68:
// Eligibility/reciprocity fields (from SSE response)
eligibilityPrice?: number; // Final price after discounts
eligibilityRole?: 'VISITOR' | 'RECIPROCAL' | 'MEMBER';
reciprocityEligible?: boolean; // Qualifies for reciprocity pricing
isHomeClub?: boolean; // Booking at home club
canBook?: boolean; // Policy gate
denyReason?: string; // e.g. OUTSIDE_BOOKING_WINDOW
currencyCode?: string; // ISO-4217 currency code
SSE Data Flow
Backend attaches eligibility fields to each slot in the SSE stream:
// From search-engine.service.ts
{
price: 850, // Base visitor price
eligibilityPrice: 650, // Final price after reciprocity
eligibilityRole: 'RECIPROCAL', // VISITOR | RECIPROCAL | MEMBER
canBook: true, // false if outside policy
denyReason: null, // OUTSIDE_BOOKING_WINDOW, etc.
currencyCode: 'ZAR' // For multi-currency display
}
Multi-Currency Support
Currency resolution chain in libs/tee-time-services/src/lib/utils/currency.util.ts:
slot.currencyCode(if present)club.meta?.currencyCodeclub.mcaV1Region→ region mappingclub.countryId→ country mapping- Default:
'ZAR'
Mobile fallback symbols in libs/mobile-booking/src/lib/utils/currency.ts:
- ZAR →
R, GBP →£, USD →$, EUR →€, AUD →A$, CAD →C$
Test Coverage
Backend (tee-time-services)
| Test File | Coverage |
|---|---|
eligibility.service.spec.ts | Eligibility calculations, rate resolution |
access-policy.service.spec.ts | canBook/denyReason logic |
search-engine.eligibility.spec.ts | SSE eligibility field emission |
search-engine.pricing.spec.ts | Price resolution with reciprocity |
booking.controller.benefits.spec.ts | Benefits application at booking time |
booking.confirm.benefits.int.spec.ts | Integration: benefits → booking confirmation |
club-visitor.service.spec.ts | Candidate player eligibility, currency resolution |
Mobile (mobile-booking)
| Test File | Coverage |
|---|---|
currency.spec.ts | formatCurrency, formatCurrencyParts, fallback symbols |
pricing.service.spec.ts | SSE tee sheet → slot mapping, eligibility field propagation |
Note: Zustand store tests require React context (@testing-library/react) - P1 future work.
Future Enhancements
- Admin UI for agreement management
- Mobile reciprocity badges and savings display
- Eligibility enforcement in booking flow
- Player-visible benefits dashboard (web)
- Usage reporting and analytics
- Automated renewal workflows