@classytic/loyalty
v0.1.0
Published
Loyalty points, tiers, earning rules, referrals, and redemption engine for MongoDB
Maintainers
Readme
@classytic/loyalty
Loyalty points, tiers, earning rules, referrals, and redemption engine for MongoDB.
Framework-agnostic. Works with Fastify (Arc), Express, NestJS, Next.js, or any Node.js app with a Mongoose connection.
Install
npm install @classytic/loyalty
# Peer deps:
npm install mongoose@^9 @classytic/mongokit@^3.4Quick Start
import { createLoyaltyEngine } from '@classytic/loyalty';
import mongoose from 'mongoose';
const engine = createLoyaltyEngine({
mongoose: mongoose.connection,
tenant: false, // single-tenant (or { field: 'orgId' } for multi-tenant)
program: { conversionRate: 10 },
redemption: { minPoints: 100, maxRedeemPercent: 50 },
referral: { referrerRewardPoints: 200, refereeRewardPoints: 100 },
});
// 6 services ready to use
const member = await engine.services.member.enroll(
{ externalId: 'cust_1', externalType: 'customer', cardId: 'MBR-001' },
{ actorId: 'admin' },
);
await engine.services.ledger.earnPoints(
{ memberId: member._id, points: 500, description: 'Order #123' },
{ actorId: 'system' },
);Architecture
createLoyaltyEngine(config)
-> models 6 Mongoose models (auto-created, OverwriteModel-safe)
-> repositories 6 repos (tenant-scoped CRUD, pagination, atomic transitions)
-> services 6 services (the public API)
-> events In-process event bus (or plug your own)Services
| Service | Purpose |
|---------|---------|
| member | Enroll, deactivate, reactivate, suspend, getByCardId, getByExternalId |
| ledger | Earn points, adjust points, transaction history, process expirations |
| earning | CRUD earning rules (order, action, category, tier_bonus types) |
| tier | CRUD tier definitions, evaluate members, set/clear overrides |
| redemption | Validate, reserve, confirm, release, cleanup expired reservations |
| referral | Generate codes, record referrals, approve/reject, rate limiting |
Key Design Decisions
- Ledger is source of truth.
Member.balanceis a denormalized cache updated atomically inside MongoDB transactions. cardIdis a first-class field. Public loyalty card identifier, unique, queryable viagetByCardId(). Separate fromreferralCode.- Tenant isolation end-to-end. All reads AND mutations (update, updateBalance, atomicStatusTransition, delete) scope by tenant. No raw
_idbypass. - Idempotency on both earn and adjust. Prevents double-crediting on retries, cancel+refund, or network failures.
- OverwriteModel-safe.
createLoyaltyEngine()can be called multiple times on the same connection (hot reload, tests).
Using with @classytic/arc
The package integrates naturally with Arc's resource pattern. Use defineResource with disableDefaultRoutes: true and inline handlers that call the engine services:
import { defineResource } from '@classytic/arc';
import { getLoyaltyEngine } from './loyalty.plugin.js';
export const memberResource = defineResource({
name: 'loyalty-member',
prefix: '/loyalty/members',
disableDefaultRoutes: true,
additionalRoutes: [
{
method: 'POST', path: '/', summary: 'Enroll member',
permissions: permissions.loyalty.manage, wrapHandler: false,
schema: memberSchemas.enroll,
handler: async (req, reply) => {
const member = await getLoyaltyEngine().services.member.enroll(
{ externalId: req.body.customerId, externalType: 'customer' },
{ actorId: req.user.id },
);
return reply.code(201).send({ success: true, data: member });
},
},
// ... more routes
],
});This is the same pattern used for warehouse resources (warehouse.resources.ts) in Arc-based projects.
Compatibility
| Concept | Arc | Loyalty | Status |
|---------|-----|---------|--------|
| PaginatedResult<T> | { docs, page, limit, total, pages, hasNext, hasPrev } | Same shape | Identical |
| Repository | RepositoryLike / CrudRepository<T> | Domain ports (MemberPort, etc.) | Compatible |
| Transactions | session?: unknown in QueryOptions | session?: TransactionSession in method params | Compatible |
| Events | Arc event registry | engine.events (EventEmitterPort) | Bridgeable |
Multi-tenant
// Single company, multi-branch (Nike with stores)
createLoyaltyEngine({ tenant: false });
// Store branchId in transaction metadata for analytics
// Multi-tenant SaaS (Shopify-like)
createLoyaltyEngine({ tenant: { field: 'organizationId' } });
// Custom tenant field
createLoyaltyEngine({ tenant: { field: 'companyId', type: 'string', contextKey: 'companyId' } });Typed DTOs
All public service methods accept typed inputs:
import type {
EnrollInput,
EarnPointsInput,
AdjustPointsInput,
CreateEarningRuleInput,
UpdateEarningRuleInput,
CreateTierInput,
UpdateTierInput,
LoyaltyContext,
} from '@classytic/loyalty';Events
import { LoyaltyEvents } from '@classytic/loyalty/events';
engine.events.on(LoyaltyEvents.POINTS_EARNED, (payload) => { /* sync projection */ });
engine.events.on(LoyaltyEvents.TIER_UPGRADED, (payload) => { /* notify customer */ });
engine.events.on(LoyaltyEvents.REFERRAL_REWARDED, (payload) => { /* notify both */ });Production Cron Jobs
// Every 5 min — release expired point reservations
await engine.services.redemption.cleanupExpired({ actorId: 'cron' });
// Every hour — expire points past their expiresAt
await engine.services.ledger.processExpirations({ actorId: 'cron' });
// Every 24h — re-evaluate all member tiers
await engine.services.tier.evaluateAll({ actorId: 'cron' });Error Codes
| Code | HTTP | When |
|------|------|------|
| MEMBER_NOT_FOUND | 404 | Member doesn't exist |
| MEMBER_ALREADY_ENROLLED | 409 | Duplicate enrollment |
| MEMBER_INACTIVE | 422 | Operating on inactive member |
| INSUFFICIENT_POINTS | 400 | Not enough points to deduct |
| RULE_NOT_FOUND | 404 | Earning rule doesn't exist |
| TIER_NOT_FOUND | 404 | Tier definition doesn't exist |
| REDEMPTION_EXPIRED | 410 | Reservation TTL exceeded |
| DUPLICATE_REFERRAL | 409 | Referee already referred |
| SELF_REFERRAL | 422 | Referring yourself |
| REFERRAL_LIMIT_EXCEEDED | 429 | Max referrals per period |
| TENANT_ISOLATION | 403 | Cross-tenant access attempt |
AI Agent Skill
Install the agent skill for AI-assisted integration:
npx skills add classytic/loyaltyLicense
MIT
