Audiences
Who a payment item applies to, and how those audience rules are resolved and measured.
An audience is the set of users a payment item applies to — who can see and pay it. It lives on the item as an array of audience rules (AudienceRule[], stored JSONB so the shape can vary per rule), declared in apps/server/src/app/payment-item/dto/payment-item.types.ts.
Rule kinds
Each rule is a tagged union — a kind (AudienceType) picks which other fields it carries:
kind | Targets | Extra fields |
|---|---|---|
user_type | Every user of a non-student type | userType: staff / guardian / guest |
students | Students, optionally narrowed | classIds?, streamIds? |
specific_users | An explicit list of users | userIds |
resource | Payers of a resource instance | resource (a ResourceAudienceType) |
type AudienceRule =
| { kind: AudienceType.USER_TYPE; userType: AudienceUserType }
| { kind: AudienceType.STUDENTS; classIds?: string[]; streamIds?: string[] }
| { kind: AudienceType.SPECIFIC_USERS; userIds: string[] }
| { kind: AudienceType.RESOURCE; resource: ResourceAudienceType };An item carries many rules; a payer is in the audience if any rule matches (OR semantics).
Two ways to measure
A rule is "measured" two different ways, by two self-registering systems — both use the same plug-in registry pattern as the gateway registry:
- Eligibility (synchronous) — does this payer match? Decides whether someone sees and can pay an item right now.
- Materialization (for ASSIGNED items) — expand the rule into concrete per-user charge rows (a charge = one user's row-level obligation). Covered in Charges vs Transactions.
Eligibility resolvers
An AudienceResolver is a class that evaluates one rule kind. The interface stays tiny: a kind plus a pure, synchronous matches().
export interface PayerContext {
userId: string;
userType: UserType;
campusId?: string;
classId?: string;
streamId?: string;
}
export interface AudienceResolver {
readonly kind: AudienceType;
matches(rule: AudienceRule, payer: PayerContext): boolean;
}A PayerContext is the bag of resolved facts about the payer that any rule might need — id, type, and (for students) their campus/class/stream. AudienceService builds it once per request in buildPayerContext(): it loads the profile via userService.resolveProfile(), and for a student also reads their current enrollment (the ACTIVE row tying a student to a class/stream) to fill classId / streamId. Building it once is what lets each resolver stay pure and synchronous — they get the facts, they don't fetch.
Resolvers are collected under a single dependency-injection token (AUDIENCE_RESOLVERS — a key NestJS uses to inject the whole set), then keyed by kind:
isEligible(item: Pick<PaymentItem, 'audience'>, payer: PayerContext): boolean {
return item.audience.some((rule) => {
const resolver = this.resolvers.get(rule.kind);
return resolver ? resolver.matches(rule, payer) : false;
});
}A concrete resolver only does its own kind, and returns false for anything else:
@Injectable()
export class UserTypeAudienceResolver implements AudienceResolver {
readonly kind = AudienceType.USER_TYPE;
matches(rule: AudienceRule, payer: PayerContext): boolean {
if (rule.kind !== AudienceType.USER_TYPE) return false;
return UserTypeAudienceResolver.TYPE_MAP[rule.userType] === payer.userType;
}
}Why this shape: a new billable concept = a new resolver registered under the token. AudienceService never changes — it just gains another entry in its Map<AudienceType, AudienceResolver>.
Resource-bound rules
The resource kind is special: the matching logic lives in another domain (e.g. an admission application charging for its own payment). To avoid the payment-item module depending on those domains at compile time, a ResourceAudienceRegistry lets each domain contribute a resolver at module init — the moment NestJS wires up modules at startup:
register(resolver: ResourceAudienceResolver): void {
this.resolvers.set(resolver.resource, resolver);
}PaymentItemService looks one up by ResourceAudienceType only when paying a resource-bound item; an unknown resource throws a 400.
Eligibility vs materialization
| Rule kind | Eligibility | Materialized into charges? |
|---|---|---|
user_type | Match payer's type | No — eligibility-only |
students | Match class/stream | Yes — via enrollment + class/stream filters |
specific_users | Payer in the list | Yes — directly |
user_type rules are open-ended (every staff member, forever), so they're never expanded into per-user charge rows — they stay a live eligibility check only.
Where to go next
Payment Items
The item an audience is attached to, and ELIGIBILITY vs ASSIGNED modes.
Charges vs Transactions
How students and specific_users rules materialize into charge rows.
Gateways
The provider registry this resolver pattern mirrors.
Multi-Tenancy
How campus and tenant scoping frames a payer's context.