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
Field Type Description learner_idinteger Who earned/spent the points amountinteger Points earned (positive) or spent (negative) source_typeenum Type of transaction source_idstring Reference to lesson, achievement, etc. descriptiontext Human-readable description created_attimestamp When 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 ();
}
}
Validate Amount
Ensure points to award is a positive number
Start Transaction
Begin database transaction for atomicity
Record in Ledger
Insert permanent record of the transaction
Update Balance
Update current balance and total earned
Commit
Commit transaction if all steps succeed
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 ):
Earn Points
Learner completes lessons and quizzes to earn points
Delegate to Goal
Learner chooses a reward and delegates points from general balance to that goal
Request Redemption
When goal is reached, learner requests the reward
Parent Approval
Parent approves or rejects the redemption
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
Quiz Answers
Lessons
Achievements
Correct answer : 5-10 points (configurable)
Double-or-loss correct : 10-20 points (2x)
Double-or-loss wrong : -1 point
Lesson completion : 25-50 points (based on length)
Perfect score bonus : +10 points
First lesson : See Achievements
First Steps : 100 points
Learning Explorer : 250 points
Perfect Score : 50 points
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