@classytic/loyalty
v0.5.0
Published
Loyalty points, tiers, earning rules, referrals, and redemption engine for MongoDB
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 zod@^4 \
@classytic/mongokit@^3.16 @classytic/repo-core@^0.6 @classytic/primitives@^0.7Quick 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 },
});
// Domain verbs live on the repositories (no service layer — PACKAGE_RULES §1–§3)
const member = await engine.repositories.member.enroll(
{ externalId: 'cust_1', externalType: 'customer', cardId: 'MBR-001' },
{ actorId: 'admin', organizationId: 'org_1' },
);
await engine.repositories.pointTransaction.earnPoints(
{ memberId: member._id, points: 500, description: 'Order #123' },
{ actorId: 'system', organizationId: 'org_1' },
);Architecture
createLoyaltyEngine(config)
-> models 6 Mongoose models (auto-created, collision-safe)
-> repositories 6 repos = the public API (tenant-scoped CRUD + domain verbs)
-> events Arc-compatible transport (in-process fallback, or plug your own)
-> syncIndexes() deploy-time index builderThe capability boot gate asserts the backend supports multi-document
transactions (MongoDB replica set / sharded cluster) — the point-mutation
verbs are transactional. For local-dev on standalone Mongo, pass
allowNonTransactional: true (accepts partial-write risk).
Repositories (the public API)
Domain verbs live on the repositories themselves — there is no service layer
(PACKAGE_RULES §1–§3). Access via engine.repositories.<name>.
| Repository | Purpose |
|------------|---------|
| member | Enroll, deactivate, reactivate, suspend |
| pointTransaction | Earn points, adjust points, awardBatch, process expirations |
| earningRule | CRUD earning rules (order, action, category, tier_bonus); evaluateOrder / evaluateAction |
| tierDefinition | CRUD tier definitions, evaluate members, set/clear/sweep overrides |
| redemption | Validate, reserve, confirm, release, reverse, 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';Migration Notes
Earning rates now live in earning rules, not host membership config fields such
as PlatformConfig.membership.pointsPerAmount or amountPerPoint. See
docs/config-engine-migration.md for the
LoyaltyConfig to engine boundary and migration checklist.
Events
engine.events is an arc-compatible EventTransport — subscribe glob-style.
Drop in any arc transport (Memory/Redis/Kafka) via eventTransport in config.
import { LoyaltyEvents } from '@classytic/loyalty/events';
await engine.events.subscribe(LoyaltyEvents.POINTS_EARNED, (e) => { /* sync projection */ });
await engine.events.subscribe('loyalty.tier.*', (e) => { /* notify customer */ });
await engine.events.subscribe(LoyaltyEvents.REFERRAL_REWARDED, (e) => { /* notify both */ });For durable delivery, wire a host-owned outbox (PACKAGE_RULES §5.5 + §P8):
the engine saves the event row inside the same transaction as the business
write (atomic), then publishes after commit. Hosts that own the outer
transaction must attach a [PENDING_EVENTS] queue and call
flushPendingLoyaltyEvents(transport, queue) after their commit, or a bare
ctx.session throws UnmanagedSessionError.
Production Cron Jobs
All cron loops accept ctx.signal (an AbortSignal) and check it between
items, so a shutdown / timeout aborts cleanly between committed units of work.
const ctx = { actorId: 'cron', organizationId: 'org_1', signal: ac.signal };
// Every 5 min — release expired point reservations
await engine.repositories.redemption.cleanupExpired(ctx);
// Every hour — expire points past their expiresAt
await engine.repositories.pointTransaction.processExpirations(ctx);
// Every 24h — re-evaluate all member tiers
await engine.repositories.tierDefinition.evaluateAll(ctx);
// Hourly — clear expired tier overrides
await engine.repositories.tierDefinition.sweepExpiredOverrides(ctx);Error Codes
All domain errors implement HttpError from @classytic/repo-core/errors
(arc serializes them to the canonical ErrorContract). Codes are hierarchical
and lowercase.
| Code | HTTP | When |
|------|------|------|
| loyalty.member.not_found | 404 | Member doesn't exist |
| loyalty.member.already_enrolled | 409 | Duplicate enrollment |
| loyalty.member.inactive | 422 | Operating on inactive member |
| loyalty.points.insufficient | 400 | Not enough points to deduct |
| loyalty.earning_rule.not_found | 404 | Earning rule doesn't exist |
| loyalty.tier.not_found | 404 | Tier definition doesn't exist |
| loyalty.redemption.expired | 410 | Reservation TTL exceeded |
| loyalty.referral.duplicate | 409 | Referee already referred |
| loyalty.referral.self | 422 | Referring yourself |
| loyalty.referral.circular | 422 | A↔B circular referral |
| loyalty.referral.limit_exceeded | 429 | Max referrals per period |
| loyalty.tenant.isolation | 403 | Missing tenant context |
| loyalty.transaction.unmanaged_session | 400 | ctx.session passed without a [PENDING_EVENTS] queue |
| loyalty.engine.missing_capabilities | 500 | Backend lacks transactions at boot |
AI Agent Skill
Install the agent skill for AI-assisted integration:
npx skills add classytic/loyaltyLicense
MIT
