Skip to main content

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

1

Parent Creates Reward

Parent defines a reward with title, description, point cost, and optional limits
2

Learner Saves Points

Learner delegates points from general balance toward the reward goal
3

Goal Reached

When saved points ≥ reward cost, learner can request redemption
4

Parent Reviews

Parent approves or rejects the redemption request
5

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

Request submitted, waiting for parent approval

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:
  1. Saved points are reset to 0 (not refunded to general balance)
  2. The reward can be earned again (repeatable)
  3. 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:
maxRedemptions: nullLearner can earn this reward as many times as they want.

Auto-Deactivation

When current_redemptions >= max_redemptions:
  1. Reward is_active set to FALSE
  2. All learners’ saved points for that reward are refunded
  3. 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.
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.