Naalya Handbook
Payments

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:

kindTargetsExtra fields
user_typeEvery user of a non-student typeuserType: staff / guardian / guest
studentsStudents, optionally narrowedclassIds?, streamIds?
specific_usersAn explicit list of usersuserIds
resourcePayers of a resource instanceresource (a ResourceAudienceType)
apps/server/src/app/payment-item/dto/payment-item.types.ts
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:

  1. Eligibility (synchronous) — does this payer match? Decides whether someone sees and can pay an item right now.
  2. 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().

apps/server/src/app/payment-item/audience/audience.types.ts
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:

apps/server/src/app/payment-item/audience/audience.service.ts
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:

apps/server/src/app/payment-item/audience/resolvers/user-type.resolver.ts
@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:

apps/server/src/app/payment-item/audience/resource/resource-audience.registry.ts
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 kindEligibilityMaterialized into charges?
user_typeMatch payer's typeNo — eligibility-only
studentsMatch class/streamYes — via enrollment + class/stream filters
specific_usersPayer in the listYes — 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

On this page