Skip to main content

Overview

Sunschool uses a comprehensive points economy to motivate and reward learners. Points are earned through learning activities and can be saved toward parent-defined rewards.

Point Sources

Learners earn points from multiple sources:
// From server/services/points-service.ts:3
export type PointsSource =
  | "QUIZ_CORRECT"      // Earned for correct quiz answers
  | "LESSON_COMPLETE"   // Earned for completing lessons
  | "ACHIEVEMENT"       // Bonus points from achievements
  | "REDEMPTION"        // Points deducted for rewards
  | "ADMIN_ADJUST";     // Manual parent/admin adjustments

Quiz Questions

Points per correct answer

Lesson Completion

Bonus for finishing lessons

Achievements

Special milestone rewards

Points Ledger

Every point transaction is recorded in a permanent ledger:
// From server/services/points-service.ts:28
const insertLedgerSQL = `
  INSERT INTO points_ledger (
    learner_id, 
    amount, 
    source_type, 
    source_id, 
    description
  )
  VALUES ($1, $2, $3, $4, $5)
  RETURNING id`;

await client.query(insertLedgerSQL, [
  opts.learnerId,
  opts.amount,
  opts.sourceType,
  opts.sourceId || null,
  opts.description,
]);

Ledger Fields

FieldTypeDescription
learner_idintegerWho earned/spent the points
amountintegerPoints earned (positive) or spent (negative)
source_typeenumType of transaction
source_idstringReference to lesson, achievement, etc.
descriptiontextHuman-readable description
created_attimestampWhen the transaction occurred
The ledger provides a complete audit trail of all point transactions. Parents can review the full history to understand how points were earned.

Awarding Points

The service ensures transactional integrity when awarding points:
// From server/services/points-service.ts:19
async awardPoints(opts: AwardPointsOptions): Promise<{ newBalance: number }> {
  if (opts.amount <= 0) throw new Error("Amount must be positive");

  const client = await pool.connect();
  try {
    await client.query("BEGIN");

    // 1. Insert ledger entry
    await client.query(insertLedgerSQL, [...]);

    // 2. Update or create learner_points balance
    const upsertSQL = `
      INSERT INTO learner_points (learner_id, current_balance, total_earned, total_redeemed)
      VALUES ($1, $2, $2, 0)
      ON CONFLICT (learner_id)
      DO UPDATE SET 
        current_balance = learner_points.current_balance + EXCLUDED.current_balance,
        total_earned = learner_points.total_earned + EXCLUDED.current_balance,
        last_updated = NOW()
      RETURNING current_balance`;
      
    const { rows } = await client.query(upsertSQL, [opts.learnerId, opts.amount]);

    await client.query("COMMIT");
    return { newBalance: rows[0].current_balance };
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
1

Validate Amount

Ensure points to award is a positive number
2

Start Transaction

Begin database transaction for atomicity
3

Record in Ledger

Insert permanent record of the transaction
4

Update Balance

Update current balance and total earned
5

Commit

Commit transaction if all steps succeed
6

Rollback on Error

Undo all changes if any step fails

Balance Tracking

Each learner has three balance metrics:
// Database: learner_points table
interface LearnerPoints {
  learner_id: number;
  current_balance: number;    // Points available to spend
  total_earned: number;       // Lifetime points earned
  total_redeemed: number;     // Lifetime points spent
  last_updated: Date;
}

Getting Current Balance

// From server/services/points-service.ts:62
async getBalance(learnerId: number | string): Promise<number> {
  const sql = `SELECT current_balance FROM learner_points WHERE learner_id = $1`;
  const { rows } = await pool.query(sql, [learnerId]);
  return rows.length ? Number(rows[0].current_balance) : 0;
}
If a learner has never earned points, getBalance() returns 0 rather than throwing an error.

Transaction History

Parents and learners can view recent point transactions:
// From server/services/points-service.ts:68
async getHistory(learnerId: number | string, limit = 50) {
  const sql = `
    SELECT * FROM points_ledger 
    WHERE learner_id = $1 
    ORDER BY created_at DESC 
    LIMIT $2`;
  const { rows } = await pool.query(sql, [learnerId, limit]);
  return rows;
}
Example response:
[
  {
    "id": "123",
    "learner_id": 42,
    "amount": 10,
    "source_type": "QUIZ_CORRECT",
    "source_id": "lesson_abc123",
    "description": "Correct answer on Math quiz",
    "created_at": "2026-03-12T10:30:00Z"
  },
  {
    "id": "122",
    "learner_id": 42,
    "amount": 50,
    "source_type": "LESSON_COMPLETE",
    "source_id": "lesson_abc123",
    "description": "Completed lesson: Introduction to Fractions",
    "created_at": "2026-03-12T10:28:00Z"
  }
]

Integration with Rewards

Points can be saved toward specific rewards (see Rewards System):
1

Earn Points

Learner completes lessons and quizzes to earn points
2

Delegate to Goal

Learner chooses a reward and delegates points from general balance to that goal
3

Request Redemption

When goal is reached, learner requests the reward
4

Parent Approval

Parent approves or rejects the redemption
5

Points Reset

On approval, saved points reset to 0 (reward is repeatable)

Double-or-Loss Mode

An optional high-stakes mode for advanced learners:
Double-or-Loss Mode is a special feature where:
  • Correct answers earn 2x points
  • Wrong answers deduct 1 point from balance
  • Balance never goes below 0
This mode should only be enabled for learners who are comfortable with the added pressure.
// From server/services/rewards-service.ts:539
export async function applyDoubleOrLossDeduction(
  learnerId: number,
  wrongCount: number
) {
  if (wrongCount <= 0) return;
  const deduction = wrongCount;

  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // Get current balance
    const { rows: balRows } = await client.query(
      `SELECT current_balance FROM learner_points WHERE learner_id=$1`,
      [learnerId]
    );
    const balance = balRows[0]?.current_balance ?? 0;
    
    // Never deduct more than current balance
    const actualDeduction = Math.min(deduction, balance);
    if (actualDeduction <= 0) {
      await client.query('COMMIT');
      return;
    }

    // Deduct points
    await client.query(
      `UPDATE learner_points
       SET current_balance = current_balance - $1, last_updated=NOW()
       WHERE learner_id=$2`,
      [actualDeduction, learnerId]
    );
    
    // Record in ledger
    await client.query(
      `INSERT INTO points_ledger (learner_id, amount, source_type, description)
       VALUES ($1, $2, 'DOUBLE_OR_LOSS_DEDUCTION', 'Wrong answer penalty')`,
      [learnerId, -actualDeduction]
    );

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Typical Point Values

  • Correct answer: 5-10 points (configurable)
  • Double-or-loss correct: 10-20 points (2x)
  • Double-or-loss wrong: -1 point

API Examples

Award Points

import { pointsService } from './services/points-service';

await pointsService.awardPoints({
  learnerId: 42,
  amount: 10,
  sourceType: 'QUIZ_CORRECT',
  sourceId: 'lesson_123_question_5',
  description: 'Correct answer on multiplication question'
});

Check Balance

const balance = await pointsService.getBalance(42);
console.log(`Learner has ${balance} points`);

View History

const history = await pointsService.getHistory(42, 20);
history.forEach(tx => {
  console.log(`${tx.created_at}: ${tx.amount} points - ${tx.description}`);
});

Parent Controls

Parents can:

View Full History

See every point transaction with timestamps and descriptions

Manual Adjustments

Award or deduct points with ADMIN_ADJUST source type

Enable Double-or-Loss

Turn on high-stakes mode per learner

Create Rewards

Define point costs for real-world rewards