Overview
Sunschool’s rewards system allows parents to create real-world incentives that learners can work toward. Learners save points toward specific rewards, request redemption when ready, and parents approve or reject requests.
Reward Lifecycle
Parent Creates Reward
Parent defines a reward with title, description, point cost, and optional limits
Learner Saves Points
Learner delegates points from general balance toward the reward goal
Goal Reached
When saved points ≥ reward cost, learner can request redemption
Parent Reviews
Parent approves or rejects the redemption request
Reward Fulfilled
On approval, saved points reset to 0 and learner can start saving again (repeatable)
Creating Rewards
Reward Fields
// From server/services/rewards-service.ts:120
export async function createReward (
parentId : number ,
data : {
title : string ; // "30 minutes extra screen time"
description ?: string ; // "Watch a movie or play a game"
tokenCost : number ; // 500 points
category ?: string ; // "SCREEN_TIME", "TOYS", "ACTIVITIES"
maxRedemptions ?: number | null ; // null = unlimited, or set a limit
imageEmoji ?: string ; // "🎮" for display
color ?: string ; // "#4A90D9" for UI theming
}
)
Example Rewards
Screen Time Cost : 500 pointsDescription : 30 extra minutes of TV or gamingMax Redemptions : UnlimitedEmoji : 📺
Ice Cream Trip Cost : 1000 pointsDescription : Visit the ice cream shopMax Redemptions : 4 per monthEmoji : 🍦
New Book Cost : 2000 pointsDescription : Choose a new book from the bookstoreMax Redemptions : 1 per rewardEmoji : 📖
Movie Night Cost : 1500 pointsDescription : Family movie night with popcornMax Redemptions : UnlimitedEmoji : 🍿
Reward Categories
Recommended categories for organization:
SCREEN_TIME: Extra device usage
TOYS: Physical toys or games
ACTIVITIES: Outings, special activities
TREATS: Food treats, desserts
PRIVILEGES: Special privileges (stay up late, etc.)
EXPERIENCES: Events, trips
GENERAL: Uncategorized rewards
Saving Points Toward Goals
Learners can delegate points from their general balance to specific reward goals:
// From server/services/rewards-service.ts:282
export async function delegatePointsToGoal (
learnerId : number ,
rewardId : string ,
points : number
) {
if ( points <= 0 ) throw new Error ( 'Points must be positive' );
const client = await pool . connect ();
try {
await client . query ( 'BEGIN' );
// 1. Check 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 ;
if ( balance < points ) throw new Error ( 'Insufficient balance' );
// 2. Deduct from learner_points
await client . query (
`UPDATE learner_points
SET current_balance = current_balance - $1, last_updated = NOW()
WHERE learner_id = $2` ,
[ points , learnerId ]
);
// 3. Record ledger entry
await client . query (
`INSERT INTO points_ledger (learner_id, amount, source_type, source_id, description)
VALUES ($1, $2, 'GOAL_DELEGATION', $3, 'Points saved toward reward goal')` ,
[ learnerId , - points , rewardId ]
);
// 4. Upsert goal savings
await client . query (
`INSERT INTO reward_goal_savings (learner_id, reward_id, saved_points)
VALUES ($1, $2, $3)
ON CONFLICT (learner_id, reward_id)
DO UPDATE SET saved_points = reward_goal_savings.saved_points + $3,
updated_at = NOW()` ,
[ learnerId , rewardId , points ]
);
await client . query ( 'COMMIT' );
} catch ( err ) {
await client . query ( 'ROLLBACK' );
throw err ;
} finally {
client . release ();
}
}
Delegating points moves them from the learner’s general balance into a specific reward goal. They cannot be used for other rewards until the current goal is completed or the reward is deleted (which refunds the points).
Redemption Workflow
Requesting Redemption
// From server/services/rewards-service.ts:338
export async function requestRedemption (
learnerId : number ,
rewardId : string
) {
// 1. Verify savings ≥ reward cost
const { rows : rewardRows } = await pool . query (
`SELECT token_cost FROM rewards WHERE id=$1 AND is_active=TRUE` ,
[ rewardId ]
);
if ( ! rewardRows [ 0 ]) throw new Error ( 'Reward not found' );
const tokenCost = rewardRows [ 0 ]. token_cost ;
const { rows : savingsRows } = await pool . query (
`SELECT saved_points FROM reward_goal_savings
WHERE learner_id=$1 AND reward_id=$2` ,
[ learnerId , rewardId ]
);
const savedPoints = savingsRows [ 0 ]?. saved_points ?? 0 ;
if ( savedPoints < tokenCost ) {
throw new Error ( `Need ${ tokenCost } saved points, have ${ savedPoints } ` );
}
// 2. Check for existing pending request
const { rows : existingRows } = await pool . query (
`SELECT id FROM reward_redemptions
WHERE learner_id=$1 AND reward_id=$2 AND status='PENDING'` ,
[ learnerId , rewardId ]
);
if ( existingRows [ 0 ]) throw new Error ( 'Redemption already pending' );
// 3. Create redemption request
const { rows } = await pool . query (
`INSERT INTO reward_redemptions (learner_id, reward_id, tokens_spent, status)
VALUES ($1, $2, $3, 'PENDING')
RETURNING *` ,
[ learnerId , rewardId , tokenCost ]
);
return toRedemption ( rows [ 0 ]);
}
Redemption States
PENDING
APPROVED
REJECTED
Request submitted, waiting for parent approval
Parent approved — reward will be fulfilled
Parent rejected — points remain saved
Parent Approval
Approving a Redemption
// From server/services/rewards-service.ts:424
export async function approveRedemption (
redemptionId : string ,
parentId : number ,
notes ?: string
) {
const client = await pool . connect ();
try {
await client . query ( 'BEGIN' );
// 1. Verify the redemption belongs to this parent
const { rows : rRows } = await client . query (
`SELECT rr.*, r.token_cost, r.max_redemptions, r.current_redemptions
FROM reward_redemptions rr
JOIN rewards r ON r.id = rr.reward_id
WHERE rr.id=$1 AND r.parent_id=$2 AND rr.status='PENDING'` ,
[ redemptionId , parentId ]
);
if ( ! rRows [ 0 ]) throw new Error ( 'Redemption not found or not pending' );
const r = rRows [ 0 ];
// 2. Approve
await client . query (
`UPDATE reward_redemptions
SET status='APPROVED', approved_at=NOW(), completed_at=NOW(), parent_notes=$1
WHERE id=$2` ,
[ notes ?? null , redemptionId ]
);
// 3. Increment reward.current_redemptions
const { rows : updatedReward } = await client . query (
`UPDATE rewards SET current_redemptions = current_redemptions + 1,
updated_at=NOW()
WHERE id=$1
RETURNING current_redemptions, max_redemptions` ,
[ r . reward_id ]
);
// 4. Deduct saved_points (reset to 0 so they can earn again)
await client . query (
`UPDATE reward_goal_savings SET saved_points=0, updated_at=NOW()
WHERE learner_id=$1 AND reward_id=$2` ,
[ r . learner_id , r . reward_id ]
);
// 5. Auto-deactivate if max redemptions reached
const ur = updatedReward [ 0 ];
if ( ur ?. max_redemptions && ur . current_redemptions >= ur . max_redemptions ) {
await client . query (
`UPDATE rewards SET is_active = FALSE, updated_at=NOW() WHERE id=$1` ,
[ r . reward_id ]
);
// Refund any other learners' savings for this now-deactivated reward
await refundSavingsForReward ( client , r . reward_id );
}
await client . query ( 'COMMIT' );
return { success: true , learnerId: r . learner_id , rewardId: r . reward_id };
} catch ( err ) {
await client . query ( 'ROLLBACK' );
throw err ;
} finally {
client . release ();
}
}
When a reward is approved:
Saved points are reset to 0 (not refunded to general balance)
The reward can be earned again (repeatable)
If max_redemptions is set and reached, the reward auto-deactivates
Rejecting a Redemption
// From server/services/rewards-service.ts:488
export async function rejectRedemption (
redemptionId : string ,
parentId : number ,
notes ?: string
) {
const { rows : rRows } = await pool . query (
`SELECT rr.learner_id, rr.reward_id, rr.tokens_spent
FROM reward_redemptions rr
JOIN rewards r ON r.id = rr.reward_id
WHERE rr.id=$1 AND r.parent_id=$2 AND rr.status='PENDING'` ,
[ redemptionId , parentId ]
);
if ( ! rRows [ 0 ]) throw new Error ( 'Redemption not found' );
await pool . query (
`UPDATE reward_redemptions
SET status='REJECTED', rejected_at=NOW(), parent_notes=$1
WHERE id=$2` ,
[ notes ?? null , redemptionId ]
);
return { success: true };
}
When a redemption is rejected, the saved points remain saved toward that reward. The learner can request again later.
Refund System
If a reward is deleted or deactivated, saved points are automatically refunded:
// From server/services/rewards-service.ts:69
async function refundSavingsForReward ( client : any , rewardId : string ) {
const { rows : savings } = await client . query (
`SELECT learner_id, saved_points FROM reward_goal_savings
WHERE reward_id = $1 AND saved_points > 0` ,
[ rewardId ]
);
for ( const s of savings ) {
// Credit points back to learner balance
await client . query (
`UPDATE learner_points
SET current_balance = current_balance + $1, last_updated = NOW()
WHERE learner_id = $2` ,
[ s . saved_points , s . learner_id ]
);
// Record ledger entry for the refund
await client . query (
`INSERT INTO points_ledger (learner_id, amount, source_type, source_id, description)
VALUES ($1, $2, 'GOAL_REFUND', $3, 'Reward removed — saved points refunded')` ,
[ s . learner_id , s . saved_points , rewardId ]
);
// Zero out the savings
await client . query (
`UPDATE reward_goal_savings SET saved_points = 0, updated_at = NOW()
WHERE learner_id = $1 AND reward_id = $2` ,
[ s . learner_id , rewardId ]
);
}
return savings . length ;
}
Max Redemptions
Rewards can have optional redemption limits:
Unlimited
One-Time
Limited
maxRedemptions: nullLearner can earn this reward as many times as they want.
maxRedemptions: 1After first redemption, reward auto-deactivates.
maxRedemptions: 4Can be redeemed 4 times, then auto-deactivates.
Auto-Deactivation
When current_redemptions >= max_redemptions:
Reward is_active set to FALSE
All learners’ saved points for that reward are refunded
Reward no longer appears in learner’s available rewards
Reward Progress Tracking
Learners can see their progress toward each reward:
// From server/services/rewards-service.ts:587
export async function getRewardSummaryForLearner ( learnerId : number ) {
const { rows } = await pool . query (
`SELECT r.id, r.title, r.token_cost, r.image_emoji, r.color,
COALESCE(gs.saved_points, 0) AS saved_points,
COALESCE(rr_pending.cnt, 0) AS pending_redemptions
FROM rewards r
JOIN users u ON u.id = $1
LEFT JOIN reward_goal_savings gs
ON gs.reward_id = r.id AND gs.learner_id = $1
LEFT JOIN (
SELECT reward_id, COUNT(*) AS cnt
FROM reward_redemptions
WHERE learner_id=$1 AND status='PENDING'
GROUP BY reward_id
) rr_pending ON rr_pending.reward_id = r.id
WHERE r.parent_id = u.parent_id AND r.is_active = TRUE
ORDER BY r.token_cost ASC` ,
[ learnerId ]
);
return rows . map ( row => ({
id: row . id ,
title: row . title ,
tokenCost: row . token_cost ,
imageEmoji: row . image_emoji ?? '🎁' ,
color: row . color ?? '#4A90D9' ,
savedPoints: row . saved_points ?? 0 ,
hasPendingRedemption: row . pending_redemptions > 0 ,
progressPct: Math . min ( 100 , Math . round (
(( row . saved_points ?? 0 ) / row . token_cost ) * 100
)),
}));
}
Example response:
[
{
"id" : "reward_123" ,
"title" : "30 minutes screen time" ,
"tokenCost" : 500 ,
"imageEmoji" : "📺" ,
"color" : "#4A90D9" ,
"savedPoints" : 350 ,
"hasPendingRedemption" : false ,
"progressPct" : 70
}
]
Best Practices for Parents
Consider how much effort is required. A 30-minute activity should cost less than a major purchase. Typical ranges:
Small privileges: 200-500 points
Medium treats: 500-1500 points
Large rewards: 1500-5000 points
Offer rewards at different price points so learners have both short-term and long-term goals to work toward.
Approve redemptions promptly and consistently. If a learner meets the criteria, honor the reward.
Use Max Redemptions Wisely
For expensive or one-time items (“New bicycle”), use maxRedemptions: 1. For repeatable treats, use null (unlimited).
Clear descriptions help learners understand exactly what they’re working toward.